diff --git a/.appveyor.yml b/.appveyor.yml index 5d9dce4bcaa0..63801100307d 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -11,6 +11,8 @@ branches: clone_depth: 50 +image: Visual Studio 2017 + environment: global: @@ -18,20 +20,14 @@ environment: PYTHONIOENCODING: UTF-8 PYTEST_ARGS: -raR --numprocesses=auto --timeout=300 --durations=25 --cov-report= --cov=lib --log-level=DEBUG - PINNEDVERS: "pyzmq!=21.0.0,!=22.0.0" matrix: - # In theory we could use a single CONDA_INSTALL_LOCN because we construct - # the envs anyway. But using one for the right python version hopefully - # making things faster due to package caching. - - PYTHON_VERSION: "3.7" - CONDA_INSTALL_LOCN: "C:\\Miniconda37-x64" - TEST_ALL: "no" - EXTRAREQS: "-r requirements/testing/extra.txt" - PYTHON_VERSION: "3.8" - CONDA_INSTALL_LOCN: "C:\\Miniconda37-x64" + CONDA_INSTALL_LOCN: "C:\\Miniconda3-x64" + TEST_ALL: "no" + - PYTHON_VERSION: "3.9" + CONDA_INSTALL_LOCN: "C:\\Miniconda3-x64" TEST_ALL: "no" - EXTRAREQS: "-r requirements/testing/extra.txt" # We always use a 64-bit machine, but can build x86 distributions # with the PYTHON_ARCH variable @@ -55,19 +51,13 @@ install: - conda config --prepend channels conda-forge # For building, use a new environment - - conda create -q -n test-environment python=%PYTHON_VERSION% tk "pip<22.0" - - activate test-environment - # pull pywin32 from conda because on py38 there is something wrong with finding - # the dlls when insalled from pip + # Add python version to environment + # `^ ` escapes spaces for indentation + - echo ^ ^ - python=%PYTHON_VERSION% >> environment.yml + - conda env create -f environment.yml + - activate mpl-dev - conda install -c conda-forge pywin32 - # install pyqt from conda-forge - - conda install -c conda-forge pyqt - echo %PYTHON_VERSION% %TARGET_ARCH% - # Install dependencies from PyPI. - - python -m pip install --upgrade -r requirements/testing/all.txt %EXTRAREQS% %PINNEDVERS% - # Install optional dependencies from PyPI. - # Sphinx is needed to run sphinxext tests - - python -m pip install --upgrade sphinx # Show the installed packages + versions - conda list @@ -91,23 +81,13 @@ test_script: - echo The following args are passed to pytest %PYTEST_ARGS% - pytest %PYTEST_ARGS% -after_test: - # After the tests were a success, build wheels. - # Hide the output, the copied files really clutter the build log... - - 'python setup.py bdist_wheel > NUL:' - - dir dist\ - - echo finished... - artifacts: - - path: dist\* - name: packages - - path: result_images\* name: result_images type: zip on_finish: - - pip install codecov + - conda install codecov - codecov -e PYTHON_VERSION PLATFORM on_failure: diff --git a/.circleci/config.yml b/.circleci/config.yml index 29949821214f..9c1f09172060 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ commands: git remote add upstream https://github.com/matplotlib/matplotlib.git fi git fetch upstream - if [[ "$CIRCLE_BRANCH" != "master" ]] && \ + if [[ "$CIRCLE_BRANCH" != "main" ]] && \ [[ "$CIRCLE_PR_NUMBER" != "" ]]; then echo "Merging ${CIRCLE_PR_NUMBER}" git pull --ff-only upstream "refs/pull/${CIRCLE_PR_NUMBER}/merge" @@ -55,10 +55,12 @@ commands: texlive-latex-recommended \ texlive-pictures \ texlive-xetex \ + ttf-wqy-zenhei \ graphviz \ fonts-crosextra-carlito \ fonts-freefont-otf \ fonts-humor-sans \ + fonts-noto-cjk \ optipng fonts-install: @@ -86,7 +88,7 @@ commands: python -m pip install --upgrade --user wheel python -m pip install --upgrade --user 'setuptools!=60.6.0' - deps-install: + doc-deps-install: parameters: numpy_version: type: string @@ -95,6 +97,8 @@ commands: - run: name: Install Python dependencies command: | + python -m pip install --no-deps --user \ + git+https://github.com/matplotlib/mpl-sphinx-theme.git python -m pip install --user \ numpy<< parameters.numpy_version >> codecov coverage \ -r requirements/doc/doc-requirements.txt @@ -133,11 +137,12 @@ commands: export SOURCE_DATE_EPOCH="$(git log -1 --format=%at $(git describe --abbrev=0))" # Set release mode only when deploying to devdocs. if [ "$CIRCLE_PROJECT_USERNAME" = "matplotlib" ] && \ - [ "$CIRCLE_BRANCH" = "master" ] && \ + [ "$CIRCLE_BRANCH" = "main" ] && \ [ "$CIRCLE_PR_NUMBER" = "" ]; then export RELEASE_TAG='-t release' fi - make html O="-T $RELEASE_TAG" + mkdir -p logs + make html O="-T $RELEASE_TAG -j4 -w /tmp/sphinxerrorswarnings.log" rm -r build/html/_sources working_directory: doc - save_cache: @@ -145,11 +150,51 @@ commands: paths: - doc/build/doctrees + doc-show-errors-warnings: + steps: + - run: + name: Extract possible build errors and warnings + command: | + (grep "WARNING\|ERROR" /tmp/sphinxerrorswarnings.log || + echo "No errors or warnings") + # Save logs as an artifact, and convert from absolute paths to + # repository-relative paths. + sed "s~$PWD/~~" /tmp/sphinxerrorswarnings.log > \ + doc/logs/sphinx-errors-warnings.log + when: always + - store_artifacts: + path: doc/logs/sphinx-errors-warnings.log + + doc-show-deprecations: + steps: + - run: + name: Extract possible deprecation warnings in examples and tutorials + command: | + (grep -rl DeprecationWarning doc/build/html/gallery || + echo "No deprecation warnings in gallery") + (grep -rl DeprecationWarning doc/build/html/plot_types || + echo "No deprecation warnings in plot_types") + (grep -rl DeprecationWarning doc/build/html/tutorials || + echo "No deprecation warnings in tutorials") + # Save deprecations that are from this absolute directory, and + # convert to repository-relative paths. + (grep -Ero --no-filename "$PWD/.+DeprecationWarning.+$" \ + doc/build/html/{gallery,plot_types,tutorials} || echo) | \ + sed "s~$PWD/~~" > doc/logs/sphinx-deprecations.log + when: always + - store_artifacts: + path: doc/logs/sphinx-deprecations.log + doc-bundle: steps: - run: name: Bundle sphinx-gallery documentation artifacts - command: tar cf doc/build/sphinx-gallery-files.tar.gz doc/api/_as_gen doc/gallery doc/tutorials + command: > + tar cf doc/build/sphinx-gallery-files.tar.gz + doc/api/_as_gen + doc/gallery + doc/plot_types + doc/tutorials when: always - store_artifacts: path: doc/build/sphinx-gallery-files.tar.gz @@ -163,6 +208,7 @@ jobs: docs-python38: docker: - image: cimg/python:3.8 + resource_class: large steps: - checkout - check-skip @@ -172,10 +218,12 @@ jobs: - fonts-install - pip-install - - deps-install - mpl-install + - doc-deps-install - doc-build + - doc-show-errors-warnings + - doc-show-deprecations - doc-bundle @@ -186,7 +234,8 @@ jobs: - add_ssh_keys: fingerprints: - - "6b:83:76:a5:7d:bd:ce:19:a4:e3:81:e0:80:16:a4:fe" + - "be:c3:c1:d8:fb:a1:0e:37:71:72:d7:a3:40:13:8f:14" + - deploy: name: "Deploy new docs" command: ./.circleci/deploy-docs.sh @@ -199,4 +248,6 @@ workflows: version: 2 build: jobs: + # NOTE: If you rename this job, then you must update the `if` condition + # and `circleci-jobs` option in `.github/workflows/circleci.yml`. - docs-python38 diff --git a/.circleci/deploy-docs.sh b/.circleci/deploy-docs.sh index 83037d2561a4..8801d5fd073e 100755 --- a/.circleci/deploy-docs.sh +++ b/.circleci/deploy-docs.sh @@ -3,10 +3,10 @@ set -e if [ "$CIRCLE_PROJECT_USERNAME" != "matplotlib" ] || \ - [ "$CIRCLE_BRANCH" != "master" ] || \ + [ "$CIRCLE_BRANCH" != "main" ] || \ [[ "$CIRCLE_PULL_REQUEST" == https://github.com/matplotlib/matplotlib/pull/* ]]; then echo "Not uploading docs for ${CIRCLE_SHA1}"\ - "from non-master branch (${CIRCLE_BRANCH})"\ + "from non-main branch (${CIRCLE_BRANCH})"\ "or pull request (${CIRCLE_PULL_REQUEST})"\ "or non-Matplotlib org (${CIRCLE_PROJECT_USERNAME})." exit diff --git a/.circleci/fetch_doc_logs.py b/.circleci/fetch_doc_logs.py new file mode 100644 index 000000000000..40452cea7792 --- /dev/null +++ b/.circleci/fetch_doc_logs.py @@ -0,0 +1,63 @@ +""" +Download artifacts from CircleCI for a documentation build. + +This is run by the :file:`.github/workflows/circleci.yml` workflow in order to +get the warning/deprecation logs that will be posted on commits as checks. Logs +are downloaded from the :file:`docs/logs` artifact path and placed in the +:file:`logs` directory. + +Additionally, the artifact count for a build is produced as a workflow output, +by appending to the file specified by :env:`GITHUB_OUTPUT`. + +If there are no logs, an "ERROR" message is printed, but this is not fatal, as +the initial 'status' workflow runs when the build has first started, and there +are naturally no artifacts at that point. + +This script should be run by passing the CircleCI build URL as its first +argument. In the GitHub Actions workflow, this URL comes from +``github.event.target_url``. +""" +import json +import os +from pathlib import Path +import sys +from urllib.parse import urlparse +from urllib.request import urlopen + + +if len(sys.argv) != 2: + print('USAGE: fetch_doc_results.py CircleCI-build-url') + sys.exit(1) + +target_url = urlparse(sys.argv[1]) +*_, organization, repository, build_id = target_url.path.split('/') +print(f'Fetching artifacts from {organization}/{repository} for {build_id}') + +artifact_url = ( + f'https://circleci.com/api/v2/project/gh/' + f'{organization}/{repository}/{build_id}/artifacts' +) +print(artifact_url) +with urlopen(artifact_url) as response: + artifacts = json.load(response) +artifact_count = len(artifacts['items']) +print(f'Found {artifact_count} artifacts') + +with open(os.environ['GITHUB_OUTPUT'], 'w+') as fd: + fd.write(f'count={artifact_count}\n') + +logs = Path('logs') +logs.mkdir(exist_ok=True) + +found = False +for item in artifacts['items']: + path = item['path'] + if path.startswith('doc/logs/'): + path = Path(path).name + print(f'Downloading {path} from {item["url"]}') + with urlopen(item['url']) as response: + (logs / path).write_bytes(response.read()) + found = True + +if not found: + print('ERROR: Did not find any artifact logs!') diff --git a/.flake8 b/.flake8 index ca1d8f2d4bdc..490ea57d9891 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -max-line-length = 79 +max-line-length = 88 select = # flake8 default C90, E, F, W, @@ -42,7 +42,6 @@ exclude = per-file-ignores = setup.py: E402 - setupext.py: E501 tests.py: F401 lib/matplotlib/__init__.py: E402, F401 @@ -50,39 +49,21 @@ per-file-ignores = lib/matplotlib/_cm.py: E122, E202, E203, E302 lib/matplotlib/_mathtext.py: E221, E251 lib/matplotlib/_mathtext_data.py: E122, E203, E261 - lib/matplotlib/animation.py: F401 lib/matplotlib/_animation_data.py: E501 lib/matplotlib/axes/__init__.py: F401, F403 - lib/matplotlib/axes/_axes.py: F401 - lib/matplotlib/backends/backend_*.py: F401 + lib/matplotlib/backends/backend_template.py: F401 lib/matplotlib/backends/qt_editor/formlayout.py: F401, F403 - lib/matplotlib/cbook/__init__.py: F401 - lib/matplotlib/cbook/deprecation.py: F401 lib/matplotlib/font_manager.py: E501 lib/matplotlib/image.py: F401, F403 - lib/matplotlib/lines.py: F401 lib/matplotlib/mathtext.py: E221, E251 lib/matplotlib/pylab.py: F401, F403 lib/matplotlib/pyplot.py: F401, F811 - lib/matplotlib/style/__init__.py: F401 - lib/matplotlib/testing/conftest.py: F401 - lib/matplotlib/tests/conftest.py: F401 - lib/matplotlib/tests/test_backend_qt.py: F401 lib/matplotlib/tests/test_mathtext.py: E501 - lib/matplotlib/text.py: F401 lib/matplotlib/transforms.py: E201, E202, E203 - lib/matplotlib/tri/__init__.py: F401, F403 - lib/matplotlib/tri/triinterpolate.py: E201, E221 - lib/mpl_toolkits/axes_grid/*: F401, F403 - lib/mpl_toolkits/axes_grid1/__init__.py: F401 + lib/matplotlib/tri/_triinterpolate.py: E201, E221 lib/mpl_toolkits/axes_grid1/axes_size.py: E272 lib/mpl_toolkits/axisartist/__init__.py: F401 lib/mpl_toolkits/axisartist/angle_helper.py: E221 - lib/mpl_toolkits/axisartist/axes_divider.py: F401 - lib/mpl_toolkits/axisartist/axes_rgb.py: F401 - lib/mpl_toolkits/axisartist/axislines.py: F401 - lib/mpl_toolkits/mplot3d/__init__.py: F401 - lib/mpl_toolkits/tests/conftest.py: F401 lib/pylab.py: F401, F403 doc/conf.py: E402 @@ -100,8 +81,10 @@ per-file-ignores = tutorials/introductory/images.py: E402, E501 tutorials/introductory/pyplot.py: E402, E501 tutorials/introductory/sample_plots.py: E501 - tutorials/introductory/usage.py: E703 + tutorials/introductory/quick_start.py: E703 + tutorials/introductory/animation_tutorial.py: E501 tutorials/text/annotations.py: E402, E501 + tutorials/text/mathtext.py: E501 tutorials/text/text_intro.py: E402 tutorials/text/text_props.py: E501 tutorials/text/usetex.py: E501 @@ -120,6 +103,7 @@ per-file-ignores = examples/subplots_axes_and_figures/demo_constrained_layout.py: E402 examples/text_labels_and_annotations/custom_legends.py: E402 examples/ticks/date_concise_formatter.py: E402 + examples/ticks/date_formatters_locators.py: F401 examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py: E402 examples/user_interfaces/embedding_in_gtk3_sgskip.py: E402 examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py: E402 @@ -131,3 +115,4 @@ per-file-ignores = examples/user_interfaces/pylab_with_gtk4_sgskip.py: E402 examples/user_interfaces/toolmanager_sgskip.py: E402 examples/userdemo/pgf_preamble_sgskip.py: E402 +force-check = True diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000000..33ff9446d8a6 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,11 @@ +# style: end-of-file-fixer pre-commit hook +c1a33a481b9c2df605bcb9bef9c19fe65c3dac21 + +# style: trailing-whitespace pre-commit hook +213061c0804530d04bbbd5c259f10dc8504e5b2b + +# style: check-docstring-first pre-commit hook +046533797725293dfc2a6edb9f536b25f08aa636 + +# chore: fix spelling errors +686c9e5a413e31c46bb049407d5eca285bcab76d diff --git a/.git_archival.txt b/.git_archival.txt index 95cb3eea4e33..3994ec0a83ea 100644 --- a/.git_archival.txt +++ b/.git_archival.txt @@ -1 +1,4 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true)$ ref-names: $Format:%D$ diff --git a/.gitattributes b/.gitattributes index 0db21d3c8659..a0c2c8627af7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ * text=auto +*.m diff=objc *.ppm binary *.svg binary *.svg linguist-language=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 87f566657fe3..985762649b67 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,17 +1,13 @@ name: Bug Report -description: Report a bug or issue with Matplotlib +description: Report a bug or issue with Matplotlib. title: "[Bug]: " body: - - type: markdown - attributes: - value: | - To help us understand and resolve your issue, please fill out the form to the best of your ability. - type: textarea id: summary attributes: label: Bug summary - description: Please provide 1-2 short sentences that succinctly describes the bug - placeholder: + description: Describe the bug in 1-2 short sentences + placeholder: value: validations: required: true @@ -19,8 +15,9 @@ body: id: reproduction attributes: label: Code for reproduction - description: If possible, please provide a minimum self-contained example. - placeholder: Paste your code here + description: | + If possible, please provide a minimum self-contained example. + placeholder: Paste your code here. This field is automatically formatted as Python code. render: python validations: required: true @@ -28,63 +25,62 @@ body: id: actual attributes: label: Actual outcome - description: Please provide the output produced by the above code, which may be a screenshot, console output, etc. - placeholder: if applicable, paste the console output and images here + description: | + Paste the output produced by the code provided above, e.g. + console output, images/videos produced by the code, any relevant screenshots/screencasts, etc. validations: required: true - type: textarea id: expected attributes: label: Expected outcome - description: Please provide a description (or visual example) of the expected outcome from the code snippet. If this used to work in an earlier version of Matplotlib, please note the version it used to work on + description: Describe (or provide a visual example of) the expected outcome from the code snippet. validations: required: true + - type: textarea + id: details + attributes: + label: Additional information + description: | + - What are the conditions under which this bug happens? input parameters, edge cases, etc? + - Has this worked in earlier versions? + - Do you know why this bug is happening? + - Do you maybe even know a fix? - type: input id: operating-system attributes: label: Operating system - description: What operating system are you using? - placeholder: Windows, OS/X, Arch, Debian, Ubuntu, etc. + description: Windows, OS/X, Arch, Debian, Ubuntu, etc. - type: input id: matplotlib-version attributes: label: Matplotlib Version - placeholder: import matplotlib; print(matplotlib.__version__) + description: "From Python prompt: `import matplotlib; print(matplotlib.__version__)`" validations: required: true - type: input id: matplotlib-backend - attributes: + attributes: label: Matplotlib Backend - placeholder: print(matplotlib.get_backend()) + description: "From Python prompt: `import matplotlib; print(matplotlib.get_backend())`" - type: input id: python-version attributes: label: Python version - placeholder: python --version + description: "In console: `python --version`" - type: input id: jupyter-version attributes: label: Jupyter version - placeholder: jupyter notebook --version, jupyter lab --version - - type: textarea - id: other-libraries - attributes: - label: Other libraries - description: please provide the versions of any other libraries that are relevant to this bug report. + description: "In console: `jupyter notebook --version` or `jupyter lab --version`" - type: dropdown id: install attributes: label: Installation - description: how did you install matplotlib? + description: How did you install matplotlib? options: - pip - conda - - Linux package manager (Debian/Fedora/etc.) - - source - - type: input - id: conda-channel - attributes: - label: Conda channel - description: If you installed from conda, please specify which channel you used if not the default? - placeholder: conda-forge + - Linux package manager + - from source (.tar.gz) + - git checkout diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index d4b9eadd5b70..ea0eb385baaf 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -3,21 +3,19 @@ description: Create a report to help us improve the documentation title: "[Doc]: " labels: [Documentation] body: - - type: markdown - attributes: - value: | - To help us understand and resolve your issue, please fill out the form to the best of your ability. - type: input id: link attributes: label: Documentation Link - description: Please provide a link to any documentation or examples that you are referencing. - placeholder: https://matplotlib.org/devdocs/ + description: | + Link to any documentation or examples that you are referencing. + Suggested improvements should be based on the development version of the docs: https://matplotlib.org/devdocs/ + placeholder: https://matplotlib.org/devdocs/... - type: textarea id: problem attributes: label: Problem - description: What is missing, unclear, or wrong in the documentation? + description: What is missing, unclear, or wrong in the documentation? placeholder: | * I found [...] to be unclear because [...] * [...] made me think that [...] when really it should be [...] @@ -28,24 +26,7 @@ body: id: improvement attributes: label: Suggested improvement - description: How can we improve the documentation? placeholder: | * This line should be be changed to say [...] * Include a paragraph explaining [...] * Add a figure showing [...] - - type: input - id: matplotlib-version - attributes: - label: Matplotlib Version - placeholder: import matplotlib; print(matplotlib.__version__) - validations: - required: true - - type: input - id: matplotlib-doc-version - attributes: - label: Matplotlib documentation version - placeholder: check under the logo - validations: - required: true - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4fbef25bf53b..5274c287569c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -6,12 +6,12 @@ body: - type: markdown attributes: value: | - Thanks for thinking of a way to improve Matplotlib. Please search the issues for relevant feature requests before creating a new feature request. + Please search the [issues](https://github.com/matplotlib/matplotlib/issues) for relevant feature requests before creating a new feature request. - type: textarea id: problem attributes: label: Problem - description: Please provide a clear and concise description of the problem this feature will solve. + description: Briefly describe the problem this feature will solve. (2-4 sentences) placeholder: | * I'm always frustrated when [...] because [...] * I would like it if [...] happened when I [...] because [...] @@ -22,15 +22,4 @@ body: id: solution attributes: label: Proposed solution - description: Please provide a clear and concise description of a way to accomplish what you want. - placeholder: Add an option so that when [...] [...] will happen - - type: textarea - id: prior-art - attributes: - label: Additional context and prior art - description: Add any other context or screenshots about the feature request here. You can also include links to examples of other programs that have something similar to your request. - placeholder: Another project [...] solved this by [...] - - - - + description: Describe a way to accomplish the goals of this feature request. diff --git a/.github/ISSUE_TEMPLATE/maintenance.yml b/.github/ISSUE_TEMPLATE/maintenance.yml index eb2458dda6e8..746ab55ef0e3 100644 --- a/.github/ISSUE_TEMPLATE/maintenance.yml +++ b/.github/ISSUE_TEMPLATE/maintenance.yml @@ -7,11 +7,11 @@ body: id: summary attributes: label: Summary - description: Please provide 1-2 short sentences that succinctly describes what could be improved. + description: Please provide 1-2 short sentences that succinctly describes what could be improved. validations: required: true - type: textarea id: fix attributes: label: Proposed fix - description: Please describe how you think this could be improved. + description: Please describe how you think this could be improved. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6c3479d74741..9b2f5b5c7275 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,13 +4,15 @@ -- [ ] Has pytest style unit tests (and `pytest` passes). -- [ ] Is [Flake 8](https://flake8.pycqa.org/en/latest/) compliant (run `flake8` on changed files to check). -- [ ] New features are documented, with examples if plot related. +**Documentation and Tests** +- [ ] Has pytest style unit tests (and `pytest` passes) - [ ] Documentation is sphinx and numpydoc compliant (the docs should [build](https://matplotlib.org/devel/documenting_mpl.html#building-the-docs) without error). -- [ ] Conforms to Matplotlib style conventions (install `flake8-docstrings` and run `flake8 --docstring-convention=all`). -- [ ] New features have an entry in `doc/users/next_whats_new/` (follow instructions in README.rst there). -- [ ] API changes documented in `doc/api/next_api_changes/` (follow instructions in README.rst there). +- [ ] New plotting related features are documented with examples. + +**Release Notes** +- [ ] New features are marked with a `.. versionadded::` directive in the docstring and documented in `doc/users/next_whats_new/` +- [ ] API changes are marked with a `.. versionchanged::` directive in the docstring and documented in `doc/api/next_api_changes/` +- [ ] Release notes conform with instructions in `next_whats_new/README.rst` or `next_api_changes/README.rst` + + + + + + + + image/svg+xml + + + + + + + + width + + + + + + + + + + + + + + + + + + + headaxislength + headlength + + + + + + + + + + + + headwidth + + + + + + + + + length + + + + + + + + + diff --git a/doc/_static/switcher.json b/doc/_static/switcher.json new file mode 100644 index 000000000000..b96977bdd725 --- /dev/null +++ b/doc/_static/switcher.json @@ -0,0 +1,37 @@ +[ + { + "name": "3.7 (stable)", + "version": "stable", + "url": "https://matplotlib.org/stable/" + }, + { + "name": "3.8 (dev)", + "version": "dev", + "url": "https://matplotlib.org/devdocs/" + }, + { + "name": "3.6", + "version": "3.6.3", + "url": "https://matplotlib.org/3.6.3/" + }, + { + "name": "3.5", + "version": "3.5.3", + "url": "https://matplotlib.org/3.5.3/" + }, + { + "name": "3.4", + "version": "3.4.3", + "url": "https://matplotlib.org/3.4.3/" + }, + { + "name": "3.3", + "version": "3.3.4", + "url": "https://matplotlib.org/3.3.4/" + }, + { + "name": "2.2", + "version": "2.2.4", + "url": "https://matplotlib.org/2.2.4/" + } +] diff --git a/doc/_static/zenodo_cache/6513224.svg b/doc/_static/zenodo_cache/6513224.svg new file mode 100644 index 000000000000..fd54dfcb9abb --- /dev/null +++ b/doc/_static/zenodo_cache/6513224.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.6513224 + + + 10.5281/zenodo.6513224 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/6982547.svg b/doc/_static/zenodo_cache/6982547.svg new file mode 100644 index 000000000000..6eb000d892da --- /dev/null +++ b/doc/_static/zenodo_cache/6982547.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.6982547 + + + 10.5281/zenodo.6982547 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/7084615.svg b/doc/_static/zenodo_cache/7084615.svg new file mode 100644 index 000000000000..9bb362063414 --- /dev/null +++ b/doc/_static/zenodo_cache/7084615.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.7084615 + + + 10.5281/zenodo.7084615 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/7162185.svg b/doc/_static/zenodo_cache/7162185.svg new file mode 100644 index 000000000000..ea0966377194 --- /dev/null +++ b/doc/_static/zenodo_cache/7162185.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.7162185 + + + 10.5281/zenodo.7162185 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/7275322.svg b/doc/_static/zenodo_cache/7275322.svg new file mode 100644 index 000000000000..2d0fd408b504 --- /dev/null +++ b/doc/_static/zenodo_cache/7275322.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.7275322 + + + 10.5281/zenodo.7275322 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/7527665.svg b/doc/_static/zenodo_cache/7527665.svg new file mode 100644 index 000000000000..3c3e0b7a8b2a --- /dev/null +++ b/doc/_static/zenodo_cache/7527665.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.7527665 + + + 10.5281/zenodo.7527665 + + + \ No newline at end of file diff --git a/doc/_templates/automodule.rst b/doc/_templates/automodule.rst index e9f2a755d413..984c12e00d03 100644 --- a/doc/_templates/automodule.rst +++ b/doc/_templates/automodule.rst @@ -5,7 +5,7 @@ treat this separately (sphinx-doc/sphinx/issues/4874) .. automodule:: {{ fullname }} :members: - + {% else %} .. automodule:: {{ fullname }} @@ -18,7 +18,7 @@ Classes ------- -.. autosummary:: +.. autosummary:: :template: autosummary.rst :toctree: {% for item in classes %}{% if item not in ['zip', 'map', 'reduce'] %} @@ -32,7 +32,7 @@ Classes Functions --------- -.. autosummary:: +.. autosummary:: :template: autosummary.rst :toctree: diff --git a/doc/_templates/autosummary_class_only.rst b/doc/_templates/autosummary_class_only.rst new file mode 100644 index 000000000000..6611f04f5c0d --- /dev/null +++ b/doc/_templates/autosummary_class_only.rst @@ -0,0 +1,11 @@ +{{ fullname | escape | underline }} + + +.. currentmodule:: {{ module }} + + +{% if objtype in ['class'] %} +.. auto{{ objtype }}:: {{ objname }} + :no-members: + +{% endif %} diff --git a/doc/_templates/donate_sidebar.html b/doc/_templates/donate_sidebar.html index a4a07779bb77..071c92888c3c 100644 --- a/doc/_templates/donate_sidebar.html +++ b/doc/_templates/donate_sidebar.html @@ -1,8 +1,5 @@ - - - diff --git a/doc/api/_enums_api.rst b/doc/api/_enums_api.rst index c9e283305967..c38d535f3573 100644 --- a/doc/api/_enums_api.rst +++ b/doc/api/_enums_api.rst @@ -12,4 +12,3 @@ .. autoclass:: CapStyle :members: demo :exclude-members: butt, round, projecting, input_description - diff --git a/doc/api/afm_api.rst b/doc/api/afm_api.rst index 7e6d307cca33..bcae04150909 100644 --- a/doc/api/afm_api.rst +++ b/doc/api/afm_api.rst @@ -2,7 +2,12 @@ ``matplotlib.afm`` ****************** -.. automodule:: matplotlib.afm +.. attention:: + This module is considered internal. + + Its use is deprecated and it will be removed in a future version. + +.. automodule:: matplotlib._afm :members: :undoc-members: :show-inheritance: diff --git a/doc/api/animation_api.rst b/doc/api/animation_api.rst index c47416a3a424..d1b81e20b5c8 100644 --- a/doc/api/animation_api.rst +++ b/doc/api/animation_api.rst @@ -12,23 +12,15 @@ :backlinks: entry -Inheritance Diagrams -==================== - -.. inheritance-diagram:: matplotlib.animation.FuncAnimation matplotlib.animation.ArtistAnimation - :private-bases: - :parts: 1 - -.. inheritance-diagram:: matplotlib.animation.FFMpegFileWriter matplotlib.animation.FFMpegWriter matplotlib.animation.ImageMagickFileWriter matplotlib.animation.ImageMagickWriter - :private-bases: - :parts: 1 - Animation ========= The easiest way to make a live animation in Matplotlib is to use one of the `Animation` classes. +.. inheritance-diagram:: matplotlib.animation.FuncAnimation matplotlib.animation.ArtistAnimation + :parts: 1 + .. autosummary:: :toctree: _as_gen :nosignatures: @@ -105,13 +97,18 @@ this hopefully minimalist example gives a sense of how ``init_func`` and ``func`` are used inside of `FuncAnimation` and the theory of how 'blitting' works. +.. note:: + + The zorder of artists is not taken into account when 'blitting' + because the 'blitted' artists are always drawn on top. + The expected signature on ``func`` and ``init_func`` is very simple to keep `FuncAnimation` out of your book keeping and plotting logic, but this means that the callable objects you pass in must know what artists they should be working on. There are several approaches to handling this, of varying complexity and encapsulation. The simplest approach, which works quite well in the case of a script, is to define the -artist at a global scope and let Python sort things out. For example :: +artist at a global scope and let Python sort things out. For example:: import numpy as np import matplotlib.pyplot as plt @@ -119,7 +116,7 @@ artist at a global scope and let Python sort things out. For example :: fig, ax = plt.subplots() xdata, ydata = [], [] - ln, = plt.plot([], [], 'ro') + ln, = ax.plot([], [], 'ro') def init(): ax.set_xlim(0, 2*np.pi) @@ -136,8 +133,36 @@ artist at a global scope and let Python sort things out. For example :: init_func=init, blit=True) plt.show() -The second method is to use `functools.partial` to 'bind' artists to -function. A third method is to use closures to build up the required +The second method is to use `functools.partial` to pass arguments to the +function:: + + import numpy as np + import matplotlib.pyplot as plt + from matplotlib.animation import FuncAnimation + from functools import partial + + fig, ax = plt.subplots() + line1, = ax.plot([], [], 'ro') + + def init(): + ax.set_xlim(0, 2*np.pi) + ax.set_ylim(-1, 1) + return line1, + + def update(frame, ln, x, y): + x.append(frame) + y.append(np.sin(frame)) + ln.set_data(x, y) + return ln, + + ani = FuncAnimation( + fig, partial(update, ln=line1, x=[], y=[]), + frames=np.linspace(0, 2*np.pi, 128), + init_func=init, blit=True) + + plt.show() + +A third method is to use closures to build up the required artists and functions. A fourth method is to create a class. Examples @@ -170,6 +195,10 @@ Examples Writer Classes ============== +.. inheritance-diagram:: matplotlib.animation.FFMpegFileWriter matplotlib.animation.FFMpegWriter matplotlib.animation.ImageMagickFileWriter matplotlib.animation.ImageMagickWriter matplotlib.animation.PillowWriter matplotlib.animation.HTMLWriter + :top-classes: matplotlib.animation.AbstractMovieWriter + :parts: 1 + The provided writers fall into a few broad categories. The Pillow writer relies on the Pillow library to write the animation, keeping diff --git a/doc/api/artist_api.rst b/doc/api/artist_api.rst index 119d5fa3464c..3903bbd5924d 100644 --- a/doc/api/artist_api.rst +++ b/doc/api/artist_api.rst @@ -11,7 +11,7 @@ Inheritance Diagrams ==================== -.. inheritance-diagram:: matplotlib.axes._axes.Axes matplotlib.axes._base._AxesBase matplotlib.axis.Axis matplotlib.axis.Tick matplotlib.axis.XAxis matplotlib.axis.XTick matplotlib.axis.YAxis matplotlib.axis.YTick matplotlib.collections.AsteriskPolygonCollection matplotlib.collections.BrokenBarHCollection matplotlib.collections.CircleCollection matplotlib.collections.Collection matplotlib.collections.EllipseCollection matplotlib.collections.EventCollection matplotlib.collections.LineCollection matplotlib.collections.PatchCollection matplotlib.collections.PathCollection matplotlib.collections.PolyCollection matplotlib.collections.QuadMesh matplotlib.collections.RegularPolyCollection matplotlib.collections.StarPolygonCollection matplotlib.collections.TriMesh matplotlib.collections._CollectionWithSizes matplotlib.contour.ClabelText matplotlib.figure.Figure matplotlib.image.AxesImage matplotlib.image.BboxImage matplotlib.image.FigureImage matplotlib.image.NonUniformImage matplotlib.image.PcolorImage matplotlib.image._ImageBase matplotlib.legend.Legend matplotlib.lines.Line2D matplotlib.offsetbox.AnchoredOffsetbox matplotlib.offsetbox.AnchoredText matplotlib.offsetbox.AnnotationBbox matplotlib.offsetbox.AuxTransformBox matplotlib.offsetbox.DrawingArea matplotlib.offsetbox.HPacker matplotlib.offsetbox.OffsetBox matplotlib.offsetbox.OffsetImage matplotlib.offsetbox.PackerBase matplotlib.offsetbox.PaddedBox matplotlib.offsetbox.TextArea matplotlib.offsetbox.VPacker matplotlib.patches.Arc matplotlib.patches.Arrow matplotlib.patches.Circle matplotlib.patches.CirclePolygon matplotlib.patches.ConnectionPatch matplotlib.patches.Ellipse matplotlib.patches.FancyArrow matplotlib.patches.FancyArrowPatch matplotlib.patches.FancyBboxPatch matplotlib.patches.Patch matplotlib.patches.PathPatch matplotlib.patches.StepPatch matplotlib.patches.Polygon matplotlib.patches.Rectangle matplotlib.patches.RegularPolygon matplotlib.patches.Shadow matplotlib.patches.Wedge matplotlib.projections.geo.AitoffAxes matplotlib.projections.geo.GeoAxes matplotlib.projections.geo.HammerAxes matplotlib.projections.geo.LambertAxes matplotlib.projections.geo.MollweideAxes matplotlib.projections.polar.PolarAxes matplotlib.quiver.Barbs matplotlib.quiver.Quiver matplotlib.quiver.QuiverKey matplotlib.spines.Spine matplotlib.table.Cell matplotlib.table.CustomCell matplotlib.table.Table matplotlib.text.Annotation matplotlib.text.Text +.. inheritance-diagram:: matplotlib.axes._axes.Axes matplotlib.axes._base._AxesBase matplotlib.axis.Axis matplotlib.axis.Tick matplotlib.axis.XAxis matplotlib.axis.XTick matplotlib.axis.YAxis matplotlib.axis.YTick matplotlib.collections.AsteriskPolygonCollection matplotlib.collections.BrokenBarHCollection matplotlib.collections.CircleCollection matplotlib.collections.Collection matplotlib.collections.EllipseCollection matplotlib.collections.EventCollection matplotlib.collections.LineCollection matplotlib.collections.PatchCollection matplotlib.collections.PathCollection matplotlib.collections.PolyCollection matplotlib.collections.QuadMesh matplotlib.collections.RegularPolyCollection matplotlib.collections.StarPolygonCollection matplotlib.collections.TriMesh matplotlib.collections._CollectionWithSizes matplotlib.contour.ClabelText matplotlib.contour.ContourSet matplotlib.contour.QuadContourSet matplotlib.figure.FigureBase matplotlib.figure.Figure matplotlib.figure.SubFigure matplotlib.image.AxesImage matplotlib.image.BboxImage matplotlib.image.FigureImage matplotlib.image.NonUniformImage matplotlib.image.PcolorImage matplotlib.image._ImageBase matplotlib.legend.Legend matplotlib.lines.Line2D matplotlib.offsetbox.AnchoredOffsetbox matplotlib.offsetbox.AnchoredText matplotlib.offsetbox.AnnotationBbox matplotlib.offsetbox.AuxTransformBox matplotlib.offsetbox.DrawingArea matplotlib.offsetbox.HPacker matplotlib.offsetbox.OffsetBox matplotlib.offsetbox.OffsetImage matplotlib.offsetbox.PackerBase matplotlib.offsetbox.PaddedBox matplotlib.offsetbox.TextArea matplotlib.offsetbox.VPacker matplotlib.patches.Annulus matplotlib.patches.Arc matplotlib.patches.Arrow matplotlib.patches.Circle matplotlib.patches.CirclePolygon matplotlib.patches.ConnectionPatch matplotlib.patches.Ellipse matplotlib.patches.FancyArrow matplotlib.patches.FancyArrowPatch matplotlib.patches.FancyBboxPatch matplotlib.patches.Patch matplotlib.patches.PathPatch matplotlib.patches.Polygon matplotlib.patches.Rectangle matplotlib.patches.RegularPolygon matplotlib.patches.Shadow matplotlib.patches.StepPatch matplotlib.patches.Wedge matplotlib.projections.geo.AitoffAxes matplotlib.projections.geo.GeoAxes matplotlib.projections.geo.HammerAxes matplotlib.projections.geo.LambertAxes matplotlib.projections.geo.MollweideAxes matplotlib.projections.polar.PolarAxes matplotlib.projections.polar.RadialAxis matplotlib.projections.polar.RadialTick matplotlib.projections.polar.ThetaAxis matplotlib.projections.polar.ThetaTick matplotlib.quiver.Barbs matplotlib.quiver.Quiver matplotlib.quiver.QuiverKey matplotlib.spines.Spine matplotlib.table.Cell matplotlib.table.Table matplotlib.text.Annotation matplotlib.text.Text matplotlib.tri.TriContourSet :parts: 1 :private-bases: @@ -27,6 +27,7 @@ Interactive ----------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -35,6 +36,8 @@ Interactive Artist.pchanged Artist.get_cursor_data Artist.format_cursor_data + Artist.set_mouseover + Artist.get_mouseover Artist.mouseover Artist.contains Artist.pick @@ -46,6 +49,7 @@ Clipping -------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -60,6 +64,7 @@ Bulk Properties --------------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -72,6 +77,7 @@ Drawing ------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -99,12 +105,14 @@ Drawing Artist.get_agg_filter Artist.get_window_extent + Artist.get_tightbbox Artist.get_transformed_clip_path_and_affine Figure and Axes --------------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -119,6 +127,7 @@ Children -------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -129,6 +138,7 @@ Transform --------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -140,6 +150,7 @@ Units ----- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -151,6 +162,7 @@ Metadata -------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -165,6 +177,7 @@ Miscellaneous ------------- .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: @@ -177,6 +190,7 @@ Functions ========= .. autosummary:: + :template: autosummary.rst :toctree: _as_gen :nosignatures: diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 231e5be98ddf..8d0951626f72 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -2,6 +2,10 @@ ``matplotlib.axes`` ******************* +The `~.axes.Axes` class represents one (sub-)plot in a figure. It contains the +plotted data, axis ticks, labels, title, legend, etc. Its methods are the main +interface for manipulating the plot. + .. currentmodule:: matplotlib.axes .. contents:: Table of Contents @@ -14,30 +18,15 @@ :no-members: :no-undoc-members: -Inheritance -=========== -.. inheritance-diagram:: matplotlib.axes.Axes - :private-bases: - The Axes class ============== -.. autoclass:: Axes - :no-members: - :no-undoc-members: - :show-inheritance: - - -Subplots -======== - .. autosummary:: :toctree: _as_gen - :template: autosummary.rst + :template: autosummary_class_only.rst :nosignatures: - SubplotBase - subplot_class_factory + Axes Plotting ======== @@ -313,6 +302,7 @@ Axis labels, title, and legend Axes.get_xlabel Axes.set_ylabel Axes.get_ylabel + Axes.label_outer Axes.set_title Axes.get_title @@ -484,6 +474,9 @@ Axes position Axes.get_axes_locator Axes.set_axes_locator + Axes.get_subplotspec + Axes.set_subplotspec + Axes.reset_position Axes.get_position diff --git a/doc/api/axis_api.rst b/doc/api/axis_api.rst index 7161671f2ab6..e7da26a11706 100644 --- a/doc/api/axis_api.rst +++ b/doc/api/axis_api.rst @@ -41,7 +41,6 @@ Inheritance :nosignatures: Axis.clear - Axis.cla Axis.get_scale @@ -101,6 +100,7 @@ Ticks, tick labels and Offset text Axis.get_offset_text Axis.get_tick_padding + Axis.get_tick_params Axis.get_ticklabels Axis.get_ticklines Axis.get_ticklocs @@ -151,6 +151,7 @@ Interactive :nosignatures: Axis.contains + Axis.pickradius Axis.get_pickradius Axis.set_pickradius diff --git a/doc/api/backend_agg_api.rst b/doc/api/backend_agg_api.rst index 134a0d83cde0..752f348f8747 100644 --- a/doc/api/backend_agg_api.rst +++ b/doc/api/backend_agg_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_agg` -=================== +*********************************** +``matplotlib.backends.backend_agg`` +*********************************** .. automodule:: matplotlib.backends.backend_agg :members: diff --git a/doc/api/backend_bases_api.rst b/doc/api/backend_bases_api.rst index 990a1a091f81..c98a6af3e05e 100644 --- a/doc/api/backend_bases_api.rst +++ b/doc/api/backend_bases_api.rst @@ -1,6 +1,6 @@ - -:mod:`matplotlib.backend_bases` -================================ +**************************** +``matplotlib.backend_bases`` +**************************** .. automodule:: matplotlib.backend_bases :members: diff --git a/doc/api/backend_cairo_api.rst b/doc/api/backend_cairo_api.rst index 0ef9d6903007..66371ec6895c 100644 --- a/doc/api/backend_cairo_api.rst +++ b/doc/api/backend_cairo_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_cairo` -===================== +************************************* +``matplotlib.backends.backend_cairo`` +************************************* .. automodule:: matplotlib.backends.backend_cairo :members: diff --git a/doc/api/backend_gtk3_api.rst b/doc/api/backend_gtk3_api.rst index 7340ba003ee7..66c247555df4 100644 --- a/doc/api/backend_gtk3_api.rst +++ b/doc/api/backend_gtk3_api.rst @@ -1,8 +1,9 @@ -:mod:`.backend_gtk3agg`, :mod:`.backend_gtk3cairo` -================================================== +********************************************************************************** +``matplotlib.backends.backend_gtk3agg``, ``matplotlib.backends.backend_gtk3cairo`` +********************************************************************************** -**NOTE** These backends are not documented here, to avoid adding a dependency -to building the docs. +**NOTE** These :ref:`backends` are not documented here, to avoid adding a +dependency to building the docs. .. redirect-from:: /api/backend_gtk3agg_api .. redirect-from:: /api/backend_gtk3cairo_api diff --git a/doc/api/backend_gtk4_api.rst b/doc/api/backend_gtk4_api.rst index 81c33c9a6ffd..8f2e38d885a8 100644 --- a/doc/api/backend_gtk4_api.rst +++ b/doc/api/backend_gtk4_api.rst @@ -1,8 +1,9 @@ -:mod:`.backend_gtk4agg`, :mod:`.backend_gtk4cairo` -================================================== +********************************************************************************** +``matplotlib.backends.backend_gtk4agg``, ``matplotlib.backends.backend_gtk4cairo`` +********************************************************************************** -**NOTE** These backends are not documented here, to avoid adding a dependency -to building the docs. +**NOTE** These :ref:`backends` are not documented here, to avoid adding a +dependency to building the docs. .. redirect-from:: /api/backend_gtk4agg_api .. redirect-from:: /api/backend_gtk4cairo_api diff --git a/doc/api/backend_managers_api.rst b/doc/api/backend_managers_api.rst index faf4eda18de3..3e77e89dbbce 100644 --- a/doc/api/backend_managers_api.rst +++ b/doc/api/backend_managers_api.rst @@ -1,6 +1,6 @@ - -:mod:`matplotlib.backend_managers` -================================== +******************************* +``matplotlib.backend_managers`` +******************************* .. automodule:: matplotlib.backend_managers :members: diff --git a/doc/api/backend_mixed_api.rst b/doc/api/backend_mixed_api.rst index 35f7a49a08c4..61d770e56ccf 100644 --- a/doc/api/backend_mixed_api.rst +++ b/doc/api/backend_mixed_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_mixed` -===================== +************************************* +``matplotlib.backends.backend_mixed`` +************************************* .. automodule:: matplotlib.backends.backend_mixed :members: diff --git a/doc/api/backend_nbagg_api.rst b/doc/api/backend_nbagg_api.rst index dddeb7ac3c74..6596f461bbf0 100644 --- a/doc/api/backend_nbagg_api.rst +++ b/doc/api/backend_nbagg_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_nbagg` -===================== +************************************* +``matplotlib.backends.backend_nbagg`` +************************************* .. automodule:: matplotlib.backends.backend_nbagg :members: diff --git a/doc/api/backend_pdf_api.rst b/doc/api/backend_pdf_api.rst index 1743083b4de8..014c3e6e5017 100644 --- a/doc/api/backend_pdf_api.rst +++ b/doc/api/backend_pdf_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_pdf` -=================== +*********************************** +``matplotlib.backends.backend_pdf`` +*********************************** .. automodule:: matplotlib.backends.backend_pdf :members: diff --git a/doc/api/backend_pgf_api.rst b/doc/api/backend_pgf_api.rst index 2902742c744b..9f90beb72a1b 100644 --- a/doc/api/backend_pgf_api.rst +++ b/doc/api/backend_pgf_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_pgf` -=================== +*********************************** +``matplotlib.backends.backend_pgf`` +*********************************** .. automodule:: matplotlib.backends.backend_pgf :members: diff --git a/doc/api/backend_ps_api.rst b/doc/api/backend_ps_api.rst index d97e024b9d5c..d9b07d961b4b 100644 --- a/doc/api/backend_ps_api.rst +++ b/doc/api/backend_ps_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_ps` -================== +********************************** +``matplotlib.backends.backend_ps`` +********************************** .. automodule:: matplotlib.backends.backend_ps :members: diff --git a/doc/api/backend_qt_api.rst b/doc/api/backend_qt_api.rst index 622889c10e5c..ebfeedceb6e1 100644 --- a/doc/api/backend_qt_api.rst +++ b/doc/api/backend_qt_api.rst @@ -1,8 +1,9 @@ -:mod:`.backend_qtagg`, :mod:`.backend_qtcairo` -============================================== +****************************************************************************** +``matplotlib.backends.backend_qtagg``, ``matplotlib.backends.backend_qtcairo`` +****************************************************************************** -**NOTE** These backends are not (auto) documented here, to avoid adding a -dependency to building the docs. +**NOTE** These :ref:`backends` are not (auto) documented here, to avoid adding +a dependency to building the docs. .. redirect-from:: /api/backend_qt4agg_api .. redirect-from:: /api/backend_qt4cairo_api @@ -26,7 +27,32 @@ supported Python bindings per version -- `PyQt5 `_ and `PySide2 `_ for Qt5 and `PyQt6 `_ and `PySide6 -`_ for Qt6 [#]_. While both PyQt +`_ for Qt6 [#]_. Matplotlib's +qtagg and qtcairo backends (``matplotlib.backends.backend_qtagg`` and +``matplotlib.backend.backend_qtcairo``) support all these bindings, with common +parts factored out in the ``matplotlib.backends.backend_qt`` module. + +At runtime, these backends select the actual binding used as follows: + +1. If a binding's ``QtCore`` subpackage is already imported, that binding is + selected (the order for the check is ``PyQt6``, ``PySide6``, ``PyQt5``, + ``PySide2``). +2. If the :envvar:`QT_API` environment variable is set to one of "PyQt6", + "PySide6", "PyQt5", "PySide2" (case-insensitive), that binding is selected. + (See also the documentation on :ref:`environment-variables`.) +3. Otherwise, the first available backend in the order ``PyQt6``, ``PySide6``, + ``PyQt5``, ``PySide2`` is selected. + +In the past, Matplotlib used to have separate backends for each version of Qt +(e.g. qt4agg/``matplotlib.backends.backend_qt4agg`` and +qt5agg/``matplotlib.backends.backend_qt5agg``). This scheme was dropped when +support for Qt6 was added. For back-compatibility, qt5agg/``backend_qt5agg`` +and qt5cairo/``backend_qt5cairo`` remain available; selecting one of these +backends forces the use of a Qt5 binding. Their use is discouraged and +``backend_qtagg`` or ``backend_qtcairo`` should be preferred instead. However, +these modules will not be deprecated until we drop support for Qt5. + +While both PyQt and Qt for Python (aka PySide) closely mirror the underlying C++ API they are wrapping, they are not drop-in replacements for each other [#]_. To account for this, Matplotlib has an internal API compatibility layer in @@ -34,31 +60,6 @@ for this, Matplotlib has an internal API compatibility layer in module, we do not consider this to be a stable user-facing API and it may change without warning [#]_. -Previously Matplotlib's Qt backends had the Qt version number in the name, both -in the module and the :rc:`backend` value -(e.g. ``matplotlib.backends.backend_qt4agg`` and -``matplotlib.backends.backend_qt5agg``). However as part of adding support for -Qt6 we were able to support both Qt5 and Qt6 with a single implementation with -all of the Qt version and binding support handled in -`~matplotlib.backends.qt_compat`. A majority of the renderer agnostic Qt code -is now in `matplotlib.backends.backend_qt` with specialization for AGG in -``backend_qtagg`` and cairo in ``backend_qtcairo``. - -The binding is selected at run time based on what bindings are already imported -(by checking for the ``QtCore`` sub-package), then by the :envvar:`QT_API` -environment variable, and finally by the :rc:`backend`. In all cases when we -need to search, the order is ``PyQt6``, ``PySide6``, ``PyQt5``, ``PySide2``. -See :ref:`QT_API-usage` for usage instructions. - -The ``backend_qt5``, ``backend_qt5agg``, and ``backend_qt5cairo`` are provided -and force the use of a Qt5 binding for backwards compatibility. Their use is -discouraged (but not deprecated) and ``backend_qt``, ``backend_qtagg``, or -``backend_qtcairo`` should be preferred instead. However, these modules will -not be deprecated until we drop support for Qt5. - - - - .. [#] There is also `PyQt4 `_ and `PySide `_ for Qt4 but these are no diff --git a/doc/api/backend_svg_api.rst b/doc/api/backend_svg_api.rst index a6208a897431..2e7c1c9f5db1 100644 --- a/doc/api/backend_svg_api.rst +++ b/doc/api/backend_svg_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_svg` -=================== +*********************************** +``matplotlib.backends.backend_svg`` +*********************************** .. automodule:: matplotlib.backends.backend_svg :members: diff --git a/doc/api/backend_template_api.rst b/doc/api/backend_template_api.rst index 0f7bffee4b74..8198eeae121e 100644 --- a/doc/api/backend_template_api.rst +++ b/doc/api/backend_template_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_template` -======================== +**************************************** +``matplotlib.backends.backend_template`` +**************************************** .. automodule:: matplotlib.backends.backend_template :members: diff --git a/doc/api/backend_tk_api.rst b/doc/api/backend_tk_api.rst index 5eaa1873cebe..08abf603fd91 100644 --- a/doc/api/backend_tk_api.rst +++ b/doc/api/backend_tk_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_tkagg`, :mod:`.backend_tkcairo` -============================================== +****************************************************************************** +``matplotlib.backends.backend_tkagg``, ``matplotlib.backends.backend_tkcairo`` +****************************************************************************** .. automodule:: matplotlib.backends.backend_tkagg :members: diff --git a/doc/api/backend_tools_api.rst b/doc/api/backend_tools_api.rst index 7e3d5619cc35..994f32ac854e 100644 --- a/doc/api/backend_tools_api.rst +++ b/doc/api/backend_tools_api.rst @@ -1,6 +1,6 @@ - -:mod:`matplotlib.backend_tools` -=============================== +**************************** +``matplotlib.backend_tools`` +**************************** .. automodule:: matplotlib.backend_tools :members: diff --git a/doc/api/backend_webagg_api.rst b/doc/api/backend_webagg_api.rst index a4089473c903..ced3533da249 100644 --- a/doc/api/backend_webagg_api.rst +++ b/doc/api/backend_webagg_api.rst @@ -1,5 +1,6 @@ -:mod:`.backend_webagg` -====================== +************************************** +``matplotlib.backends.backend_webagg`` +************************************** .. automodule:: matplotlib.backends.backend_webagg :members: diff --git a/doc/api/backend_wx_api.rst b/doc/api/backend_wx_api.rst index a3b6d4155c11..abf506a161be 100644 --- a/doc/api/backend_wx_api.rst +++ b/doc/api/backend_wx_api.rst @@ -1,8 +1,9 @@ -:mod:`.backend_wxagg`, :mod:`.backend_wxcairo` -============================================== +****************************************************************************** +``matplotlib.backends.backend_wxagg``, ``matplotlib.backends.backend_wxcairo`` +****************************************************************************** -**NOTE** These backends are not documented here, to avoid adding a dependency -to building the docs. +**NOTE** These :ref:`backends` are not documented here, to avoid adding a +dependency to building the docs. .. redirect-from:: /api/backend_wxagg_api diff --git a/doc/api/blocking_input_api.rst b/doc/api/blocking_input_api.rst deleted file mode 100644 index 6ba612682ac4..000000000000 --- a/doc/api/blocking_input_api.rst +++ /dev/null @@ -1,8 +0,0 @@ -***************************** -``matplotlib.blocking_input`` -***************************** - -.. automodule:: matplotlib.blocking_input - :members: - :undoc-members: - :show-inheritance: diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index e7b6da70f641..970986ff4438 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -14,26 +14,44 @@ :no-members: :no-inherited-members: -Classes -------- +Color norms +----------- .. autosummary:: :toctree: _as_gen/ :template: autosummary.rst + Normalize + NoNorm + AsinhNorm BoundaryNorm - Colormap CenteredNorm - LightSource - LinearSegmentedColormap - ListedColormap + FuncNorm LogNorm - NoNorm - Normalize PowerNorm SymLogNorm TwoSlopeNorm - FuncNorm + +Colormaps +--------- + +.. autosummary:: + :toctree: _as_gen/ + :template: autosummary.rst + + Colormap + LinearSegmentedColormap + ListedColormap + +Other classes +------------- + +.. autosummary:: + :toctree: _as_gen/ + :template: autosummary.rst + + ColorSequenceRegistry + LightSource Functions --------- diff --git a/doc/api/dates_api.rst b/doc/api/dates_api.rst index 1150094aed1a..7a3e3bcf4a95 100644 --- a/doc/api/dates_api.rst +++ b/doc/api/dates_api.rst @@ -9,4 +9,5 @@ .. automodule:: matplotlib.dates :members: :undoc-members: + :exclude-members: rrule :show-inheritance: diff --git a/doc/api/docstring_api.rst b/doc/api/docstring_api.rst index 853ff93494cf..38a73a2e83d1 100644 --- a/doc/api/docstring_api.rst +++ b/doc/api/docstring_api.rst @@ -2,7 +2,12 @@ ``matplotlib.docstring`` ************************ -.. automodule:: matplotlib.docstring +.. attention:: + This module is considered internal. + + Its use is deprecated and it will be removed in a future version. + +.. automodule:: matplotlib._docstring :members: :undoc-members: :show-inheritance: diff --git a/doc/api/font_manager_api.rst b/doc/api/font_manager_api.rst index 8b698bacf0fe..3e043112380b 100644 --- a/doc/api/font_manager_api.rst +++ b/doc/api/font_manager_api.rst @@ -7,5 +7,9 @@ :undoc-members: :show-inheritance: + .. data:: fontManager + + The global instance of `FontManager`. + .. autoclass:: FontEntry :no-undoc-members: diff --git a/doc/api/fontconfig_pattern_api.rst b/doc/api/fontconfig_pattern_api.rst deleted file mode 100644 index 772900035df7..000000000000 --- a/doc/api/fontconfig_pattern_api.rst +++ /dev/null @@ -1,8 +0,0 @@ -********************************* -``matplotlib.fontconfig_pattern`` -********************************* - -.. automodule:: matplotlib.fontconfig_pattern - :members: - :undoc-members: - :show-inheritance: diff --git a/doc/api/ft2font.rst b/doc/api/ft2font.rst new file mode 100644 index 000000000000..a1f984abdda5 --- /dev/null +++ b/doc/api/ft2font.rst @@ -0,0 +1,8 @@ +********************** +``matplotlib.ft2font`` +********************** + +.. automodule:: matplotlib.ft2font + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/hatch_api.rst b/doc/api/hatch_api.rst new file mode 100644 index 000000000000..b706be379a15 --- /dev/null +++ b/doc/api/hatch_api.rst @@ -0,0 +1,8 @@ +******************** +``matplotlib.hatch`` +******************** + +.. automodule:: matplotlib.hatch + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/index.rst b/doc/api/index.rst index 1f9048787e69..2e8c33fa05e5 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -30,6 +30,62 @@ methods on them to plot data, add axis labels and a figure title. fig.set_facecolor('lightsteelblue') + +.. _usage_patterns: + +Usage patterns +-------------- + +Below we describe several common approaches to plotting with Matplotlib. See +:ref:`api_interfaces` for an explanation of the trade-offs between the supported user +APIs. + + +The explicit API +^^^^^^^^^^^^^^^^ + +At its core, Matplotlib is an object-oriented library. We recommend directly +working with the objects if you need more control and customization of your +plots. + +In many cases you will create a `.Figure` and one or more +`~matplotlib.axes.Axes` using `.pyplot.subplots` and from then on only work +on these objects. However, it's also possible to create `.Figure`\ s +explicitly (e.g. when including them in GUI applications). + +Further reading: + +- `matplotlib.axes.Axes` and `matplotlib.figure.Figure` for an overview of + plotting functions. +- Most of the :ref:`examples ` use the object-oriented approach + (except for the pyplot section) + + +The implicit API +^^^^^^^^^^^^^^^^ + +`matplotlib.pyplot` is a collection of functions that make +Matplotlib work like MATLAB. Each pyplot function makes some change to a +figure: e.g., creates a figure, creates a plotting area in a figure, plots +some lines in a plotting area, decorates the plot with labels, etc. + +`.pyplot` is mainly intended for interactive plots and simple cases of +programmatic plot generation. + +Further reading: + +- The `matplotlib.pyplot` function reference +- :doc:`/tutorials/introductory/pyplot` +- :ref:`Pyplot examples ` + +.. _api-index: + +The pylab API (discouraged) +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: pylab + :no-members: + Modules ------- @@ -49,7 +105,6 @@ Alphabetical list of modules: backend_tools_api.rst index_backend_api.rst bezier_api.rst - blocking_input_api.rst category_api.rst cbook_api.rst cm_api.rst @@ -63,9 +118,11 @@ Alphabetical list of modules: dviread.rst figure_api.rst font_manager_api.rst - fontconfig_pattern_api.rst + ft2font.rst gridspec_api.rst + hatch_api.rst image_api.rst + layout_engine_api.rst legend_api.rst legend_handler_api.rst lines_api.rst @@ -90,7 +147,6 @@ Alphabetical list of modules: testing_api.rst text_api.rst texmanager_api.rst - textpath_api.rst ticker_api.rst tight_bbox_api.rst tight_layout_api.rst @@ -104,55 +160,3 @@ Alphabetical list of modules: toolkits/mplot3d.rst toolkits/axes_grid1.rst toolkits/axisartist.rst - toolkits/axes_grid.rst - - -.. _usage_patterns: - -Usage patterns --------------- - -Below we describe several common approaches to plotting with Matplotlib. - -The pyplot API -^^^^^^^^^^^^^^ - -`matplotlib.pyplot` is a collection of functions that make -Matplotlib work like MATLAB. Each pyplot function makes some change to a -figure: e.g., creates a figure, creates a plotting area in a figure, plots -some lines in a plotting area, decorates the plot with labels, etc. - -`.pyplot` is mainly intended for interactive plots and simple cases of -programmatic plot generation. - -Further reading: - -- The `matplotlib.pyplot` function reference -- :doc:`/tutorials/introductory/pyplot` -- :ref:`Pyplot examples ` - -.. _api-index: - -The object-oriented API -^^^^^^^^^^^^^^^^^^^^^^^ - -At its core, Matplotlib is object-oriented. We recommend directly working -with the objects, if you need more control and customization of your plots. - -In many cases you will create a `.Figure` and one or more -`~matplotlib.axes.Axes` using `.pyplot.subplots` and from then on only work -on these objects. However, it's also possible to create `.Figure`\ s -explicitly (e.g. when including them in GUI applications). - -Further reading: - -- `matplotlib.axes.Axes` and `matplotlib.figure.Figure` for an overview of - plotting functions. -- Most of the :ref:`examples ` use the object-oriented approach - (except for the pyplot section) - -The pylab API (discouraged) -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. automodule:: pylab - :no-members: diff --git a/doc/api/index_backend_api.rst b/doc/api/index_backend_api.rst index 25c06820c9da..639d96a9a0dd 100644 --- a/doc/api/index_backend_api.rst +++ b/doc/api/index_backend_api.rst @@ -2,6 +2,8 @@ ``matplotlib.backends`` *********************** +.. module:: matplotlib.backends + .. toctree:: :maxdepth: 1 diff --git a/doc/api/layout_engine_api.rst b/doc/api/layout_engine_api.rst new file mode 100644 index 000000000000..8890061e0979 --- /dev/null +++ b/doc/api/layout_engine_api.rst @@ -0,0 +1,9 @@ +**************************** +``matplotlib.layout_engine`` +**************************** + +.. currentmodule:: matplotlib.layout_engine + +.. automodule:: matplotlib.layout_engine + :members: + :inherited-members: diff --git a/doc/api/lines_api.rst b/doc/api/lines_api.rst index 808df726d118..4cde67c7e656 100644 --- a/doc/api/lines_api.rst +++ b/doc/api/lines_api.rst @@ -26,4 +26,3 @@ Functions :template: autosummary.rst segment_hits - \ No newline at end of file diff --git a/doc/api/mathtext_api.rst b/doc/api/mathtext_api.rst index c0f4941414ed..295ed0382c61 100644 --- a/doc/api/mathtext_api.rst +++ b/doc/api/mathtext_api.rst @@ -9,4 +9,3 @@ :members: :undoc-members: :show-inheritance: - :exclude-members: Box, Char, ComputerModernFontConstants, DejaVuSansFontConstants, DejaVuSerifFontConstants, FontConstantsBase, Fonts, Glue, Kern, Node, Parser, STIXFontConstants, STIXSansFontConstants, Ship, StandardPsFonts, TruetypeFonts diff --git a/doc/api/matplotlib_configuration_api.rst b/doc/api/matplotlib_configuration_api.rst index 3636c45d0c71..d5dc60c80613 100644 --- a/doc/api/matplotlib_configuration_api.rst +++ b/doc/api/matplotlib_configuration_api.rst @@ -4,6 +4,11 @@ .. py:currentmodule:: matplotlib +.. automodule:: matplotlib + :no-members: + :no-undoc-members: + :noindex: + Backend management ================== @@ -26,6 +31,7 @@ Default values and styling :no-members: .. automethod:: find_all + .. automethod:: copy .. autofunction:: rc_context @@ -52,13 +58,18 @@ Logging .. autofunction:: set_loglevel -Colormaps -========= +Colormaps and color sequences +============================= .. autodata:: colormaps :no-value: +.. autodata:: color_sequences + :no-value: + Miscellaneous ============= +.. autoclass:: MatplotlibDeprecationWarning + .. autofunction:: get_cachedir diff --git a/doc/api/next_api_changes/behavior/00001-ABC.rst b/doc/api/next_api_changes/behavior/00001-ABC.rst index 236f672c1123..f6d8c1d8b351 100644 --- a/doc/api/next_api_changes/behavior/00001-ABC.rst +++ b/doc/api/next_api_changes/behavior/00001-ABC.rst @@ -1,7 +1,7 @@ -Behavior Change template +Behavior change template ~~~~~~~~~~~~~~~~~~~~~~~~ Enter description here.... Please rename file with PR number and your initials i.e. "99999-ABC.rst" -and ``git add`` the new file. +and ``git add`` the new file. diff --git a/doc/api/next_api_changes/development/00001-ABC.rst b/doc/api/next_api_changes/development/00001-ABC.rst index 4c60d3db185b..6db90a13e44c 100644 --- a/doc/api/next_api_changes/development/00001-ABC.rst +++ b/doc/api/next_api_changes/development/00001-ABC.rst @@ -1,7 +1,7 @@ -Development Change template +Development change template ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Enter description here.... Please rename file with PR number and your initials i.e. "99999-ABC.rst" -and ``git add`` the new file. +and ``git add`` the new file. diff --git a/doc/api/next_api_changes/removals/00001-ABC.rst b/doc/api/next_api_changes/removals/00001-ABC.rst index 5c68eda9698a..3cc5b6344f7f 100644 --- a/doc/api/next_api_changes/removals/00001-ABC.rst +++ b/doc/api/next_api_changes/removals/00001-ABC.rst @@ -1,7 +1,7 @@ -Removal Change template +Removal change template ~~~~~~~~~~~~~~~~~~~~~~~ Enter description of methods/classes removed here.... Please rename file with PR number and your initials i.e. "99999-ABC.rst" -and ``git add`` the new file. +and ``git add`` the new file. diff --git a/doc/api/patches_api.rst b/doc/api/patches_api.rst index 2c016f659cba..5b1eefa91971 100644 --- a/doc/api/patches_api.rst +++ b/doc/api/patches_api.rst @@ -48,4 +48,3 @@ Functions bbox_artist draw_bbox - diff --git a/doc/api/prev_api_changes/api_changes_0.65.rst b/doc/api/prev_api_changes/api_changes_0.65.rst index 43fffb1bcf4e..f9b9af732010 100644 --- a/doc/api/prev_api_changes/api_changes_0.65.rst +++ b/doc/api/prev_api_changes/api_changes_0.65.rst @@ -8,5 +8,5 @@ Changes for 0.65 connect and disconnect Did away with the text methods for angle since they were ambiguous. - fontangle could mean fontstyle (obligue, etc) or the rotation of the + fontangle could mean fontstyle (oblique, etc) or the rotation of the text. Use style and rotation instead. diff --git a/doc/api/prev_api_changes/api_changes_0.70.rst b/doc/api/prev_api_changes/api_changes_0.70.rst index b8094658b249..e30dfbb64954 100644 --- a/doc/api/prev_api_changes/api_changes_0.70.rst +++ b/doc/api/prev_api_changes/api_changes_0.70.rst @@ -6,4 +6,4 @@ Changes for 0.70 MplEvent factored into a base class Event and derived classes MouseEvent and KeyEvent - Removed definct set_measurement in wx toolbar + Removed defunct set_measurement in wx toolbar diff --git a/doc/api/prev_api_changes/api_changes_0.72.rst b/doc/api/prev_api_changes/api_changes_0.72.rst index 9529e396f356..bfb6fc124658 100644 --- a/doc/api/prev_api_changes/api_changes_0.72.rst +++ b/doc/api/prev_api_changes/api_changes_0.72.rst @@ -6,7 +6,7 @@ Changes for 0.72 - Line2D, Text, and Patch copy_properties renamed update_from and moved into artist base class - - LineCollecitons.color renamed to LineCollections.set_color for + - LineCollections.color renamed to LineCollections.set_color for consistency with set/get introspection mechanism, - pylab figure now defaults to num=None, which creates a new figure diff --git a/doc/api/prev_api_changes/api_changes_0.91.0.rst b/doc/api/prev_api_changes/api_changes_0.91.0.rst index b33fcb50f0ad..32760554522a 100644 --- a/doc/api/prev_api_changes/api_changes_0.91.0.rst +++ b/doc/api/prev_api_changes/api_changes_0.91.0.rst @@ -25,18 +25,18 @@ Changes for 0.91.0 * The :mod:`matplotlib.dviread` file now has a parser for files like psfonts.map and pdftex.map, to map TeX font names to external files. -* The file :mod:`matplotlib.type1font` contains a new class for Type 1 +* The file ``matplotlib.type1font`` contains a new class for Type 1 fonts. Currently it simply reads pfa and pfb format files and stores the data in a way that is suitable for embedding in pdf files. In the future the class might actually parse the font to allow e.g., subsetting. -* :mod:`matplotlib.ft2font` now supports ``FT_Attach_File``. In +* ``matplotlib.ft2font`` now supports ``FT_Attach_File``. In practice this can be used to read an afm file in addition to a pfa/pfb file, to get metrics and kerning information for a Type 1 font. -* The :class:`.AFM` class now supports querying CapHeight and stem +* The ``AFM`` class now supports querying CapHeight and stem widths. The get_name_char method now has an isord kwarg like get_width_char. diff --git a/doc/api/prev_api_changes/api_changes_0.98.0.rst b/doc/api/prev_api_changes/api_changes_0.98.0.rst index c50b98cbab16..bb9f3e6585af 100644 --- a/doc/api/prev_api_changes/api_changes_0.98.0.rst +++ b/doc/api/prev_api_changes/api_changes_0.98.0.rst @@ -12,7 +12,7 @@ Changes for 0.98.0 rather than custom callback handling. Any users of ``matplotlib.cm.ScalarMappable.add_observer`` of the :class:`~matplotlib.cm.ScalarMappable` should use the - :attr:`matplotlib.cm.ScalarMappable.callbacksSM` + ``matplotlib.cm.ScalarMappable.callbacksSM`` :class:`~matplotlib.cbook.CallbackRegistry` instead. * New axes function and Axes method provide control over the plot @@ -181,7 +181,7 @@ The ``Polar`` class has moved to :mod:`matplotlib.projections.polar`. .. [3] :meth:`matplotlib.axes.Axes.set_position` now accepts either four scalars or a :class:`matplotlib.transforms.Bbox` instance. -.. [4] Since the recfactoring allows for more than two scale types +.. [4] Since the refactoring allows for more than two scale types ('log' or 'linear'), it no longer makes sense to have a toggle. ``Axes.toggle_log_lineary()`` has been removed. diff --git a/doc/api/prev_api_changes/api_changes_0.98.x.rst b/doc/api/prev_api_changes/api_changes_0.98.x.rst index 41ee63502254..053fd908a03e 100644 --- a/doc/api/prev_api_changes/api_changes_0.98.x.rst +++ b/doc/api/prev_api_changes/api_changes_0.98.x.rst @@ -63,8 +63,8 @@ Changes for 0.98.x :meth:`matplotlib.axes.Axes.set_ylim` now return a copy of the ``viewlim`` array to avoid modify-in-place surprises. -* :meth:`matplotlib.afm.AFM.get_fullname` and - :meth:`matplotlib.afm.AFM.get_familyname` no longer raise an +* ``matplotlib.afm.AFM.get_fullname`` and + ``matplotlib.afm.AFM.get_familyname`` no longer raise an exception if the AFM file does not specify these optional attributes, but returns a guess based on the required FontName attribute. @@ -87,13 +87,13 @@ Changes for 0.98.x :class:`~matplotlib.collections.Collection` base class. * ``matplotlib.figure.Figure.figurePatch`` renamed - :attr:`matplotlib.figure.Figure.patch`; + ``matplotlib.figure.Figure.patch``; ``matplotlib.axes.Axes.axesPatch`` renamed - :attr:`matplotlib.axes.Axes.patch`; + ``matplotlib.axes.Axes.patch``; ``matplotlib.axes.Axes.axesFrame`` renamed - :attr:`matplotlib.axes.Axes.frame`. + ``matplotlib.axes.Axes.frame``. ``matplotlib.axes.Axes.get_frame``, which returns - :attr:`matplotlib.axes.Axes.patch`, is deprecated. + ``matplotlib.axes.Axes.patch``, is deprecated. * Changes in the :class:`matplotlib.contour.ContourLabeler` attributes (:func:`matplotlib.pyplot.clabel` function) so that they all have a diff --git a/doc/api/prev_api_changes/api_changes_0.99.x.rst b/doc/api/prev_api_changes/api_changes_0.99.x.rst index 03596e93dac3..4736d066d43e 100644 --- a/doc/api/prev_api_changes/api_changes_0.99.x.rst +++ b/doc/api/prev_api_changes/api_changes_0.99.x.rst @@ -21,8 +21,8 @@ Changes beyond 0.99.x on or off, and applies it. + :meth:`matplotlib.axes.Axes.margins` sets margins used to - autoscale the :attr:`matplotlib.axes.Axes.viewLim` based on - the :attr:`matplotlib.axes.Axes.dataLim`. + autoscale the ``matplotlib.axes.Axes.viewLim`` based on + the ``matplotlib.axes.Axes.dataLim``. + :meth:`matplotlib.axes.Axes.locator_params` allows one to adjust axes locator parameters such as *nbins*. diff --git a/doc/api/prev_api_changes/api_changes_1.3.x.rst b/doc/api/prev_api_changes/api_changes_1.3.x.rst index 1cfba079bb5e..553f4d7118c7 100644 --- a/doc/api/prev_api_changes/api_changes_1.3.x.rst +++ b/doc/api/prev_api_changes/api_changes_1.3.x.rst @@ -33,7 +33,7 @@ Code removal functionality on `matplotlib.path.Path.contains_point` and friends instead. - - Instead of ``axes.Axes.get_frame``, use `.axes.Axes.patch`. + - Instead of ``axes.Axes.get_frame``, use ``axes.Axes.patch``. - The following keyword arguments to the `~.axes.Axes.legend` function have been renamed: @@ -102,7 +102,7 @@ Code deprecation be used. In previous Matplotlib versions this attribute was an undocumented tuple of ``(colorbar_instance, colorbar_axes)`` but is now just ``colorbar_instance``. To get the colorbar axes it is possible to just use - the :attr:`~matplotlib.colorbar.ColorbarBase.ax` attribute on a colorbar + the ``matplotlib.colorbar.ColorbarBase.ax`` attribute on a colorbar instance. * The ``matplotlib.mpl`` module is now deprecated. Those who relied on this @@ -192,7 +192,7 @@ Code changes by ``self.vline`` for vertical cursors lines and ``self.hline`` is added for the horizontal cursors lines. -* On POSIX platforms, the :func:`~matplotlib.cbook.report_memory` function +* On POSIX platforms, the ``matplotlib.cbook.report_memory`` function raises :class:`NotImplementedError` instead of :class:`OSError` if the :command:`ps` command cannot be run. diff --git a/doc/api/prev_api_changes/api_changes_1.4.x.rst b/doc/api/prev_api_changes/api_changes_1.4.x.rst index d0952784677c..c12d40a67991 100644 --- a/doc/api/prev_api_changes/api_changes_1.4.x.rst +++ b/doc/api/prev_api_changes/api_changes_1.4.x.rst @@ -82,7 +82,7 @@ original location: * The artist used to draw the outline of a `.Figure.colorbar` has been changed from a `matplotlib.lines.Line2D` to `matplotlib.patches.Polygon`, thus - `.colorbar.ColorbarBase.outline` is now a `matplotlib.patches.Polygon` + ``colorbar.ColorbarBase.outline`` is now a `matplotlib.patches.Polygon` object. * The legend handler interface has changed from a callable, to any object @@ -149,9 +149,9 @@ original location: ``drawRect`` from ``FigureCanvasQTAgg``; they were always an implementation detail of the (preserved) ``drawRectangle()`` function. -* The function signatures of `.tight_bbox.adjust_bbox` and - `.tight_bbox.process_figure_for_rasterizing` have been changed. A new - *fixed_dpi* parameter allows for overriding the ``figure.dpi`` setting +* The function signatures of ``matplotlib.tight_bbox.adjust_bbox`` and + ``matplotlib.tight_bbox.process_figure_for_rasterizing`` have been changed. + A new *fixed_dpi* parameter allows for overriding the ``figure.dpi`` setting instead of trying to deduce the intended behaviour from the file format. * Added support for horizontal/vertical axes padding to diff --git a/doc/api/prev_api_changes/api_changes_1.5.0.rst b/doc/api/prev_api_changes/api_changes_1.5.0.rst index b51f311d8836..1248b1dfd394 100644 --- a/doc/api/prev_api_changes/api_changes_1.5.0.rst +++ b/doc/api/prev_api_changes/api_changes_1.5.0.rst @@ -41,7 +41,7 @@ fully delegate to `.Quiver`). Previously any input matching 'mid.*' would be interpreted as 'middle', 'tip.*' as 'tip' and any string not matching one of those patterns as 'tail'. -The value of `.Quiver.pivot` is normalized to be in the set {'tip', 'tail', +The value of ``Quiver.pivot`` is normalized to be in the set {'tip', 'tail', 'middle'} in `.Quiver`. Reordered ``Axes.get_children`` @@ -116,10 +116,10 @@ In either case to update the data in the `.Line2D` object you must update both the ``x`` and ``y`` data. -Removed *args* and *kwargs* from `.MicrosecondLocator.__call__` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Removed *args* and *kwargs* from ``MicrosecondLocator.__call__`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The call signature of :meth:`~matplotlib.dates.MicrosecondLocator.__call__` +The call signature of ``matplotlib.dates.MicrosecondLocator.__call__`` has changed from ``__call__(self, *args, **kwargs)`` to ``__call__(self)``. This is consistent with the superclass :class:`~matplotlib.ticker.Locator` and also all the other Locators derived from this superclass. @@ -374,7 +374,7 @@ directly. patheffects.svg ~~~~~~~~~~~~~~~ - - remove ``get_proxy_renderer`` method from ``AbstarctPathEffect`` class + - remove ``get_proxy_renderer`` method from ``AbstractPathEffect`` class - remove ``patch_alpha`` and ``offset_xy`` from ``SimplePatchShadow`` diff --git a/doc/api/prev_api_changes/api_changes_2.1.0.rst b/doc/api/prev_api_changes/api_changes_2.1.0.rst index 9673d24c719f..39ea78bdf587 100644 --- a/doc/api/prev_api_changes/api_changes_2.1.0.rst +++ b/doc/api/prev_api_changes/api_changes_2.1.0.rst @@ -422,8 +422,8 @@ The ``shading`` kwarg to `~matplotlib.axes.Axes.pcolor` has been removed. Set ``edgecolors`` appropriately instead. -Functions removed from the `.lines` module -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Functions removed from the ``lines`` module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :mod:`matplotlib.lines` module no longer imports the ``pts_to_prestep``, ``pts_to_midstep`` and ``pts_to_poststep`` diff --git a/doc/api/prev_api_changes/api_changes_2.2.0.rst b/doc/api/prev_api_changes/api_changes_2.2.0.rst index 29ed03649fd8..f13fe2a246f0 100644 --- a/doc/api/prev_api_changes/api_changes_2.2.0.rst +++ b/doc/api/prev_api_changes/api_changes_2.2.0.rst @@ -74,7 +74,7 @@ rcParam. Deprecated ``Axis.unit_data`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Use `.Axis.units` (which has long existed) instead. +Use ``Axis.units`` (which has long existed) instead. Removals @@ -159,7 +159,7 @@ If `.MovieWriterRegistry` can't find the requested `.MovieWriter`, a more helpful `RuntimeError` message is now raised instead of the previously raised `KeyError`. -`~.tight_layout.auto_adjust_subplotpars` now raises `ValueError` +``matplotlib.tight_layout.auto_adjust_subplotpars`` now raises `ValueError` instead of `RuntimeError` when sizes of input lists don't match @@ -169,7 +169,7 @@ instead of `RuntimeError` when sizes of input lists don't match `matplotlib.figure.Figure.set_figwidth` and `matplotlib.figure.Figure.set_figheight` had the keyword argument ``forward=False`` by default, but `.figure.Figure.set_size_inches` now defaults -to ``forward=True``. This makes these functions conistent. +to ``forward=True``. This makes these functions consistent. Do not truncate svg sizes to nearest point @@ -198,11 +198,11 @@ Changes to Qt backend class MRO To support both Agg and cairo rendering for Qt backends all of the non-Agg specific code previously in ``backend_qt5agg.FigureCanvasQTAggBase`` has been -moved to :class:`.backend_qt5.FigureCanvasQT` so it can be shared with the +moved to ``backend_qt5.FigureCanvasQT`` so it can be shared with the cairo implementation. The ``FigureCanvasQTAggBase.paintEvent``, ``FigureCanvasQTAggBase.blit``, and ``FigureCanvasQTAggBase.print_figure`` -methods have moved to :meth:`.FigureCanvasQTAgg.paintEvent`, -:meth:`.FigureCanvasQTAgg.blit`, and :meth:`.FigureCanvasQTAgg.print_figure`. +methods have moved to ``FigureCanvasQTAgg.paintEvent``, +``FigureCanvasQTAgg.blit``, and ``FigureCanvasQTAgg.print_figure``. The first two methods assume that the instance is also a ``QWidget`` so to use ``FigureCanvasQTAggBase`` it was required to multiple inherit from a ``QWidget`` sub-class. @@ -210,9 +210,9 @@ The first two methods assume that the instance is also a ``QWidget`` so to use Having moved all of its methods either up or down the class hierarchy ``FigureCanvasQTAggBase`` has been deprecated. To do this without warning and to preserve as much API as possible, ``.backend_qt5agg.FigureCanvasQTAggBase`` -now inherits from :class:`.backend_qt5.FigureCanvasQTAgg`. +now inherits from ``backend_qt5.FigureCanvasQTAgg``. -The MRO for :class:`.FigureCanvasQTAgg` and ``FigureCanvasQTAggBase`` used to +The MRO for ``FigureCanvasQTAgg`` and ``FigureCanvasQTAggBase`` used to be :: diff --git a/doc/api/prev_api_changes/api_changes_3.0.0.rst b/doc/api/prev_api_changes/api_changes_3.0.0.rst index 93efda636e3d..89a588d8f9e3 100644 --- a/doc/api/prev_api_changes/api_changes_3.0.0.rst +++ b/doc/api/prev_api_changes/api_changes_3.0.0.rst @@ -106,7 +106,7 @@ Different exception types for undocumented options - Passing the undocumented ``xmin`` or ``xmax`` arguments to :meth:`~matplotlib.axes.Axes.set_xlim` would silently override the ``left`` and ``right`` arguments. :meth:`~matplotlib.axes.Axes.set_ylim` and the - 3D equivalents (e.g. `~.Axes3D.set_zlim3d`) had a + 3D equivalents (e.g. `~.Axes3D.set_zlim`) had a corresponding problem. A ``TypeError`` will be raised if they would override the earlier limit arguments. In 3.0 these were kwargs were deprecated, but in 3.1 @@ -197,7 +197,7 @@ Contour color autoscaling improvements Selection of contour levels is now the same for contour and contourf; previously, for contour, levels outside the data range were deleted. (Exception: if no contour levels are found within the -data range, the `levels` attribute is replaced with a list holding +data range, the ``levels`` attribute is replaced with a list holding only the minimum of the data range.) When contour is called with levels specified as a target number rather @@ -297,7 +297,7 @@ Blacklisted rcparams no longer updated by `~matplotlib.rcdefaults`, `~matplotlib The rc modifier functions `~matplotlib.rcdefaults`, `~matplotlib.rc_file_defaults` and `~matplotlib.rc_file` -now ignore rcParams in the `matplotlib.style.core.STYLE_BLACKLIST` set. In +now ignore rcParams in the ``matplotlib.style.core.STYLE_BLACKLIST`` set. In particular, this prevents the ``backend`` and ``interactive`` rcParams from being incorrectly modified by these functions. @@ -324,12 +324,12 @@ instead of ``\usepackage{ucs}\usepackage[utf8x]{inputenc}``. Return type of ArtistInspector.get_aliases changed -------------------------------------------------- -`ArtistInspector.get_aliases` previously returned the set of aliases as +``ArtistInspector.get_aliases`` previously returned the set of aliases as ``{fullname: {alias1: None, alias2: None, ...}}``. The dict-to-None mapping was used to simulate a set in earlier versions of Python. It has now been replaced by a set, i.e. ``{fullname: {alias1, alias2, ...}}``. -This value is also stored in `.ArtistInspector.aliasd`, which has likewise +This value is also stored in ``ArtistInspector.aliasd``, which has likewise changed. diff --git a/doc/api/prev_api_changes/api_changes_3.0.1.rst b/doc/api/prev_api_changes/api_changes_3.0.1.rst index d214ae9e6652..4b203cd04596 100644 --- a/doc/api/prev_api_changes/api_changes_3.0.1.rst +++ b/doc/api/prev_api_changes/api_changes_3.0.1.rst @@ -1,10 +1,10 @@ API Changes for 3.0.1 ===================== -`.tight_layout.auto_adjust_subplotpars` can return ``None`` now if the new -subplotparams will collapse axes to zero width or height. This prevents -``tight_layout`` from being executed. Similarly -`.tight_layout.get_tight_layout_figure` will return None. +``matplotlib.tight_layout.auto_adjust_subplotpars`` can return ``None`` now if +the new subplotparams will collapse axes to zero width or height. +This prevents ``tight_layout`` from being executed. Similarly +``matplotlib.tight_layout.get_tight_layout_figure`` will return None. To improve import (startup) time, private modules are now imported lazily. These modules are no longer available at these locations: diff --git a/doc/api/prev_api_changes/api_changes_3.1.0.rst b/doc/api/prev_api_changes/api_changes_3.1.0.rst index b300d0d4c469..3e67af8f64cf 100644 --- a/doc/api/prev_api_changes/api_changes_3.1.0.rst +++ b/doc/api/prev_api_changes/api_changes_3.1.0.rst @@ -293,9 +293,9 @@ where the `.cm.ScalarMappable` passed to `matplotlib.colorbar.Colorbar` (`~.Figure.colorbar`) had a ``set_norm`` method, as did the colorbar. The colorbar is now purely a follower to the `.ScalarMappable` norm and colormap, and the old inherited methods -`~matplotlib.colorbar.ColorbarBase.set_norm`, -`~matplotlib.colorbar.ColorbarBase.set_cmap`, -`~matplotlib.colorbar.ColorbarBase.set_clim` are deprecated, as are +``matplotlib.colorbar.ColorbarBase.set_norm``, +``matplotlib.colorbar.ColorbarBase.set_cmap``, +``matplotlib.colorbar.ColorbarBase.set_clim`` are deprecated, as are the getter versions of those calls. To set the norm associated with a colorbar do ``colorbar.mappable.set_norm()`` etc. @@ -337,7 +337,7 @@ match the array value type of the ``Path.codes`` array. LaTeX code in matplotlibrc file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Previously, the rc file keys ``pgf.preamble`` and ``text.latex.preamble`` were -parsed using commmas as separators. This would break valid LaTeX code, such as:: +parsed using commas as separators. This would break valid LaTeX code, such as:: \usepackage[protrusion=true, expansion=false]{microtype} @@ -389,16 +389,16 @@ consistent with the behavior on Py2, where a buffer object was returned. -`matplotlib.font_manager.win32InstalledFonts` return type -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -`matplotlib.font_manager.win32InstalledFonts` returns an empty list instead +``matplotlib.font_manager.win32InstalledFonts`` return type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``matplotlib.font_manager.win32InstalledFonts`` returns an empty list instead of None if no fonts are found. -`.Axes.fmt_xdata` and `.Axes.fmt_ydata` error handling -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``Axes.fmt_xdata`` and ``Axes.fmt_ydata`` error handling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Previously, if the user provided a `.Axes.fmt_xdata` or -`.Axes.fmt_ydata` function that raised a `TypeError` (or set them to a +Previously, if the user provided a ``Axes.fmt_xdata`` or +``Axes.fmt_ydata`` function that raised a `TypeError` (or set them to a non-callable), the exception would be silently ignored and the default formatter be used instead. This is no longer the case; the exception is now propagated out. @@ -476,7 +476,7 @@ Exception changes - `.Axes.streamplot` does not support irregularly gridded ``x`` and ``y`` values. So far, it used to silently plot an incorrect result. This has been changed to raise a `ValueError` instead. -- The `.streamplot.Grid` class, which is internally used by streamplot +- The ``streamplot.Grid`` class, which is internally used by streamplot code, also throws a `ValueError` when irregularly gridded values are passed in. @@ -496,7 +496,7 @@ Classes and methods - ``backend_wx.Toolbar`` (use ``backend_wx.NavigationToolbar2Wx`` instead) - ``cbook.align_iterators`` (no replacement) - ``contour.ContourLabeler.get_real_label_width`` (no replacement) -- ``legend.Legend.draggable`` (use `legend.Legend.set_draggable()` instead) +- ``legend.Legend.draggable`` (use `.legend.Legend.set_draggable()` instead) - ``texmanager.TexManager.postscriptd``, ``texmanager.TexManager.pscnt``, ``texmanager.TexManager.make_ps``, ``texmanager.TexManager.get_ps_bbox`` (no replacements) @@ -566,7 +566,7 @@ in Matplotlib 2.2 has been removed. See below for a list: - ``mlab.safe_isnan`` (use `numpy.isnan` instead) - ``mlab.cohere_pairs`` (use `scipy.signal.coherence` instead) - ``mlab.entropy`` (use `scipy.stats.entropy` instead) -- ``mlab.normpdf`` (use `scipy.stats.norm.pdf` instead) +- ``mlab.normpdf`` (use ``scipy.stats.norm.pdf`` instead) - ``mlab.find`` (use ``np.nonzero(np.ravel(condition))`` instead) - ``mlab.longest_contiguous_ones`` - ``mlab.longest_ones`` @@ -652,7 +652,7 @@ no longer available in the `pylab` module: - ``longest_ones`` - ``movavg`` - ``norm_flat`` (use ``numpy.linalg.norm(a.flat, ord=2)`` instead) -- ``normpdf`` (use `scipy.stats.norm.pdf` instead) +- ``normpdf`` (use ``scipy.stats.norm.pdf`` instead) - ``path_length`` - ``poly_below`` - ``poly_between`` @@ -705,7 +705,7 @@ now a no-op). The image comparison test decorators now skip (rather than xfail) the test for uncomparable formats. The affected decorators are `~.image_comparison` and -`~.check_figures_equal`. The deprecated `~.ImageComparisonTest` class is +`~.check_figures_equal`. The deprecated ``ImageComparisonTest`` class is likewise changed. Dependency changes @@ -825,7 +825,7 @@ This has not been used in the codebase since its addition in 2009. This has never been used internally, there is no equivalent method exists on the 2D Axis classes, and despite the similar name, it has a completely - different behavior from the 2D Axis' `axis.Axis.get_ticks_position` method. + different behavior from the 2D Axis' ``axis.Axis.get_ticks_position`` method. - ``.backend_pgf.LatexManagerFactory`` - ``mpl_toolkits.axisartist.axislines.SimpleChainedObjects`` @@ -936,8 +936,8 @@ Axes3D - `.axes3d.Axes3D.w_yaxis` - `.axes3d.Axes3D.w_zaxis` -Use `.axes3d.Axes3D.xaxis`, `.axes3d.Axes3D.yaxis` and `.axes3d.Axes3D.zaxis` -instead. +Use ``axes3d.Axes3D.xaxis``, ``axes3d.Axes3D.yaxis`` and +``axes3d.Axes3D.zaxis`` instead. Testing ~~~~~~~ @@ -945,7 +945,7 @@ Testing - ``matplotlib.testing.decorators.switch_backend`` decorator Test functions should use ``pytest.mark.backend``, and the mark will be -picked up by the `matplotlib.testing.conftest.mpl_test_settings` fixture. +picked up by the ``matplotlib.testing.conftest.mpl_test_settings`` fixture. Quiver ~~~~~~ @@ -1069,7 +1069,7 @@ Axis - ``Axis.iter_ticks`` -This only served as a helper to the private `.Axis._update_ticks` +This only served as a helper to the private ``Axis._update_ticks`` Undeprecations @@ -1123,10 +1123,10 @@ The `.Formatter` class gained a new `~.Formatter.format_ticks` method, which takes the list of all tick locations as a single argument and returns the list of all formatted values. It is called by the axis tick handling code and, by default, first calls `~.Formatter.set_locs` with all locations, then repeatedly -calls `~.Formatter.__call__` for each location. +calls ``Formatter.__call__`` for each location. Tick-handling code in the codebase that previously performed this sequence -(`~.Formatter.set_locs` followed by repeated `~.Formatter.__call__`) have been +(`~.Formatter.set_locs` followed by repeated ``Formatter.__call__``) have been updated to use `~.Formatter.format_ticks`. `~.Formatter.format_ticks` is intended to be overridden by `.Formatter` diff --git a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst index 8e76a047e348..dc47740890be 100644 --- a/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst +++ b/doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst @@ -46,9 +46,9 @@ highest. Code that worked around the normalization between 0 and 1 will need to be modified. -`MovieWriterRegistry` -~~~~~~~~~~~~~~~~~~~~~ -`MovieWriterRegistry` now always checks the availability of the writer classes +``MovieWriterRegistry`` +~~~~~~~~~~~~~~~~~~~~~~~ +`.MovieWriterRegistry` now always checks the availability of the writer classes before returning them. If one wishes, for example, to get the first available writer, without performing the availability check on subsequent writers, it is now possible to iterate over the registry, which will yield the names of the @@ -88,13 +88,13 @@ enough to be accommodated by the default data limit margins. While the new behavior is algorithmically simpler, it is conditional on properties of the `.Collection` object: - 1. ``offsets = None``, ``transform`` is a child of `.Axes.transData`: use the paths + 1. ``offsets = None``, ``transform`` is a child of ``Axes.transData``: use the paths for the automatic limits (i.e. for `.LineCollection` in `.Axes.streamplot`). - 2. ``offsets != None``, and ``offset_transform`` is child of `.Axes.transData`: + 2. ``offsets != None``, and ``offset_transform`` is child of ``Axes.transData``: - a) ``transform`` is child of `.Axes.transData`: use the ``path + offset`` for + a) ``transform`` is child of ``Axes.transData``: use the ``path + offset`` for limits (i.e., for `.Axes.bar`). - b) ``transform`` is not a child of `.Axes.transData`: just use the offsets + b) ``transform`` is not a child of ``Axes.transData``: just use the offsets for the limits (i.e. for scatter) 3. otherwise return a null `.Bbox`. @@ -294,7 +294,7 @@ Exception changes ~~~~~~~~~~~~~~~~~ Various APIs that raised a `ValueError` for incorrectly typed inputs now raise `TypeError` instead: `.backend_bases.GraphicsContextBase.set_clip_path`, -`.blocking_input.BlockingInput.__call__`, `.cm.register_cmap`, `.dviread.DviFont`, +``blocking_input.BlockingInput.__call__``, `.cm.register_cmap`, `.dviread.DviFont`, `.rcsetup.validate_hatch`, ``.rcsetup.validate_animation_writer_path``, `.spines.Spine`, many classes in the :mod:`matplotlib.transforms` module and :mod:`matplotlib.tri` package, and Axes methods that take a ``norm`` parameter. diff --git a/doc/api/prev_api_changes/api_changes_3.2.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.2.0/deprecations.rst index 42de9ffccd77..65b72c7e0558 100644 --- a/doc/api/prev_api_changes/api_changes_3.2.0/deprecations.rst +++ b/doc/api/prev_api_changes/api_changes_3.2.0/deprecations.rst @@ -118,7 +118,7 @@ The unused ``Locator.autoscale`` method is deprecated (pass the axis limits to Animation ~~~~~~~~~ -The following methods and attributes of the `MovieWriterRegistry` class are +The following methods and attributes of the `.MovieWriterRegistry` class are deprecated: ``set_dirty``, ``ensure_not_dirty``, ``reset_available_writers``, ``avail``. diff --git a/doc/api/prev_api_changes/api_changes_3.2.0/development.rst b/doc/api/prev_api_changes/api_changes_3.2.0/development.rst index 470b594d522c..9af7fb8fb561 100644 --- a/doc/api/prev_api_changes/api_changes_3.2.0/development.rst +++ b/doc/api/prev_api_changes/api_changes_3.2.0/development.rst @@ -22,7 +22,7 @@ is desired. Packaging DLLs ~~~~~~~~~~~~~~ -Previously, it was possible to package Windows DLLs into the Maptlotlib +Previously, it was possible to package Windows DLLs into the Matplotlib wheel (or sdist) by copying them into the source tree and setting the ``package_data.dlls`` entry in ``setup.cfg``. diff --git a/doc/api/prev_api_changes/api_changes_3.3.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.3.0/deprecations.rst index 22aa93f89931..322f6df40d42 100644 --- a/doc/api/prev_api_changes/api_changes_3.3.0/deprecations.rst +++ b/doc/api/prev_api_changes/api_changes_3.3.0/deprecations.rst @@ -273,13 +273,13 @@ mathtext glues The *copy* parameter of ``mathtext.Glue`` is deprecated (the underlying glue spec is now immutable). ``mathtext.GlueSpec`` is deprecated. -Signatures of `.Artist.draw` and `.Axes.draw` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The *inframe* parameter to `.Axes.draw` is deprecated. Use +Signatures of `.Artist.draw` and `matplotlib.axes.Axes.draw` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The *inframe* parameter to `matplotlib.axes.Axes.draw` is deprecated. Use `.Axes.redraw_in_frame` instead. -Not passing the *renderer* parameter to `.Axes.draw` is deprecated. Use -``axes.draw_artist(axes)`` instead. +Not passing the *renderer* parameter to `matplotlib.axes.Axes.draw` is +deprecated. Use ``axes.draw_artist(axes)`` instead. These changes make the signature of the ``draw`` (``artist.draw(renderer)``) method consistent across all artists; thus, additional parameters to @@ -328,7 +328,7 @@ are deprecated. Panning and zooming are now implemented using the Passing None to various Axes subclass factories ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Support for passing ``None`` as base class to `.axes.subplot_class_factory`, +Support for passing ``None`` as base class to ``axes.subplot_class_factory``, ``axes_grid1.parasite_axes.host_axes_class_factory``, ``axes_grid1.parasite_axes.host_subplot_class_factory``, ``axes_grid1.parasite_axes.parasite_axes_class_factory``, and @@ -545,8 +545,8 @@ experimental and may change in the future. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ... is deprecated. -`.epoch2num` and `.num2epoch` are deprecated -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``epoch2num`` and ``num2epoch`` are deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These are unused and can be easily reproduced by other date tools. `.get_epoch` will return Matplotlib's epoch. diff --git a/doc/api/prev_api_changes/api_changes_3.3.0/removals.rst b/doc/api/prev_api_changes/api_changes_3.3.0/removals.rst index 3f7c232e9800..36b63c6dcfc8 100644 --- a/doc/api/prev_api_changes/api_changes_3.3.0/removals.rst +++ b/doc/api/prev_api_changes/api_changes_3.3.0/removals.rst @@ -202,7 +202,7 @@ Arguments renamed to ``manage_ticks``. - The ``normed`` parameter of `~.Axes.hist2d` has been renamed to ``density``. - The ``s`` parameter of `.Annotation` has been renamed to ``text``. -- For all functions in `.bezier` that supported a ``tolerence`` parameter, this +- For all functions in `.bezier` that supported a ``tolerance`` parameter, this parameter has been renamed to ``tolerance``. - ``axis("normal")`` is not supported anymore. Use the equivalent ``axis("auto")`` instead. diff --git a/doc/api/prev_api_changes/api_changes_3.3.1.rst b/doc/api/prev_api_changes/api_changes_3.3.1.rst index b3383a4e5fd2..3eda8a9a3a1a 100644 --- a/doc/api/prev_api_changes/api_changes_3.3.1.rst +++ b/doc/api/prev_api_changes/api_changes_3.3.1.rst @@ -15,7 +15,7 @@ reverts the deprecation. Functions ``epoch2num`` and ``dates.julian2num`` use ``date.epoch`` rcParam ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Now `~.dates.epoch2num` and (undocumented) ``julian2num`` return floating point +Now ``epoch2num`` and (undocumented) ``julian2num`` return floating point days since `~.dates.get_epoch` as set by :rc:`date.epoch`, instead of floating point days since the old epoch of "0000-12-31T00:00:00". If needed, you can translate from the new to old values as diff --git a/doc/api/prev_api_changes/api_changes_3.4.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.4.0/deprecations.rst index 3de8959bb3ef..9e09f3febe64 100644 --- a/doc/api/prev_api_changes/api_changes_3.4.0/deprecations.rst +++ b/doc/api/prev_api_changes/api_changes_3.4.0/deprecations.rst @@ -38,8 +38,8 @@ Subplot-related attributes and methods Some ``SubplotBase`` methods and attributes have been deprecated and/or moved to `.SubplotSpec`: -- ``get_geometry`` (use `.SubplotBase.get_subplotspec` instead), -- ``change_geometry`` (use `.SubplotBase.set_subplotspec` instead), +- ``get_geometry`` (use ``SubplotBase.get_subplotspec`` instead), +- ``change_geometry`` (use ``SubplotBase.set_subplotspec`` instead), - ``is_first_row``, ``is_last_row``, ``is_first_col``, ``is_last_col`` (use the corresponding methods on the `.SubplotSpec` instance instead), - ``update_params`` (now a no-op), diff --git a/doc/api/prev_api_changes/api_changes_3.4.0/development.rst b/doc/api/prev_api_changes/api_changes_3.4.0/development.rst index ab5e118de9e8..982046c3869e 100644 --- a/doc/api/prev_api_changes/api_changes_3.4.0/development.rst +++ b/doc/api/prev_api_changes/api_changes_3.4.0/development.rst @@ -4,7 +4,7 @@ Development changes Increase to minimum supported versions of Python and dependencies ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For Maptlotlib 3.4, the :ref:`minimum supported versions ` are +For Matplotlib 3.4, the :ref:`minimum supported versions ` are being bumped: +------------+-----------------+---------------+ diff --git a/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst b/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst index 62cdb0a32854..69e38270ca76 100644 --- a/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst +++ b/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst @@ -47,16 +47,16 @@ corresponding ``Axes.add_*`` method. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Historically, it has not been possible to filter -`.MatplotlibDeprecationWarning`\s by checking for `DeprecationWarning`, since we -subclass `UserWarning` directly. +`~matplotlib.MatplotlibDeprecationWarning`\s by checking for +`DeprecationWarning`, since we subclass `UserWarning` directly. The decision to not subclass `DeprecationWarning` has to do with a decision from core Python in the 2.x days to not show `DeprecationWarning`\s to users. However, there is now a more sophisticated filter in place (see https://www.python.org/dev/peps/pep-0565/). -Users will now see `.MatplotlibDeprecationWarning` only during interactive -sessions, and these can be silenced by the standard mechanism: +Users will now see `~matplotlib.MatplotlibDeprecationWarning` only during +interactive sessions, and these can be silenced by the standard mechanism: .. code:: python @@ -198,8 +198,8 @@ This affects `.ContourSet` itself and its subclasses, `.QuadContourSet` ``hatch.SmallFilledCircles`` inherits from ``hatch.Circles`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``hatch.SmallFilledCircles`` class now inherits from ``hatch.Circles`` -rather than from ``hatch.SmallCircles``. +The `.hatch.SmallFilledCircles` class now inherits from `.hatch.Circles` rather +than from `.hatch.SmallCircles`. hexbin with a log norm ~~~~~~~~~~~~~~~~~~~~~~ @@ -229,19 +229,19 @@ defaults to *False*. Type 1 fonts have a large part of their code encrypted as an obsolete copy-protection measure. This part is now available decrypted as the -``decrypted`` attribute of `~.type1font.Type1Font`. This decrypted data is not -yet parsed, but this is a prerequisite for implementing subsetting. +``decrypted`` attribute of ``matplotlib.type1font.Type1Font``. This decrypted +data is not yet parsed, but this is a prerequisite for implementing subsetting. 3D contourf polygons placed between levels ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The polygons used in a 3D `~mpl_toolkits.mplot3d.Axes3D.contourf` plot are now +The polygons used in a 3D `~.Axes3D.contourf` plot are now placed halfway between the contour levels, as each polygon represents the location of values that lie between two levels. ``AxesDivider`` now defaults to rcParams-specified pads ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -`.AxesDivider.append_axes`, `.AxesDivider.new_horizontal`, and -`.AxesDivider.new_vertical` now default to paddings specified by +`.AxesDivider.append_axes`, ``AxesDivider.new_horizontal``, and +``AxesDivider.new_vertical`` now default to paddings specified by :rc:`figure.subplot.wspace` and :rc:`figure.subplot.hspace` rather than zero. diff --git a/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst index 2132e0faf9db..7bb9009fbe77 100644 --- a/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst +++ b/doc/api/prev_api_changes/api_changes_3.5.0/deprecations.rst @@ -62,7 +62,7 @@ These methods convert from unix timestamps to matplotlib floats, but are not used internally to matplotlib, and should not be needed by end users. To convert a unix timestamp to datetime, simply use `datetime.datetime.utcfromtimestamp`, or to use NumPy `~numpy.datetime64` -``dt = np.datetim64(e*1e6, 'us')``. +``dt = np.datetime64(e*1e6, 'us')``. Auto-removal of grids by `~.Axes.pcolor` and `~.Axes.pcolormesh` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -162,7 +162,7 @@ implement a ``convert`` method that not only accepted instances of the unit, but also unitless values (which are passed through as is). This is no longer the case (``convert`` is never called with a unitless value), and such support in `.StrCategoryConverter` is deprecated. Likewise, the -`.ConversionInterface.is_numlike` helper is deprecated. +``.ConversionInterface.is_numlike`` helper is deprecated. Consider calling `.Axis.convert_units` instead, which still supports unitless values. diff --git a/doc/api/prev_api_changes/api_changes_3.5.0/removals.rst b/doc/api/prev_api_changes/api_changes_3.5.0/removals.rst index cc81bd702918..45b574e04cf5 100644 --- a/doc/api/prev_api_changes/api_changes_3.5.0/removals.rst +++ b/doc/api/prev_api_changes/api_changes_3.5.0/removals.rst @@ -214,7 +214,7 @@ The following class methods have been removed: - ``Colorbar.on_mappable_changed`` and ``Colorbar.update_bruteforce``; use ``Colorbar.update_normal()`` instead - ``docstring.Substitution.from_params`` has been removed; directly assign to - ``params`` of `.Substitution` instead + ``params`` of ``docstring.Substitution`` instead - ``DraggableBase.artist_picker``; set the artist's picker instead - ``DraggableBase.on_motion_blit``; use `.DraggableBase.on_motion` instead - ``FigureCanvasGTK3._renderer_init`` @@ -260,7 +260,7 @@ Arguments - The *s* parameter to `.Axes.annotate` and `.pyplot.annotate` is no longer supported; use the new name *text*. -- The *inframe* parameter to `.Axes.draw` has been removed; use +- The *inframe* parameter to `matplotlib.axes.Axes.draw` has been removed; use `.Axes.redraw_in_frame` instead. - The *required*, *forbidden* and *allowed* parameters of `.cbook.normalize_kwargs` have been removed. @@ -282,7 +282,7 @@ Arguments - The *dummy* parameter of `.RendererPgf` has been removed. - The *props* parameter of `.Shadow` has been removed; use keyword arguments instead. -- The *recursionlimit* parameter of `matplotlib.test` has been removed. +- The *recursionlimit* parameter of ``matplotlib.test`` has been removed. - The *label* parameter of `.Tick` has no effect and has been removed. - `~.ticker.MaxNLocator` no longer accepts a positional parameter and the keyword argument *nbins* simultaneously because they specify the same @@ -312,13 +312,13 @@ Arguments warning for keyword arguments that were overridden by the mappable is now removed. -- Omitting the *renderer* parameter to `.Axes.draw` is no longer supported; use - ``axes.draw_artist(axes)`` instead. +- Omitting the *renderer* parameter to `matplotlib.axes.Axes.draw` is no longer + supported; use ``axes.draw_artist(axes)`` instead. - Passing ``ismath="TeX!"`` to `.RendererAgg.get_text_width_height_descent` is no longer supported; pass ``ismath="TeX"`` instead, -- Changes to the signature of the `.Axes.draw` method make it consistent with - all other artists; thus additional parameters to `.Artist.draw` have also - been removed. +- Changes to the signature of the `matplotlib.axes.Axes.draw` method make it + consistent with all other artists; thus additional parameters to + `.Artist.draw` have also been removed. rcParams ~~~~~~~~ diff --git a/doc/api/prev_api_changes/api_changes_3.5.3.rst b/doc/api/prev_api_changes/api_changes_3.5.3.rst new file mode 100644 index 000000000000..03d1f476513e --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.5.3.rst @@ -0,0 +1,13 @@ +API Changes for 3.5.3 +===================== + +.. contents:: + :local: + :depth: 1 + +Passing *linefmt* positionally is undeprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Positional use of all formatting parameters in `~.Axes.stem` has been +deprecated since Matplotlib 3.5. This deprecation is relaxed so that one can +still pass *linefmt* positionally, i.e. ``stem(x, y, 'r')``. diff --git a/doc/api/prev_api_changes/api_changes_3.6.0.rst b/doc/api/prev_api_changes/api_changes_3.6.0.rst new file mode 100644 index 000000000000..1bba4506fd7d --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.6.0.rst @@ -0,0 +1,14 @@ +API Changes for 3.6.0 +===================== + +.. contents:: + :local: + :depth: 1 + +.. include:: /api/prev_api_changes/api_changes_3.6.0/behaviour.rst + +.. include:: /api/prev_api_changes/api_changes_3.6.0/deprecations.rst + +.. include:: /api/prev_api_changes/api_changes_3.6.0/removals.rst + +.. include:: /api/prev_api_changes/api_changes_3.6.0/development.rst diff --git a/doc/api/prev_api_changes/api_changes_3.6.0/behaviour.rst b/doc/api/prev_api_changes/api_changes_3.6.0/behaviour.rst new file mode 100644 index 000000000000..a35584b04961 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.6.0/behaviour.rst @@ -0,0 +1,248 @@ +Behaviour changes +----------------- + +``plt.get_cmap`` and ``matplotlib.cm.get_cmap`` return a copy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Formerly, `~.pyplot.get_cmap` and `.cm.get_cmap` returned a global version of a +`.Colormap`. This was prone to errors as modification of the colormap would +propagate from one location to another without warning. Now, a new copy of the +colormap is returned. + +Large ``imshow`` images are now downsampled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When showing an image using `~matplotlib.axes.Axes.imshow` that has more than +:math:`2^{24}` columns or :math:`2^{23}` rows, the image will now be +downsampled to below this resolution before being resampled for display by the +AGG renderer. Previously such a large image would be shown incorrectly. To +prevent this downsampling and the warning it raises, manually downsample your +data before handing it to `~matplotlib.axes.Axes.imshow`. + +Default date limits changed to 1970-01-01 – 1970-01-02 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously the default limits for an empty axis set up for dates +(`.Axis.axis_date`) was 2000-01-01 to 2010-01-01. This has been changed to +1970-01-01 to 1970-01-02. With the default epoch, this makes the numeric limit +for date axes the same as for other axes (0.0-1.0), and users are less likely +to set a locator with far too many ticks. + +*markerfmt* argument to ``stem`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The behavior of the *markerfmt* parameter of `~.Axes.stem` has changed: + +- If *markerfmt* does not contain a color, the color is taken from *linefmt*. +- If *markerfmt* does not contain a marker, the default is 'o'. + +Before, *markerfmt* was passed unmodified to ``plot(..., fmt)``, which had a +number of unintended side-effects; e.g. only giving a color switched to a solid +line without markers. + +For a simple call ``stem(x, y)`` without parameters, the new rules still +reproduce the old behavior. + +``get_ticklabels`` now always populates labels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously `.Axis.get_ticklabels` (and `.Axes.get_xticklabels`, +`.Axes.get_yticklabels`) would only return empty strings unless a draw had +already been performed. Now the ticks and their labels are updated when the +labels are requested. + +Warning when scatter plot color settings discarded +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When making an animation of a scatter plot, if you don't set *c* (the color +value parameter) when initializing the artist, the color settings are ignored. +`.Axes.scatter` now raises a warning if color-related settings are changed +without setting *c*. + +3D ``contourf`` polygons placed between levels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The polygons used in a 3D `~.Axes3D.contourf` plot are now placed halfway +between the contour levels, as each polygon represents the location of values +that lie between two levels. + +Axes title now avoids y-axis offset +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, Axes titles could overlap the y-axis offset text, which is often in +the upper left corner of the axes. Now titles are moved above the offset text +if overlapping when automatic title positioning is in effect (i.e. if *y* in +`.Axes.set_title` is *None* and :rc:`axes.titley` is also *None*). + +Dotted operators gain extra space in mathtext +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In mathtext, ``\doteq \doteqdot \dotminus \dotplus \dots`` are now surrounded +by extra space because they are correctly treated as relational or binary +operators. + +*math* parameter of ``mathtext.get_unicode_index`` defaults to False +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In math mode, ASCII hyphens (U+002D) are now replaced by Unicode minus signs +(U+2212) at the parsing stage. + +``ArtistList`` proxies copy contents on iteration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When iterating over the contents of the dynamically generated proxy lists for +the Artist-type accessors (see :ref:`Behavioural API Changes 3.5 - Axes +children combined`), a copy of the contents is made. This ensure that artists +can safely be added or removed from the Axes while iterating over their +children. + +This is a departure from the expected behavior of mutable iterable data types +in Python — iterating over a list while mutating it has surprising consequences +and dictionaries will error if they change size during iteration. Because all +of the accessors are filtered views of the same underlying list, it is possible +for seemingly unrelated changes, such as removing a Line, to affect the +iteration over any of the other accessors. In this case, we have opted to make +a copy of the relevant children before yielding them to the user. + +This change is also consistent with our plan to make these accessors immutable +in Matplotlib 3.7. + +``AxesImage`` string representation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The string representation of `.AxesImage` changes from stating the position in +the figure ``"AxesImage(80,52.8;496x369.6)"`` to giving the number of pixels +``"AxesImage(size=(300, 200))"``. + +Improved autoscaling for Bézier curves +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Bézier curves are now autoscaled to their extents - previously they were +autoscaled to their ends and control points, which in some cases led to +unnecessarily large limits. + +``QuadMesh`` mouseover defaults to False +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +New in 3.5, `.QuadMesh.get_cursor_data` allows display of data values under the +cursor. However, this can be very slow for large meshes, so mouseover now +defaults to *False*. + +Changed pgf backend document class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The pgf backend now uses the ``article`` document class as basis for +compilation. + +``MathtextBackendAgg.get_results`` no longer returns ``used_characters`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The last item (``used_characters``) in the tuple returned by +``MathtextBackendAgg.get_results`` has been removed. In order to unpack this +tuple in a backward and forward-compatible way, use e.g. ``ox, oy, width, +height, descent, image, *_ = parse(...)``, which will ignore +``used_characters`` if it was present. + +``Type1Font`` objects include more properties +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``matplotlib._type1font.Type1Font.prop`` dictionary now includes more keys, +such as ``CharStrings`` and ``Subrs``. The value of the ``Encoding`` key is now +a dictionary mapping codes to glyph names. The +``matplotlib._type1font.Type1Font.transform`` method now correctly removes +``UniqueID`` properties from the font. + +``rcParams.copy()`` returns ``RcParams`` rather than ``dict`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Returning an `.RcParams` instance from `.RcParams.copy` makes the copy still +validate inputs, and additionally avoids emitting deprecation warnings when +using a previously copied instance to update the global instance (even if some +entries are deprecated). + +``rc_context`` no longer resets the value of ``'backend'`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`matplotlib.rc_context` incorrectly reset the value of :rc:`backend` if backend +resolution was triggered in the context. This affected only the value. The +actual backend was not changed. Now, `matplotlib.rc_context` does not reset +:rc:`backend` anymore. + +Default ``rcParams["animation.convert_args"]`` changed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It now defaults to ``["-layers", "OptimizePlus"]`` to try to generate smaller +GIFs. Set it back to an empty list to recover the previous behavior. + +Style file encoding now specified to be UTF-8 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It has been impossible to import Matplotlib with a non UTF-8 compatible locale +encoding because we read the style library at import time. This change is +formalizing and documenting the status quo so there is no deprecation period. + +MacOSX backend uses sRGB instead of GenericRGB color space +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +MacOSX backend now display sRGB tagged image instead of GenericRGB which is an +older (now deprecated) Apple color space. This is the source color space used +by ColorSync to convert to the current display profile. + +Renderer optional for ``get_tightbbox`` and ``get_window_extent`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The `.Artist.get_tightbbox` and `.Artist.get_window_extent` methods no longer +require the *renderer* keyword argument, saving users from having to query it +from ``fig.canvas.get_renderer``. If the *renderer* keyword argument is not +supplied, these methods first check if there is a cached renderer from a +previous draw and use that. If there is no cached renderer, then the methods +will use ``fig.canvas.get_renderer()`` as a fallback. + +``FigureFrameWx`` constructor, subclasses, and ``get_canvas`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``FigureCanvasWx`` constructor gained a *canvas_class* keyword-only +parameter which specifies the canvas class that should be used. This parameter +will become required in the future. The ``get_canvas`` method, which was +previously used to customize canvas creation, is deprecated. The +``FigureFrameWxAgg`` and ``FigureFrameWxCairo`` subclasses, which overrode +``get_canvas``, are deprecated. + +``FigureFrameWx.sizer`` +~~~~~~~~~~~~~~~~~~~~~~~ + +... has been removed. The frame layout is no longer based on a sizer, as the +canvas is now the sole child widget; the toolbar is now a regular toolbar added +using ``SetToolBar``. + +Incompatible layout engines raise +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You cannot switch between ``tight_layout`` and ``constrained_layout`` if a +colorbar has already been added to a figure. Invoking the incompatible layout +engine used to warn, but now raises with a `RuntimeError`. + +``CallbackRegistry`` raises on unknown signals +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When Matplotlib instantiates a `.CallbackRegistry`, it now limits callbacks to +the signals that the registry knows about. In practice, this means that calling +`~.FigureCanvasBase.mpl_connect` with an invalid signal name now raises a +`ValueError`. + +Changed exception type for incorrect SVG date metadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Providing date metadata with incorrect type to the SVG backend earlier resulted +in a `ValueError`. Now, a `TypeError` is raised instead. + +Specified exception types in ``Grid`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In a few cases an `Exception` was thrown when an incorrect argument value was +set in the `mpl_toolkits.axes_grid1.axes_grid.Grid` (= +`mpl_toolkits.axisartist.axes_grid.Grid`) constructor. These are replaced as +follows: + +* Providing an incorrect value for *ngrids* now raises a `ValueError` +* Providing an incorrect type for *rect* now raises a `TypeError` diff --git a/doc/api/prev_api_changes/api_changes_3.6.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.6.0/deprecations.rst new file mode 100644 index 000000000000..3a9e91e12289 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.6.0/deprecations.rst @@ -0,0 +1,414 @@ +Deprecations +------------ + +Parameters to ``plt.figure()`` and the ``Figure`` constructor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +All parameters to `.pyplot.figure` and the `.Figure` constructor, other than +*num*, *figsize*, and *dpi*, will become keyword-only after a deprecation +period. + +Deprecation aliases in cbook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The module ``matplotlib.cbook.deprecation`` was previously deprecated in +Matplotlib 3.4, along with deprecation-related API in ``matplotlib.cbook``. Due +to technical issues, ``matplotlib.cbook.MatplotlibDeprecationWarning`` and +``matplotlib.cbook.mplDeprecation`` did not raise deprecation warnings on use. +Changes in Python have now made it possible to warn when these aliases are +being used. + +In order to avoid downstream breakage, these aliases will now warn, and their +removal has been pushed from 3.6 to 3.8 to give time to notice said warnings. +As replacement, please use `matplotlib.MatplotlibDeprecationWarning`. + +``Axes`` subclasses should override ``clear`` instead of ``cla`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For clarity, `.axes.Axes.clear` is now preferred over `.Axes.cla`. However, for +backwards compatibility, the latter will remain as an alias for the former. + +For additional compatibility with third-party libraries, Matplotlib will +continue to call the ``cla`` method of any `~.axes.Axes` subclasses if they +define it. In the future, this will no longer occur, and Matplotlib will only +call the ``clear`` method in `~.axes.Axes` subclasses. + +It is recommended to define only the ``clear`` method when on Matplotlib 3.6, +and only ``cla`` for older versions. + +Pending deprecation top-level cmap registration and access functions in ``mpl.cm`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As part of a `multi-step process +`_ we are refactoring +the global state for managing the registered colormaps. + +In Matplotlib 3.5 we added a `.ColormapRegistry` class and exposed an instance +at the top level as ``matplotlib.colormaps``. The existing top level functions +in `matplotlib.cm` (``get_cmap``, ``register_cmap``, ``unregister_cmap``) were +changed to be aliases around the same instance. + +In Matplotlib 3.6 we have marked those top level functions as pending +deprecation with the intention of deprecation in Matplotlib 3.7. The following +functions have been marked for pending deprecation: + +- ``matplotlib.cm.get_cmap``; use ``matplotlib.colormaps[name]`` instead if you + have a `str`. + + **Added 3.6.1** Use `matplotlib.cm.ColormapRegistry.get_cmap` if you + have a string, `None` or a `matplotlib.colors.Colormap` object that you want + to convert to a `matplotlib.colors.Colormap` instance. +- ``matplotlib.cm.register_cmap``; use `matplotlib.colormaps.register + <.ColormapRegistry.register>` instead +- ``matplotlib.cm.unregister_cmap``; use `matplotlib.colormaps.unregister + <.ColormapRegistry.unregister>` instead +- ``matplotlib.pyplot.register_cmap``; use `matplotlib.colormaps.register + <.ColormapRegistry.register>` instead + +The `matplotlib.pyplot.get_cmap` function will stay available for backward +compatibility. + +Pending deprecation of layout methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The methods `~.Figure.set_tight_layout`, `~.Figure.set_constrained_layout`, are +discouraged, and now emit a `PendingDeprecationWarning` in favor of explicitly +referencing the layout engine via ``figure.set_layout_engine('tight')`` and +``figure.set_layout_engine('constrained')``. End users should not see the +warning, but library authors should adjust. + +The methods `~.Figure.set_constrained_layout_pads` and +`~.Figure.get_constrained_layout_pads` are will be deprecated in favor of +``figure.get_layout_engine().set()`` and ``figure.get_layout_engine().get()``, +and currently emit a `PendingDeprecationWarning`. + +seaborn styles renamed +~~~~~~~~~~~~~~~~~~~~~~ + +Matplotlib currently ships many style files inspired from the seaborn library +("seaborn", "seaborn-bright", "seaborn-colorblind", etc.) but they have gone +out of sync with the library itself since the release of seaborn 0.9. To +prevent confusion, the style files have been renamed "seaborn-v0_8", +"seaborn-v0_8-bright", "seaborn-v0_8-colorblind", etc. Users are encouraged to +directly use seaborn to access the up-to-date styles. + +Auto-removal of overlapping Axes by ``plt.subplot`` and ``plt.subplot2grid`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, `.pyplot.subplot` and `.pyplot.subplot2grid` would automatically +remove preexisting Axes that overlap with the newly added Axes. This behavior +was deemed confusing, and is now deprecated. Explicitly call ``ax.remove()`` on +Axes that need to be removed. + +Passing *linefmt* positionally to ``stem`` is undeprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Positional use of all formatting parameters in `~.Axes.stem` has been +deprecated since Matplotlib 3.5. This deprecation is relaxed so that one can +still pass *linefmt* positionally, i.e. ``stem(x, y, 'r')``. + +``stem(..., use_line_collection=False)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated with no replacement. This was a compatibility fallback to a +former more inefficient representation of the stem lines. + +Positional / keyword arguments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Passing all but the very few first arguments positionally in the constructors +of Artists is deprecated. Most arguments will become keyword-only in a future +version. + +Passing too many positional arguments to ``tripcolor`` is now deprecated (extra +arguments were previously silently ignored). + +Passing *emit* and *auto* parameters of ``set_xlim``, ``set_ylim``, +``set_zlim``, ``set_rlim`` positionally is deprecated; they will become +keyword-only in a future release. + +The *transOffset* parameter of `.Collection.set_offset_transform` and the +various ``create_collection`` methods of legend handlers has been renamed to +*offset_transform* (consistently with the property name). + +Calling ``MarkerStyle()`` with no arguments or ``MarkerStyle(None)`` is +deprecated; use ``MarkerStyle("")`` to construct an empty marker style. + +``Axes.get_window_extent`` / ``Figure.get_window_extent`` accept only +*renderer*. This aligns the API with the general `.Artist.get_window_extent` +API. All other parameters were ignored anyway. + +The *cleared* parameter of ``get_renderer``, which only existed for AGG-based +backends, has been deprecated. Use ``renderer.clear()`` instead to explicitly +clear the renderer buffer. + +Methods to set parameters in ``LogLocator`` and ``LogFormatter*`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In `~.LogFormatter` and derived subclasses, the methods ``base`` and +``label_minor`` for setting the respective parameter are deprecated and +replaced by ``set_base`` and ``set_label_minor``, respectively. + +In `~.LogLocator`, the methods ``base`` and ``subs`` for setting the respective +parameter are deprecated. Instead, use ``set_params(base=..., subs=...)``. + +``Axes.get_renderer_cache`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The canvas now takes care of the renderer and whether to cache it or not. The +alternative is to call ``axes.figure.canvas.get_renderer()``. + +Groupers from ``get_shared_x_axes`` / ``get_shared_y_axes`` will be immutable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Modifications to the Groupers returned by ``get_shared_x_axes`` and +``get_shared_y_axes`` are deprecated. In the future, these methods will return +immutable views on the grouper structures. Note that previously, calling e.g. +``join()`` would already fail to set up the correct structures for sharing +axes; use `.Axes.sharex` or `.Axes.sharey` instead. + +Unused methods in ``Axis``, ``Tick``, ``XAxis``, and ``YAxis`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Tick.label`` has been pending deprecation since 3.1 and is now deprecated. +Use ``Tick.label1`` instead. + +The following methods are no longer used and deprecated without a replacement: + +- ``Axis.get_ticklabel_extents`` +- ``Tick.get_pad_pixels`` +- ``XAxis.get_text_heights`` +- ``YAxis.get_text_widths`` + +``mlab.stride_windows`` +~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. Use ``np.lib.stride_tricks.sliding_window_view`` instead (or +``np.lib.stride_tricks.as_strided`` on NumPy < 1.20). + +Event handlers +~~~~~~~~~~~~~~ + +The ``draw_event``, ``resize_event``, ``close_event``, ``key_press_event``, +``key_release_event``, ``pick_event``, ``scroll_event``, +``button_press_event``, ``button_release_event``, ``motion_notify_event``, +``enter_notify_event`` and ``leave_notify_event`` methods of +`.FigureCanvasBase` are deprecated. They had inconsistent signatures across +backends, and made it difficult to improve event metadata. + +In order to trigger an event on a canvas, directly construct an `.Event` object +of the correct class and call ``canvas.callbacks.process(event.name, event)``. + +Widgets +~~~~~~~ + +All parameters to ``MultiCursor`` starting from *useblit* are becoming +keyword-only (passing them positionally is deprecated). + +The ``canvas`` and ``background`` attributes of ``MultiCursor`` are deprecated +with no replacement. + +The *visible* attribute of Selector widgets has been deprecated; use +``set_visible`` or ``get_visible`` instead. + +The *state_modifier_keys* attribute of Selector widgets has been privatized and +the modifier keys must be set when creating the widget. + +``Axes3D.dist`` +~~~~~~~~~~~~~~~ + +... has been privatized. Use the *zoom* keyword argument in +`.Axes3D.set_box_aspect` instead. + +3D Axis +~~~~~~~ + +The previous constructor of `.axis3d.Axis`, with signature ``(self, adir, +v_intervalx, d_intervalx, axes, *args, rotate_label=None, **kwargs)`` is +deprecated in favor of a new signature closer to the one of 2D Axis; it is now +``(self, axes, *, rotate_label=None, **kwargs)`` where ``kwargs`` are forwarded +to the 2D Axis constructor. The axis direction is now inferred from the axis +class' ``axis_name`` attribute (as in the 2D case); the ``adir`` attribute is +deprecated. + +The ``init3d`` method of 3D Axis is also deprecated; all the relevant +initialization is done as part of the constructor. + +The ``d_interval`` and ``v_interval`` attributes of 3D Axis are deprecated; use +``get_data_interval`` and ``get_view_interval`` instead. + +The ``w_xaxis``, ``w_yaxis``, and ``w_zaxis`` attributes of ``Axis3D`` have +been pending deprecation since 3.1. They are now deprecated. Instead use +``xaxis``, ``yaxis``, and ``zaxis``. + +``mplot3d.axis3d.Axis.set_pane_pos`` is deprecated. This is an internal method +where the provided values are overwritten during drawing. Hence, it does not +serve any purpose to be directly accessible. + +The two helper functions ``mplot3d.axis3d.move_from_center`` and +``mplot3d.axis3d.tick_update_position`` are considered internal and deprecated. +If these are required, please vendor the code from the corresponding private +methods ``_move_from_center`` and ``_tick_update_position``. + +``Figure.callbacks`` is deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Figure ``callbacks`` property is deprecated. The only signal was +"dpi_changed", which can be replaced by connecting to the "resize_event" on the +canvas ``figure.canvas.mpl_connect("resize_event", func)`` instead. + +``FigureCanvas`` without a ``required_interactive_framework`` attribute +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Support for such canvas classes is deprecated. Note that canvas classes which +inherit from ``FigureCanvasBase`` always have such an attribute. + +Backend-specific deprecations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``backend_gtk3.FigureManagerGTK3Agg`` and + ``backend_gtk4.FigureManagerGTK4Agg``; directly use + ``backend_gtk3.FigureManagerGTK3`` and ``backend_gtk4.FigureManagerGTK4`` + instead. +- The *window* parameter to ``backend_gtk3.NavigationToolbar2GTK3`` had no + effect, and is now deprecated. +- ``backend_gtk3.NavigationToolbar2GTK3.win`` +- ``backend_gtk3.RendererGTK3Cairo`` and ``backend_gtk4.RendererGTK4Cairo``; + use `.RendererCairo` instead, which has gained the ``set_context`` method, + which also auto-infers the size of the underlying surface. +- ``backend_cairo.RendererCairo.set_ctx_from_surface`` and + ``backend_cairo.RendererCairo.set_width_height`` in favor of + `.RendererCairo.set_context`. +- ``backend_gtk3.error_msg_gtk`` +- ``backend_gtk3.icon_filename`` and ``backend_gtk3.window_icon`` +- ``backend_macosx.NavigationToolbar2Mac.prepare_configure_subplots`` has been + replaced by ``configure_subplots()``. +- ``backend_pdf.Name.hexify`` +- ``backend_pdf.Operator`` and ``backend_pdf.Op.op`` are deprecated in favor of + a single standard `enum.Enum` interface on `.backend_pdf.Op`. +- ``backend_pdf.fill``; vendor the code of the similarly named private + functions if you rely on these functions. +- ``backend_pgf.LatexManager.texcommand`` and + ``backend_pgf.LatexManager.latex_header`` +- ``backend_pgf.NO_ESCAPE`` +- ``backend_pgf.common_texification`` +- ``backend_pgf.get_fontspec`` +- ``backend_pgf.get_preamble`` +- ``backend_pgf.re_mathsep`` +- ``backend_pgf.writeln`` +- ``backend_ps.convert_psfrags`` +- ``backend_ps.quote_ps_string``; vendor the code of the similarly named + private functions if you rely on it. +- ``backend_qt.qApp``; use ``QtWidgets.QApplication.instance()`` instead. +- ``backend_svg.escape_attrib``; vendor the code of the similarly named private + functions if you rely on it. +- ``backend_svg.escape_cdata``; vendor the code of the similarly named private + functions if you rely on it. +- ``backend_svg.escape_comment``; vendor the code of the similarly named + private functions if you rely on it. +- ``backend_svg.short_float_fmt``; vendor the code of the similarly named + private functions if you rely on it. +- ``backend_svg.generate_transform`` and ``backend_svg.generate_css`` +- ``backend_tk.NavigationToolbar2Tk.lastrect`` and + ``backend_tk.RubberbandTk.lastrect`` +- ``backend_tk.NavigationToolbar2Tk.window``; use ``toolbar.master`` instead. +- ``backend_tools.ToolBase.destroy``; To run code upon tool removal, connect to + the ``tool_removed_event`` event. +- ``backend_wx.RendererWx.offset_text_height`` +- ``backend_wx.error_msg_wx`` + +- ``FigureCanvasBase.pick``; directly call `.Figure.pick`, which has taken over + the responsibility of checking the canvas widget lock as well. +- ``FigureCanvasBase.resize``, which has no effect; use + ``FigureManagerBase.resize`` instead. + +- ``FigureManagerMac.close`` + +- ``FigureFrameWx.sizer``; use ``frame.GetSizer()`` instead. +- ``FigureFrameWx.figmgr`` and ``FigureFrameWx.get_figure_manager``; use + ``frame.canvas.manager`` instead. +- ``FigureFrameWx.num``; use ``frame.canvas.manager.num`` instead. +- ``FigureFrameWx.toolbar``; use ``frame.GetToolBar()`` instead. +- ``FigureFrameWx.toolmanager``; use ``frame.canvas.manager.toolmanager`` + instead. + +Modules +~~~~~~~ + +The modules ``matplotlib.afm``, ``matplotlib.docstring``, +``matplotlib.fontconfig_pattern``, ``matplotlib.tight_bbox``, +``matplotlib.tight_layout``, and ``matplotlib.type1font`` are considered +internal and public access is deprecated. + +``checkdep_usetex`` deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This method was only intended to disable tests in case no latex install was +found. As such, it is considered to be private and for internal use only. + +Please vendor the code if you need this. + +``date_ticker_factory`` deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``date_ticker_factory`` method in the `matplotlib.dates` module is +deprecated. Instead use `~.AutoDateLocator` and `~.AutoDateFormatter` for a +more flexible and scalable locator and formatter. + +If you need the exact ``date_ticker_factory`` behavior, please copy the code. + +``dviread.find_tex_file`` will raise ``FileNotFoundError`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In the future, ``dviread.find_tex_file`` will raise a `FileNotFoundError` for +missing files. Previously, it would return an empty string in such cases. +Raising an exception allows attaching a user-friendly message instead. During +the transition period, a warning is raised. + +``transforms.Affine2D.identity()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated in favor of directly calling the `.Affine2D` constructor with +no arguments. + +Deprecations in ``testing.decorators`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The unused class ``CleanupTestCase`` and decorator ``cleanup`` are deprecated +and will be removed. Vendor the code, including the private function +``_cleanup_cm``. + +The function ``check_freetype_version`` is considered internal and deprecated. +Vendor the code of the private function ``_check_freetype_version``. + +``text.get_rotation()`` +~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated with no replacement. Copy the original implementation if +needed. + +Miscellaneous internals +~~~~~~~~~~~~~~~~~~~~~~~ + +- ``axes_grid1.axes_size.AddList``; use ``sum(sizes, start=Fixed(0))`` (for + example) to sum multiple size objects. +- ``axes_size.Padded``; use ``size + pad`` instead +- ``axes_size.SizeFromFunc``, ``axes_size.GetExtentHelper`` +- ``AxisArtistHelper.delta1`` and ``AxisArtistHelper.delta2`` +- ``axislines.GridHelperBase.new_gridlines`` and + ``axislines.Axes.new_gridlines`` +- ``cbook.maxdict``; use the standard library ``functools.lru_cache`` instead. +- ``_DummyAxis.dataLim`` and ``_DummyAxis.viewLim``; use + ``get_data_interval()``, ``set_data_interval()``, ``get_view_interval()``, + and ``set_view_interval()`` instead. +- ``GridSpecBase.get_grid_positions(..., raw=True)`` +- ``ImageMagickBase.delay`` and ``ImageMagickBase.output_args`` +- ``MathtextBackend``, ``MathtextBackendAgg``, ``MathtextBackendPath``, + ``MathTextWarning`` +- ``TexManager.get_font_config``; it previously returned an internal hashed key + for used for caching purposes. +- ``TextToPath.get_texmanager``; directly construct a `.texmanager.TexManager` + instead. +- ``ticker.is_close_to_int``; use ``math.isclose(x, round(x))`` instead. +- ``ticker.is_decade``; use ``y = numpy.log(x)/numpy.log(base); + numpy.isclose(y, numpy.round(y))`` instead. diff --git a/doc/api/prev_api_changes/api_changes_3.6.0/development.rst b/doc/api/prev_api_changes/api_changes_3.6.0/development.rst new file mode 100644 index 000000000000..fb9f1f3e21c5 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.6.0/development.rst @@ -0,0 +1,42 @@ +Development changes +------------------- + +Increase to minimum supported versions of dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For Matplotlib 3.6, the :ref:`minimum supported versions ` are +being bumped: + ++------------+-----------------+---------------+ +| Dependency | min in mpl3.5 | min in mpl3.6 | ++============+=================+===============+ +| Python | 3.7 | 3.8 | ++------------+-----------------+---------------+ +| NumPy | 1.17 | 1.19 | ++------------+-----------------+---------------+ + +This is consistent with our :ref:`min_deps_policy` and `NEP29 +`__ + +Build setup options changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``gui_support.macosx`` setup option has been renamed to +``packages.macosx``. + +New wheel architectures +~~~~~~~~~~~~~~~~~~~~~~~ + +Wheels have been added for: + +- Python 3.11 +- PyPy 3.8 and 3.9 + +Increase to required versions of documentation dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`sphinx`_ >= 3.0 and `numpydoc`_ >= 1.0 are now required for building the +documentation. + +.. _numpydoc: https://pypi.org/project/numpydoc/ +.. _sphinx: https://pypi.org/project/Sphinx/ diff --git a/doc/api/prev_api_changes/api_changes_3.6.0/removals.rst b/doc/api/prev_api_changes/api_changes_3.6.0/removals.rst new file mode 100644 index 000000000000..b261fdb30596 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.6.0/removals.rst @@ -0,0 +1,222 @@ +Removals +-------- + +The following deprecated APIs have been removed: + +Removed behaviour +~~~~~~~~~~~~~~~~~ + +Stricter validation of function parameters +.......................................... + +- Unknown keyword arguments to `.Figure.savefig`, `.pyplot.savefig`, and the + ``FigureCanvas.print_*`` methods now raise a `TypeError`, instead of being + ignored. +- Extra parameters to the `~.axes.Axes` constructor, i.e., those other than + *fig* and *rect*, are now keyword only. +- Passing arguments not specifically listed in the signatures of + `.Axes3D.plot_surface` and `.Axes3D.plot_wireframe` is no longer supported; + pass any extra arguments as keyword arguments instead. +- Passing positional arguments to `.LineCollection` has been removed; use + specific keyword argument names now. + +``imread`` no longer accepts URLs +................................. + +Passing a URL to `~.pyplot.imread()` has been removed. Please open the URL for +reading and directly use the Pillow API (e.g., +``PIL.Image.open(urllib.request.urlopen(url))``, or +``PIL.Image.open(io.BytesIO(requests.get(url).content))``) instead. + +MarkerStyle is immutable +........................ + +The methods ``MarkerStyle.set_fillstyle`` and ``MarkerStyle.set_marker`` have +been removed. Create a new `.MarkerStyle` with the respective parameters +instead. + +Passing bytes to ``FT2Font.set_text`` +..................................... + +... is no longer supported. Pass `str` instead. + +Support for passing tool names to ``ToolManager.add_tool`` +.......................................................... + +... has been removed. The second parameter to `.ToolManager.add_tool` must now +always be a tool class. + +``backend_tools.ToolFullScreen`` now inherits from ``ToolBase``, not from ``ToolToggleBase`` +............................................................................................ + +`.ToolFullScreen` can only switch between the non-fullscreen and fullscreen +states, but not unconditionally put the window in a given state; hence the +``enable`` and ``disable`` methods were misleadingly named. Thus, the +`.ToolToggleBase`-related API (``enable``, ``disable``, etc.) was removed. + +``BoxStyle._Base`` and ``transmute`` method of box styles +......................................................... + +... have been removed. Box styles implemented as classes no longer need to +inherit from a base class. + +Loaded modules logging +...................... + +The list of currently loaded modules is no longer logged at the DEBUG level at +Matplotlib import time, because it can produce extensive output and make other +valuable DEBUG statements difficult to find. If you were relying on this +output, please arrange for your own logging (the built-in `sys.modules` can be +used to get the currently loaded modules). + +Modules +~~~~~~~ + +- The ``cbook.deprecation`` module has been removed from the public API as it + is considered internal. +- The ``mpl_toolkits.axes_grid`` module has been removed. All functionality from + ``mpl_toolkits.axes_grid`` can be found in either `mpl_toolkits.axes_grid1` + or `mpl_toolkits.axisartist`. Axes classes from ``mpl_toolkits.axes_grid`` + based on ``Axis`` from `mpl_toolkits.axisartist` can be found in + `mpl_toolkits.axisartist`. + +Classes, methods and attributes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following module-level classes/variables have been removed: + +- ``cm.cmap_d`` +- ``colorbar.colorbar_doc``, ``colorbar.colorbar_kw_doc`` +- ``ColorbarPatch`` +- ``mathtext.Fonts`` and all its subclasses +- ``mathtext.FontConstantsBase`` and all its subclasses +- ``mathtext.latex_to_bakoma``, ``mathtext.latex_to_cmex``, + ``mathtext.latex_to_standard`` +- ``mathtext.MathtextBackendPdf``, ``mathtext.MathtextBackendPs``, + ``mathtext.MathtextBackendSvg``, ``mathtext.MathtextBackendCairo``; use + `.MathtextBackendPath` instead. +- ``mathtext.Node`` and all its subclasses +- ``mathtext.NUM_SIZE_LEVELS`` +- ``mathtext.Parser`` +- ``mathtext.Ship`` +- ``mathtext.SHRINK_FACTOR`` and ``mathtext.GROW_FACTOR`` +- ``mathtext.stix_virtual_fonts``, +- ``mathtext.tex2uni`` +- ``backend_pgf.TmpDirCleaner`` +- ``backend_ps.GraphicsContextPS``; use ``GraphicsContextBase`` instead. +- ``backend_wx.IDLE_DELAY`` +- ``axes_grid1.parasite_axes.ParasiteAxesAuxTransBase``; use + `.ParasiteAxesBase` instead. +- ``axes_grid1.parasite_axes.ParasiteAxesAuxTrans``; use `.ParasiteAxes` + instead. + +The following class attributes have been removed: + +- ``Line2D.validCap`` and ``Line2D.validJoin``; validation is centralized in + ``rcsetup``. +- ``Patch.validCap`` and ``Patch.validJoin``; validation is centralized in + ``rcsetup``. +- ``renderer.M``, ``renderer.eye``, ``renderer.vvec``, + ``renderer.get_axis_position`` placed on the Renderer during 3D Axes draw; + these attributes are all available via `.Axes3D`, which can be accessed via + ``self.axes`` on all `.Artist`\s. +- ``RendererPdf.mathtext_parser``, ``RendererPS.mathtext_parser``, + ``RendererSVG.mathtext_parser``, ``RendererCairo.mathtext_parser`` +- ``StandardPsFonts.pswriter`` +- ``Subplot.figbox``; use `.Axes.get_position` instead. +- ``Subplot.numRows``; ``ax.get_gridspec().nrows`` instead. +- ``Subplot.numCols``; ``ax.get_gridspec().ncols`` instead. +- ``SubplotDivider.figbox`` +- ``cids``, ``cnt``, ``observers``, ``change_observers``, and + ``submit_observers`` on all `.Widget`\s + +The following class methods have been removed: + +- ``Axis.cla()``; use `.Axis.clear` instead. +- ``RadialAxis.cla()`` and ``ThetaAxis.cla()``; use `.RadialAxis.clear` or + `.ThetaAxis.clear` instead. +- ``Spine.cla()``; use `.Spine.clear` instead. +- ``ContourLabeler.get_label_coords()``; there is no replacement as it was + considered an internal helper. +- ``FancyArrowPatch.get_dpi_cor`` and ``FancyArrowPatch.set_dpi_cor`` + +- ``FigureCanvas.get_window_title()`` and ``FigureCanvas.set_window_title()``; + use `.FigureManagerBase.get_window_title` or + `.FigureManagerBase.set_window_title` if using pyplot, or use GUI-specific + methods if embedding. +- ``FigureManager.key_press()`` and ``FigureManager.button_press()``; trigger + the events directly on the canvas using + ``canvas.callbacks.process(event.name, event)`` for key and button events. + +- ``RendererAgg.get_content_extents()`` and + ``RendererAgg.tostring_rgba_minimized()`` +- ``NavigationToolbar2Wx.get_canvas()`` + +- ``ParasiteAxesBase.update_viewlim()``; use ``ParasiteAxesBase.apply_aspect`` + instead. +- ``Subplot.get_geometry()``; use ``SubplotBase.get_subplotspec`` instead. +- ``Subplot.change_geometry()``; use ``SubplotBase.set_subplotspec`` instead. +- ``Subplot.update_params()``; this method did nothing. +- ``Subplot.is_first_row()``; use ``ax.get_subplotspec().is_first_row`` + instead. +- ``Subplot.is_first_col()``; use ``ax.get_subplotspec().is_first_col`` + instead. +- ``Subplot.is_last_row()``; use ``ax.get_subplotspec().is_last_row`` instead. +- ``Subplot.is_last_col()``; use ``ax.get_subplotspec().is_last_col`` instead. +- ``SubplotDivider.change_geometry()``; use `.SubplotDivider.set_subplotspec` + instead. +- ``SubplotDivider.get_geometry()``; use `.SubplotDivider.get_subplotspec` + instead. +- ``SubplotDivider.update_params()`` +- ``get_depth``, ``parse``, ``to_mask``, ``to_rgba``, and ``to_png`` of + `.MathTextParser`; use `.mathtext.math_to_image` instead. + +- ``MovieWriter.cleanup()``; the cleanup logic is instead fully implemented in + `.MovieWriter.finish` and ``cleanup`` is no longer called. + +Functions +~~~~~~~~~ + +The following functions have been removed; + +- ``backend_template.new_figure_manager()``, + ``backend_template.new_figure_manager_given_figure()``, and + ``backend_template.draw_if_interactive()`` have been removed, as part of the + introduction of the simplified backend API. +- Deprecation-related re-imports ``cbook.deprecated()``, and + ``cbook.warn_deprecated()``. +- ``colorbar.colorbar_factory()``; use `.Colorbar` instead. + ``colorbar.make_axes_kw_doc()`` +- ``mathtext.Error()`` +- ``mathtext.ship()`` +- ``mathtext.tex2uni()`` +- ``axes_grid1.parasite_axes.parasite_axes_auxtrans_class_factory()``; use + `.parasite_axes_class_factory` instead. +- ``sphinext.plot_directive.align()``; use + ``docutils.parsers.rst.directives.images.Image.align`` instead. + +Arguments +~~~~~~~~~ + +The following arguments have been removed: + +- *dpi* from ``print_ps()`` in the PS backend and ``print_pdf()`` in the PDF + backend. Instead, the methods will obtain the DPI from the ``savefig`` + machinery. +- *dpi_cor* from `~.FancyArrowPatch` +- *minimum_descent* from ``TextArea``; it is now effectively always True +- *origin* from ``FigureCanvasWx.gui_repaint()`` +- *project* from ``Line3DCollection.draw()`` +- *renderer* from `.Line3DCollection.do_3d_projection`, + `.Patch3D.do_3d_projection`, `.PathPatch3D.do_3d_projection`, + `.Path3DCollection.do_3d_projection`, `.Patch3DCollection.do_3d_projection`, + `.Poly3DCollection.do_3d_projection` +- *resize_callback* from the Tk backend; use + ``get_tk_widget().bind('', ..., True)`` instead. +- *return_all* from ``gridspec.get_position()`` +- Keyword arguments to ``gca()``; there is no replacement. + +rcParams +~~~~~~~~ + +The setting :rc:`ps.useafm` no longer has any effect on `matplotlib.mathtext`. diff --git a/doc/api/prev_api_changes/api_changes_3.6.1.rst b/doc/api/prev_api_changes/api_changes_3.6.1.rst new file mode 100644 index 000000000000..ad929d426885 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.6.1.rst @@ -0,0 +1,15 @@ +API Changes for 3.6.1 +===================== + +Deprecations +------------ + +Colorbars for orphaned mappables are deprecated, but no longer raise +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before 3.6.0, Colorbars for mappables that do not have a parent Axes would +steal space from the current Axes. 3.6.0 raised an error on this, but without a +deprecation cycle. For 3.6.1 this is reverted; the current Axes is used, but a +deprecation warning is shown instead. In this undetermined case, users and +libraries should explicitly specify what Axes they want space to be stolen +from: ``fig.colorbar(mappable, ax=plt.gca())``. diff --git a/doc/api/prev_api_changes/api_changes_3.7.0.rst b/doc/api/prev_api_changes/api_changes_3.7.0.rst new file mode 100644 index 000000000000..932a4ba34452 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.7.0.rst @@ -0,0 +1,14 @@ +API Changes for 3.7.0 +===================== + +.. contents:: + :local: + :depth: 1 + +.. include:: /api/prev_api_changes/api_changes_3.7.0/behaviour.rst + +.. include:: /api/prev_api_changes/api_changes_3.7.0/deprecations.rst + +.. include:: /api/prev_api_changes/api_changes_3.7.0/removals.rst + +.. include:: /api/prev_api_changes/api_changes_3.7.0/development.rst diff --git a/doc/api/prev_api_changes/api_changes_3.7.0/behaviour.rst b/doc/api/prev_api_changes/api_changes_3.7.0/behaviour.rst new file mode 100644 index 000000000000..6057bfa9af4c --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.7.0/behaviour.rst @@ -0,0 +1,136 @@ +Behaviour Changes +----------------- + +All Axes have ``get_subplotspec`` and ``get_gridspec`` methods now, which returns None for Axes not positioned via a gridspec +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, this method was only present for Axes positioned via a gridspec. +Following this change, checking ``hasattr(ax, "get_gridspec")`` should now be +replaced by ``ax.get_gridspec() is not None``. For compatibility with older +Matplotlib releases, one can also check +``hasattr(ax, "get_gridspec") and ax.get_gridspec() is not None``. + +``HostAxesBase.get_aux_axes`` now defaults to using the same base axes class as the host axes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If using an ``mpl_toolkits.axisartist``-based host Axes, the parasite Axes will +also be based on ``mpl_toolkits.axisartist``. This behavior is consistent with +``HostAxesBase.twin``, ``HostAxesBase.twinx``, and ``HostAxesBase.twiny``. + +``plt.get_cmap`` and ``matplotlib.cm.get_cmap`` return a copy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Formerly, `~.pyplot.get_cmap` and `.cm.get_cmap` returned a global version of a +`.Colormap`. This was prone to errors as modification of the colormap would +propagate from one location to another without warning. Now, a new copy of the +colormap is returned. + +``TrapezoidMapTriFinder`` uses different random number generator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The random number generator used to determine the order of insertion of +triangle edges in ``TrapezoidMapTriFinder`` has changed. This can result in a +different triangle index being returned for a point that lies exactly on an +edge between two triangles. This can also affect triangulation interpolation +and refinement algorithms that use ``TrapezoidMapTriFinder``. + +``FuncAnimation(save_count=None)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Passing ``save_count=None`` to `.FuncAnimation` no longer limits the number +of frames to 100. Make sure that it either can be inferred from *frames* +or provide an integer *save_count*. + +``CenteredNorm`` halfrange is not modified when vcenter changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, the **halfrange** would expand in proportion to the +amount that **vcenter** was moved away from either **vmin** or **vmax**. +Now, the halfrange remains fixed when vcenter is changed, and **vmin** and +**vmax** are updated based on the **vcenter** and **halfrange** values. + +For example, this is what the values were when changing vcenter previously. + +.. code-block:: + + norm = CenteredNorm(vcenter=0, halfrange=1) + # Move vcenter up by one + norm.vcenter = 1 + # updates halfrange and vmax (vmin stays the same) + # norm.halfrange == 2, vmin == -1, vmax == 3 + +and now, with that same example + +.. code-block:: + + norm = CenteredNorm(vcenter=0, halfrange=1) + norm.vcenter = 1 + # updates vmin and vmax (halfrange stays the same) + # norm.halfrange == 1, vmin == 0, vmax == 2 + +The **halfrange** can be set manually or ``norm.autoscale()`` +can be used to automatically set the limits after setting **vcenter**. + +``fig.subplot_mosaic`` no longer passes the ``gridspec_kw`` args to nested gridspecs. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For nested `.Figure.subplot_mosaic` layouts, it is almost always +inappropriate for *gridspec_kw* arguments to be passed to lower nest +levels, and these arguments are incompatible with the lower levels in +many cases. This dictionary is no longer passed to the inner +layouts. Users who need to modify *gridspec_kw* at multiple levels +should use `.Figure.subfigures` to get nesting, and construct the +inner layouts with `.Figure.subplots` or `.Figure.subplot_mosaic`. + +``HPacker`` alignment with **bottom** or **top** are now correct +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, the **bottom** and **top** alignments were swapped. +This has been corrected so that the alignments correspond appropriately. + +On Windows only fonts known to the registry will be discovered +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, Matplotlib would recursively walk user and system font directories +to discover fonts, however this lead to a number of undesirable behaviors +including finding deleted fonts. Now Matplotlib will only find fonts that are +known to the Windows registry. + +This means that any user installed fonts must go through the Windows font +installer rather than simply being copied to the correct folder. + +This only impacts the set of fonts Matplotlib will consider when using +`matplotlib.font_manager.findfont`. To use an arbitrary font, directly pass the +path to a font as shown in +:doc:`/gallery/text_labels_and_annotations/font_file`. + +``QuadMesh.set_array`` now always raises ``ValueError`` for inputs with incorrect shapes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It could previously also raise `TypeError` in some cases. + +``contour`` and ``contourf`` auto-select suitable levels when given boolean inputs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the height array given to `.Axes.contour` or `.Axes.contourf` is of bool +dtype and *levels* is not specified, *levels* now defaults to ``[0.5]`` for +`~.Axes.contour` and ``[0, 0.5, 1]`` for `.Axes.contourf`. + +``contour`` no longer warns if no contour lines are drawn. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This can occur if the user explicitly passes a ``levels`` array with no values + +``AxesImage.set_extent`` now raises ``TypeError`` for unknown keyword arguments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It previously raised a `ValueError`. + +etween ``z.min()`` and ``z.max()``; or if ``z`` has the same value everywhere. + +Change of ``legend(loc="best")`` behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The algorithm of the auto-legend locator has been tweaked to better handle +non rectangular patches. Additional details on this change can be found in +:ghissue:`9580` and :ghissue:`9598`. diff --git a/doc/api/prev_api_changes/api_changes_3.7.0/deprecations.rst b/doc/api/prev_api_changes/api_changes_3.7.0/deprecations.rst new file mode 100644 index 000000000000..dd6d9d8e0894 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.7.0/deprecations.rst @@ -0,0 +1,291 @@ +Deprecations +------------ + +``Axes`` subclasses should override ``clear`` instead of ``cla`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For clarity, `.axes.Axes.clear` is now preferred over `.Axes.cla`. However, for +backwards compatibility, the latter will remain as an alias for the former. + +For additional compatibility with third-party libraries, Matplotlib will +continue to call the ``cla`` method of any `~.axes.Axes` subclasses if they +define it. In the future, this will no longer occur, and Matplotlib will only +call the ``clear`` method in `~.axes.Axes` subclasses. + +It is recommended to define only the ``clear`` method when on Matplotlib 3.6, +and only ``cla`` for older versions. + +rcParams type +~~~~~~~~~~~~~ + +Relying on ``rcParams`` being a ``dict`` subclass is deprecated. + +Nothing will change for regular users because ``rcParams`` will continue to +be dict-like (technically fulfill the ``MutableMapping`` interface). + +The `.RcParams` class does validation checking on calls to +``.RcParams.__getitem__`` and ``.RcParams.__setitem__``. However, there are rare +cases where we want to circumvent the validation logic and directly access the +underlying data values. Previously, this could be accomplished via a call to +the parent methods ``dict.__getitem__(rcParams, key)`` and +``dict.__setitem__(rcParams, key, val)``. + +Matplotlib 3.7 introduces ``rcParams._set(key, val)`` and +``rcParams._get(key)`` as a replacement to calling the parent methods. They are +intentionally marked private to discourage external use; However, if direct +`.RcParams` data access is needed, please switch from the dict functions to the +new ``_get()`` and ``_set()``. Even though marked private, we guarantee API +stability for these methods and they are subject to Matplotlib's API and +deprecation policy. + +Please notify the Matplotlib developers if you rely on ``rcParams`` being a +dict subclass in any other way, for which there is no migration path yet. + +Deprecation aliases in cbook +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The module ``matplotlib.cbook.deprecation`` was previously deprecated in +Matplotlib 3.4, along with deprecation-related API in ``matplotlib.cbook``. Due +to technical issues, ``matplotlib.cbook.MatplotlibDeprecationWarning`` and +``matplotlib.cbook.mplDeprecation`` did not raise deprecation warnings on use. +Changes in Python have now made it possible to warn when these aliases are +being used. + +In order to avoid downstream breakage, these aliases will now warn, and their +removal has been pushed from 3.6 to 3.8 to give time to notice said warnings. +As replacement, please use `matplotlib.MatplotlibDeprecationWarning`. + +``draw_gouraud_triangle`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated as in most backends this is a redundant call. Use +`~.RendererBase.draw_gouraud_triangles` instead. A ``draw_gouraud_triangle`` +call in a custom `~matplotlib.artist.Artist` can readily be replaced as:: + + self.draw_gouraud_triangles(gc, points.reshape((1, 3, 2)), + colors.reshape((1, 3, 4)), trans) + +A `~.RendererBase.draw_gouraud_triangles` method can be implemented from an +existing ``draw_gouraud_triangle`` method as:: + + transform = transform.frozen() + for tri, col in zip(triangles_array, colors_array): + self.draw_gouraud_triangle(gc, tri, col, transform) + +``matplotlib.pyplot.get_plot_commands`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is a pending deprecation. This is considered internal and no end-user +should need it. + +``matplotlib.tri`` submodules are deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``matplotlib.tri.*`` submodules are deprecated. All functionality is +available in ``matplotlib.tri`` directly and should be imported from there. + +Passing undefined *label_mode* to ``Grid`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. This includes `mpl_toolkits.axes_grid1.axes_grid.Grid`, +`mpl_toolkits.axes_grid1.axes_grid.AxesGrid`, and +`mpl_toolkits.axes_grid1.axes_grid.ImageGrid` as well as the corresponding +classes imported from `mpl_toolkits.axisartist.axes_grid`. + +Pass ``label_mode='keep'`` instead to get the previous behavior of not modifying labels. + +Colorbars for orphaned mappables are deprecated, but no longer raise +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before 3.6.0, Colorbars for mappables that do not have a parent axes would +steal space from the current Axes. 3.6.0 raised an error on this, but without +a deprecation cycle. For 3.6.1 this is reverted, the current axes is used, +but a deprecation warning is shown instead. In this undetermined case users +and libraries should explicitly specify what axes they want space to be stolen +from: ``fig.colorbar(mappable, ax=plt.gca())``. + +``Animation`` attributes +~~~~~~~~~~~~~~~~~~~~~~~~ + +The attributes ``repeat`` of `.TimedAnimation` and subclasses and +``save_count`` of `.FuncAnimation` are considered private and deprecated. + +``contour.ClabelText`` and ``ContourLabeler.set_label_props`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... are deprecated. + +Use ``Text(..., transform_rotates_text=True)`` as a replacement for +``contour.ClabelText(...)`` and ``text.set(text=text, color=color, +fontproperties=labeler.labelFontProps, clip_box=labeler.axes.bbox)`` as a +replacement for the ``ContourLabeler.set_label_props(label, text, color)``. + +``ContourLabeler`` attributes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``labelFontProps``, ``labelFontSizeList``, and ``labelTextsList`` +attributes of `.ContourLabeler` have been deprecated. Use the ``labelTexts`` +attribute and the font properties of the corresponding text objects instead. + +``backend_ps.PsBackendHelper`` and ``backend_ps.ps_backend_helper`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... are deprecated with no replacement. + +``backend_webagg.ServerThread`` is deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... with no replacement. + +``parse_fontconfig_pattern`` will no longer ignore unknown constant names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, in a fontconfig pattern like ``DejaVu Sans:foo``, the unknown +``foo`` constant name would be silently ignored. This now raises a warning, +and will become an error in the future. + +``BufferRegion.to_string`` and ``BufferRegion.to_string_argb`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... are deprecated. Use ``np.asarray(buffer_region)`` to get an array view on +a buffer region without making a copy; to convert that view from RGBA (the +default) to ARGB, use ``np.take(..., [2, 1, 0, 3], axis=2)``. + +``num2julian``, ``julian2num`` and ``JULIAN_OFFSET`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... of the `.dates` module are deprecated without replacements. These are +undocumented and not exported. If you rely on these, please make a local copy. + +``unit_cube``, ``tunit_cube``, and ``tunit_edges`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... of `.Axes3D` are deprecated without replacements. If you rely on them, +please copy the code of the corresponding private function (name starting +with ``_``). + +Most arguments to widgets have been made keyword-only +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Passing all but the very few first arguments positionally in the constructors +of Widgets is deprecated. Most arguments will become keyword-only in a future +version. + +``SimpleEvent`` +~~~~~~~~~~~~~~~ + +The ``SimpleEvent`` nested class (previously accessible via the public +subclasses of ``ConnectionStyle._Base``, such as `.ConnectionStyle.Arc`, has +been deprecated. + +``RadioButtons.circles`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. (RadioButtons now draws itself using `~.Axes.scatter`.) + +``CheckButtons.rectangles`` and ``CheckButtons.lines`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``CheckButtons.rectangles`` and ``CheckButtons.lines`` are deprecated. +(``CheckButtons`` now draws itself using `~.Axes.scatter`.) + +``OffsetBox.get_extent_offsets`` and ``OffsetBox.get_extent`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... are deprecated; these methods are also deprecated on all subclasses of +`.OffsetBox`. + +To get the offsetbox extents, instead of ``get_extent``, use +`.OffsetBox.get_bbox`, which directly returns a `.Bbox` instance. + +To also get the child offsets, instead of ``get_extent_offsets``, separately +call `~.OffsetBox.get_offset` on each children after triggering a draw. + +``legend.legendHandles`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +... was undocumented and has been renamed to ``legend_handles``. Using ``legendHandles`` is deprecated. + +``ticklabels`` parameter of `.Axis.set_ticklabels` renamed to ``labels`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``offsetbox.bbox_artist`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. This is just a wrapper to call `.patches.bbox_artist` if a +flag is set in the file, so use that directly if you need the behavior. + +``Quiver.quiver_doc`` and ``Barbs.barbs_doc`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... are deprecated. These are the doc-string and should not be accessible as +a named class member. + +Deprecate unused parameter *x* to ``TextBox.begin_typing`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This parameter was unused in the method, but was a required argument. + +Deprecation of top-level cmap registration and access functions in ``mpl.cm`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As part of a `multi-step process +`_ we are refactoring +the global state for managing the registered colormaps. + +In Matplotlib 3.5 we added a `.ColormapRegistry` class and exposed an instance +at the top level as ``matplotlib.colormaps``. The existing top level functions +in `matplotlib.cm` (``get_cmap``, ``register_cmap``, ``unregister_cmap``) were +changed to be aliases around the same instance. In Matplotlib 3.6 we have +marked those top level functions as pending deprecation. + +In Matplotlib 3.7, the following functions have been marked for deprecation: + +- ``matplotlib.cm.get_cmap``; use ``matplotlib.colormaps[name]`` instead if you + have a `str`. + + **Added 3.6.1** Use `matplotlib.cm.ColormapRegistry.get_cmap` if you + have a string, `None` or a `matplotlib.colors.Colormap` object that you want + to convert to a `matplotlib.colors.Colormap` instance. +- ``matplotlib.cm.register_cmap``; use `matplotlib.colormaps.register + <.ColormapRegistry.register>` instead +- ``matplotlib.cm.unregister_cmap``; use `matplotlib.colormaps.unregister + <.ColormapRegistry.unregister>` instead +- ``matplotlib.pyplot.register_cmap``; use `matplotlib.colormaps.register + <.ColormapRegistry.register>` instead + +The `matplotlib.pyplot.get_cmap` function will stay available for backward +compatibility. + +``BrokenBarHCollection`` is deprecated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It was just a thin wrapper inheriting from `.PolyCollection`; +`~.Axes.broken_barh` has now been changed to return a `.PolyCollection` +instead. + +The ``BrokenBarHCollection.span_where`` helper is likewise deprecated; for the +duration of the deprecation it has been moved to the parent `.PolyCollection` +class. Use `~.Axes.fill_between` as a replacement; see +:doc:`/gallery/lines_bars_and_markers/span_regions` for an example. + +Passing inconsistent ``loc`` and ``nth_coord`` to axisartist helpers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Trying to construct for example a "top y-axis" or a "left x-axis" is now +deprecated. + +``passthru_pt`` +~~~~~~~~~~~~~~~ + +This attribute of ``AxisArtistHelper``\s is deprecated. + +``axes3d.vvec``, ``axes3d.eye``, ``axes3d.sx``, and ``axes3d.sy`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... are deprecated without replacement. + +``Line2D`` +~~~~~~~~~~ + +When creating a Line2D or using `.Line2D.set_xdata` and `.Line2D.set_ydata`, +passing x/y data as non sequence is deprecated. diff --git a/doc/api/prev_api_changes/api_changes_3.7.0/development.rst b/doc/api/prev_api_changes/api_changes_3.7.0/development.rst new file mode 100644 index 000000000000..c2ae35970524 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.7.0/development.rst @@ -0,0 +1,49 @@ +Development changes +------------------- + + +Windows wheel runtime bundling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Wheels built for Windows now bundle the MSVC runtime DLL ``msvcp140.dll``. This +enables importing Matplotlib on systems that do not have the runtime installed. + + +Increase to minimum supported versions of dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +For Matplotlib 3.7, the :ref:`minimum supported versions ` are +being bumped: + ++------------+-----------------+---------------+ +| Dependency | min in mpl3.6 | min in mpl3.7 | ++============+=================+===============+ +| NumPy | 1.19 | 1.20 | ++------------+-----------------+---------------+ +| pyparsing | 2.2.1 | 2.3.1 | ++------------+-----------------+---------------+ +| Qt | | 5.10 | ++------------+-----------------+---------------+ + +- There are no wheels or conda packages that support both Qt 5.9 (or older) and + Python 3.8 (or newer). + +This is consistent with our :ref:`min_deps_policy` and `NEP29 +`__ + + +New dependencies +~~~~~~~~~~~~~~~~ + +* `importlib-resources `_ + (>= 3.2.0; only required on Python < 3.10) + +Maximum line length increased to 88 characters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The maximum line length for new contributions has been extended from 79 characters to +88 characters. +This change provides an extra 9 characters to allow code which is a single idea to fit +on fewer lines (often a single line). +The chosen length is the same as `black `_. diff --git a/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst b/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst new file mode 100644 index 000000000000..c8f499666525 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.7.0/removals.rst @@ -0,0 +1,369 @@ +Removals +-------- + +``epoch2num`` and ``num2epoch`` are removed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These methods convert from unix timestamps to matplotlib floats, but are not +used internally to Matplotlib, and should not be needed by end users. To +convert a unix timestamp to datetime, simply use +`datetime.datetime.utcfromtimestamp`, or to use NumPy `~numpy.datetime64` +``dt = np.datetime64(e*1e6, 'us')``. + +Locator and Formatter wrapper methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``set_view_interval``, ``set_data_interval`` and ``set_bounds`` methods of +`.Locator`\s and `.Formatter`\s (and their common base class, TickHelper) are +removed. Directly manipulate the view and data intervals on the underlying +axis instead. + +Interactive cursor details +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Setting a mouse cursor on a window has been moved from the toolbar to the +canvas. Consequently, several implementation details on toolbars and within +backends have been removed. + +``NavigationToolbar2.set_cursor`` and ``backend_tools.SetCursorBase.set_cursor`` +................................................................................ + +Instead, use the `.FigureCanvasBase.set_cursor` method on the canvas (available +as the ``canvas`` attribute on the toolbar or the Figure.) + +``backend_tools.SetCursorBase`` and subclasses +.............................................. + +``backend_tools.SetCursorBase`` was subclassed to provide backend-specific +implementations of ``set_cursor``. As that is now removed, the subclassing +is no longer necessary. Consequently, the following subclasses are also +removed: + +- ``matplotlib.backends.backend_gtk3.SetCursorGTK3`` +- ``matplotlib.backends.backend_qt5.SetCursorQt`` +- ``matplotlib.backends._backend_tk.SetCursorTk`` +- ``matplotlib.backends.backend_wx.SetCursorWx`` + +Instead, use the `.backend_tools.ToolSetCursor` class. + +``cursord`` in GTK and wx backends +.................................. + +The ``backend_gtk3.cursord`` and ``backend_wx.cursord`` dictionaries are +removed. This makes the GTK module importable on headless environments. + +``auto_add_to_figure=True`` for ``Axes3D`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is no longer supported. Instead use ``fig.add_axes(ax)``. + +The first parameter of ``Axes.grid`` and ``Axis.grid`` has been renamed to *visible* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The parameter was previously named *b*. This name change only matters if that +parameter was passed using a keyword argument, e.g. ``grid(b=False)``. + +Removal of deprecations in the Selector widget API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +RectangleSelector and EllipseSelector +..................................... + +The *drawtype* keyword argument to `~matplotlib.widgets.RectangleSelector` is +removed. From now on, the only behaviour will be ``drawtype='box'``. + +Support for ``drawtype=line`` is removed altogether. As a +result, the *lineprops* keyword argument to +`~matplotlib.widgets.RectangleSelector` is also removed. + +To retain the behaviour of ``drawtype='none'``, use ``rectprops={'visible': +False}`` to make the drawn `~matplotlib.patches.Rectangle` invisible. + +Cleaned up attributes and arguments are: + +- The ``active_handle`` attribute has been privatized and removed. +- The ``drawtype`` attribute has been privatized and removed. +- The ``eventpress`` attribute has been privatized and removed. +- The ``eventrelease`` attribute has been privatized and removed. +- The ``interactive`` attribute has been privatized and removed. +- The *marker_props* argument is removed, use *handle_props* instead. +- The *maxdist* argument is removed, use *grab_range* instead. +- The *rectprops* argument is removed, use *props* instead. +- The ``rectprops`` attribute has been privatized and removed. +- The ``state`` attribute has been privatized and removed. +- The ``to_draw`` attribute has been privatized and removed. + +PolygonSelector +............... + +- The *line* attribute is removed. If you want to change the selector artist + properties, use the ``set_props`` or ``set_handle_props`` methods. +- The *lineprops* argument is removed, use *props* instead. +- The *markerprops* argument is removed, use *handle_props* instead. +- The *maxdist* argument and attribute is removed, use *grab_range* instead. +- The *vertex_select_radius* argument and attribute is removed, use + *grab_range* instead. + +SpanSelector +............ + +- The ``active_handle`` attribute has been privatized and removed. +- The ``eventpress`` attribute has been privatized and removed. +- The ``eventrelease`` attribute has been privatized and removed. +- The ``pressv`` attribute has been privatized and removed. +- The ``prev`` attribute has been privatized and removed. +- The ``rect`` attribute has been privatized and removed. +- The *rectprops* parameter has been renamed to *props*. +- The ``rectprops`` attribute has been privatized and removed. +- The *span_stays* parameter has been renamed to *interactive*. +- The ``span_stays`` attribute has been privatized and removed. +- The ``state`` attribute has been privatized and removed. + +LassoSelector +............. + +- The *lineprops* argument is removed, use *props* instead. +- The ``onpress`` and ``onrelease`` methods are removed. They are straight + aliases for ``press`` and ``release``. +- The ``matplotlib.widgets.TextBox.DIST_FROM_LEFT`` attribute has been + removed. It was marked as private in 3.5. + +``backend_template.show`` +~~~~~~~~~~~~~~~~~~~~~~~~~ +... has been removed, in order to better demonstrate the new backend definition +API. + +Unused positional parameters to ``print_`` methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +None of the ``print_`` methods implemented by canvas subclasses used +positional arguments other that the first (the output filename or file-like), +so these extra parameters are removed. + +``QuadMesh`` signature +~~~~~~~~~~~~~~~~~~~~~~ + +The `.QuadMesh` signature :: + + def __init__(meshWidth, meshHeight, coordinates, + antialiased=True, shading='flat', **kwargs) + +is removed and replaced by the new signature :: + + def __init__(coordinates, *, antialiased=True, shading='flat', **kwargs) + +In particular: + +- The *coordinates* argument must now be a (M, N, 2) array-like. Previously, + the grid shape was separately specified as (*meshHeight* + 1, *meshWidth* + + 1) and *coordinates* could be an array-like of any shape with M * N * 2 + elements. +- All parameters except *coordinates* are keyword-only now. + +Expiration of ``FancyBboxPatch`` deprecations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The `.FancyBboxPatch` constructor no longer accepts the *bbox_transmuter* +parameter, nor can the *boxstyle* parameter be set to "custom" -- instead, +directly set *boxstyle* to the relevant boxstyle instance. The +*mutation_scale* and *mutation_aspect* parameters have also become +keyword-only. + +The *mutation_aspect* parameter is now handled internally and no longer passed +to the boxstyle callables when mutating the patch path. + +Testing support +~~~~~~~~~~~~~~~ + +``matplotlib.test()`` has been removed +...................................... + +Run tests using ``pytest`` from the commandline instead. The variable +``matplotlib.default_test_modules`` was only used for ``matplotlib.test()`` and +is thus removed as well. + +To test an installed copy, be sure to specify both ``matplotlib`` and +``mpl_toolkits`` with ``--pyargs``:: + + python -m pytest --pyargs matplotlib.tests mpl_toolkits.tests + +See :ref:`testing` for more details. + +Auto-removal of grids by `~.Axes.pcolor` and `~.Axes.pcolormesh` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`~.Axes.pcolor` and `~.Axes.pcolormesh` previously remove any visible axes +major grid. This behavior is removed; please explicitly call ``ax.grid(False)`` +to remove the grid. + +Modification of ``Axes`` children sublists +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +See :ref:`Behavioural API Changes 3.5 - Axes children combined` for more +information; modification of the following sublists is no longer supported: + +* ``Axes.artists`` +* ``Axes.collections`` +* ``Axes.images`` +* ``Axes.lines`` +* ``Axes.patches`` +* ``Axes.tables`` +* ``Axes.texts`` + +To remove an Artist, use its `.Artist.remove` method. To add an Artist, use the +corresponding ``Axes.add_*`` method. + +Passing incorrect types to ``Axes.add_*`` methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following ``Axes.add_*`` methods will now raise if passed an unexpected +type. See their documentation for the types they expect. + +- `.Axes.add_collection` +- `.Axes.add_image` +- `.Axes.add_line` +- `.Axes.add_patch` +- `.Axes.add_table` + + +``ConversionInterface.convert`` no longer accepts unitless values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, custom subclasses of `.units.ConversionInterface` needed to +implement a ``convert`` method that not only accepted instances of the unit, +but also unitless values (which are passed through as is). This is no longer +the case (``convert`` is never called with a unitless value), and such support +in ``.StrCategoryConverter`` is removed. Likewise, the +``.ConversionInterface.is_numlike`` helper is removed. + +Consider calling `.Axis.convert_units` instead, which still supports unitless +values. + + +Normal list of `.Artist` objects now returned by `.HandlerLine2D.create_artists` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For Matplotlib 3.5 and 3.6 a proxy list was returned that simulated the return +of `.HandlerLine2DCompound.create_artists`. Now a list containing only the +single artist is return. + + +rcParams will no longer cast inputs to str +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +rcParams that expect a (non-pathlike) str no longer cast non-str inputs using +`str`. This will avoid confusing errors in subsequent code if e.g. a list input +gets implicitly cast to a str. + +Case-insensitive scales +~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, scales could be set case-insensitively (e.g., +``set_xscale("LoG")``). Now all builtin scales use lowercase names. + +Support for ``nx1 = None`` or ``ny1 = None`` in ``AxesLocator`` and ``Divider.locate`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In `.axes_grid1.axes_divider`, various internal APIs no longer supports +passing ``nx1 = None`` or ``ny1 = None`` to mean ``nx + 1`` or ``ny + 1``, in +preparation for a possible future API which allows indexing and slicing of +dividers (possibly ``divider[a:b] == divider.new_locator(a, b)``, but also +``divider[a:] == divider.new_locator(a, )``). The user-facing +`.Divider.new_locator` API is unaffected -- it correctly normalizes ``nx1 = +None`` and ``ny1 = None`` as needed. + + +change signature of ``.FigureCanvasBase.enter_notify_event`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The *xy* parameter is now required and keyword only. This was deprecated in +3.0 and originally slated to be removed in 3.5. + +``Colorbar`` tick update parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The *update_ticks* parameter of `.Colorbar.set_ticks` and +`.Colorbar.set_ticklabels` was ignored since 3.5 and has been removed. + +plot directive removals +~~~~~~~~~~~~~~~~~~~~~~~ + +The public methods: + +- ``matplotlib.sphinxext.split_code_at_show`` +- ``matplotlib.sphinxext.unescape_doctest`` +- ``matplotlib.sphinxext.run_code`` + +have been removed. + +The deprecated *encoding* option to the plot directive has been removed. + +Miscellaneous removals +~~~~~~~~~~~~~~~~~~~~~~ + +- ``is_url`` and ``URL_REGEX`` are removed. (They were previously defined in + the toplevel :mod:`matplotlib` module.) +- The ``ArrowStyle.beginarrow`` and ``ArrowStyle.endarrow`` attributes are + removed; use the ``arrow`` attribute to define the desired heads and tails + of the arrow. +- ``backend_pgf.LatexManager.str_cache`` is removed. +- ``backends.qt_compat.ETS`` and ``backends.qt_compat.QT_RC_MAJOR_VERSION`` are + removed, with no replacement. +- The ``blocking_input`` module is removed. Instead, use + ``canvas.start_event_loop()`` and ``canvas.stop_event_loop()`` while + connecting event callbacks as needed. +- ``cbook.report_memory`` is removed; use ``psutil.virtual_memory`` instead. +- ``cm.LUTSIZE`` is removed. Use :rc:`image.lut` instead. This value only + affects colormap quantization levels for default colormaps generated at + module import time. +- ``Colorbar.patch`` is removed; this attribute was not correctly updated + anymore. +- ``ContourLabeler.get_label_width`` is removed. +- ``Dvi.baseline`` is removed (with no replacement). +- The *format* parameter of ``dviread.find_tex_file`` is removed (with no + replacement). +- ``FancyArrowPatch.get_path_in_displaycoord`` and + ``ConnectionPath.get_path_in_displaycoord`` are removed. The path in + display coordinates can still be obtained, as for other patches, using + ``patch.get_transform().transform_path(patch.get_path())``. +- The ``font_manager.win32InstalledFonts`` and + ``font_manager.get_fontconfig_fonts`` helper functions are removed. +- All parameters of ``imshow`` starting from *aspect* are keyword-only. +- ``QuadMesh.convert_mesh_to_paths`` and ``QuadMesh.convert_mesh_to_triangles`` + are removed. ``QuadMesh.get_paths()`` can be used as an alternative for the + former; there is no replacement for the latter. +- ``ScalarMappable.callbacksSM`` is removed. Use + ``ScalarMappable.callbacks`` instead. +- ``streamplot.get_integrator`` is removed. +- ``style.core.STYLE_FILE_PATTERN``, ``style.core.load_base_library``, and + ``style.core.iter_user_libraries`` are removed. +- ``SubplotParams.validate`` is removed. Use `.SubplotParams.update` to + change `.SubplotParams` while always keeping it in a valid state. +- The ``grey_arrayd``, ``font_family``, ``font_families``, and ``font_info`` + attributes of `.TexManager` are removed. +- ``Text.get_prop_tup`` is removed with no replacements (because the `.Text` + class cannot know whether a backend needs to update cache e.g. when the + text's color changes). +- ``Tick.apply_tickdir`` didn't actually update the tick markers on the + existing Line2D objects used to draw the ticks and is removed; use + `.Axis.set_tick_params` instead. +- ``tight_layout.auto_adjust_subplotpars`` is removed. +- The ``grid_info`` attribute of ``axisartist`` classes has been removed. +- ``axes_grid1.axes_grid.CbarAxes`` and ``axisartist.axes_grid.CbarAxes`` are + removed (they are now dynamically generated based on the owning axes + class). +- The ``axes_grid1.Divider.get_vsize_hsize`` and + ``axes_grid1.Grid.get_vsize_hsize`` methods are removed. +- ``AxesDivider.append_axes(..., add_to_figure=False)`` is removed. Use + ``ax.remove()`` to remove the Axes from the figure if needed. +- ``FixedAxisArtistHelper.change_tick_coord`` is removed with no + replacement. +- ``floating_axes.GridHelperCurveLinear.get_boundary`` is removed with no + replacement. +- ``ParasiteAxesBase.get_images_artists`` is removed. +- The "units finalize" signal (previously emitted by Axis instances) is + removed. Connect to "units" instead. +- Passing formatting parameters positionally to ``stem()`` is no longer + possible. +- ``axisartist.clip_path`` is removed with no replacement. + diff --git a/doc/api/projections/geo.rst b/doc/api/projections/geo.rst new file mode 100644 index 000000000000..beaa7ec343f3 --- /dev/null +++ b/doc/api/projections/geo.rst @@ -0,0 +1,7 @@ +****************************** +``matplotlib.projections.geo`` +****************************** + +.. automodule:: matplotlib.projections.geo + :members: + :show-inheritance: diff --git a/doc/api/projections/polar.rst b/doc/api/projections/polar.rst new file mode 100644 index 000000000000..3491fd92d16e --- /dev/null +++ b/doc/api/projections/polar.rst @@ -0,0 +1,7 @@ +******************************** +``matplotlib.projections.polar`` +******************************** + +.. automodule:: matplotlib.projections.polar + :members: + :show-inheritance: diff --git a/doc/api/projections_api.rst b/doc/api/projections_api.rst index ff12a2be8623..f0c742c241e7 100644 --- a/doc/api/projections_api.rst +++ b/doc/api/projections_api.rst @@ -6,16 +6,13 @@ :members: :show-inheritance: -``matplotlib.projections.polar`` -================================ +Built-in projections +==================== +Matplotlib has built-in support for polar and some geographic projections. +See the following pages for more information: -.. automodule:: matplotlib.projections.polar - :members: - :show-inheritance: - -``matplotlib.projections.geo`` -============================== +.. toctree:: + :maxdepth: 1 -.. automodule:: matplotlib.projections.geo - :members: - :show-inheritance: + projections/polar + projections/geo diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index 8d18c8b67e3e..616e9c257aa5 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -2,32 +2,337 @@ ``matplotlib.pyplot`` ********************* -Pyplot function overview +.. currentmodule:: matplotlib.pyplot + +.. automodule:: matplotlib.pyplot + :no-members: + :no-undoc-members: + + +Managing Figure and Axes ------------------------ -.. currentmodule:: matplotlib +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + axes + cla + clf + close + delaxes + fignum_exists + figure + gca + gcf + get_figlabels + get_fignums + sca + subplot + subplot2grid + subplot_mosaic + subplots + twinx + twiny + + +Adding data to the plot +----------------------- + +Basic +^^^^^ .. autosummary:: :toctree: _as_gen - :template: autofunctions.rst + :template: autosummary.rst + :nosignatures: - pyplot + plot + errorbar + scatter + plot_date + step + loglog + semilogx + semilogy + fill_between + fill_betweenx + bar + barh + bar_label + stem + eventplot + pie + stackplot + broken_barh + vlines + hlines + fill + polar -.. currentmodule:: matplotlib.pyplot -.. autofunction:: plotting +Spans +^^^^^ +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: -Colors in Matplotlib --------------------- + axhline + axhspan + axvline + axvspan + axline -There are many colormaps you can use to map data onto color values. -Below we list several ways in which color can be utilized in Matplotlib. -For a more in-depth look at colormaps, see the -:doc:`/tutorials/colors/colormaps` tutorial. +Spectral +^^^^^^^^ -.. currentmodule:: matplotlib.pyplot +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + acorr + angle_spectrum + cohere + csd + magnitude_spectrum + phase_spectrum + psd + specgram + xcorr + + +Statistics +^^^^^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + boxplot + violinplot + + +Binned +^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + hexbin + hist + hist2d + stairs + + +Contours +^^^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + clabel + contour + contourf + + +2D arrays +^^^^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + imshow + matshow + pcolor + pcolormesh + spy + figimage + + +Unstructured triangles +^^^^^^^^^^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + triplot + tripcolor + tricontour + tricontourf + + +Text and annotations +^^^^^^^^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + annotate + text + figtext + table + arrow + figlegend + legend + + +Vector fields +^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + barbs + quiver + quiverkey + streamplot + + +Axis configuration +------------------ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + autoscale + axis + box + grid + locator_params + minorticks_off + minorticks_on + rgrids + thetagrids + tick_params + ticklabel_format + xlabel + xlim + xscale + xticks + ylabel + ylim + yscale + yticks + suptitle + title + + +Layout +------ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + margins + subplots_adjust + subplot_tool + tight_layout + + +Colormapping +------------ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + clim + colorbar + gci + sci + get_cmap + set_cmap + imread + imsave + +Colormaps are available via the colormap registry `matplotlib.colormaps`. For +convenience this registry is available in ``pyplot`` as .. autodata:: colormaps :no-value: + +Additionally, there are shortcut functions to set builtin colormaps; e.g. +``plt.viridis()`` is equivalent to ``plt.set_cmap('viridis')``. + + +.. autodata:: color_sequences + :no-value: + + +Configuration +------------- + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + rc + rc_context + rcdefaults + + +Output +------ + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + draw + draw_if_interactive + ioff + ion + install_repl_displayhook + isinteractive + pause + savefig + show + switch_backend + uninstall_repl_displayhook + + +Other +----- + +.. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + connect + disconnect + findobj + get + getp + get_current_fig_manager + ginput + new_figure_manager + set_loglevel + setp + waitforbuttonpress + xkcd diff --git a/doc/api/testing_api.rst b/doc/api/testing_api.rst index 808d2b870109..7731d4510b27 100644 --- a/doc/api/testing_api.rst +++ b/doc/api/testing_api.rst @@ -3,11 +3,6 @@ ********************** -:func:`matplotlib.test` -======================= - -.. autofunction:: matplotlib.test - :mod:`matplotlib.testing` ========================= diff --git a/doc/api/text_api.rst b/doc/api/text_api.rst index 8bed3173ebdb..af37e5c526a3 100644 --- a/doc/api/text_api.rst +++ b/doc/api/text_api.rst @@ -2,6 +2,8 @@ ``matplotlib.text`` ******************* +.. redirect-from:: /api/textpath_api + .. automodule:: matplotlib.text :no-members: @@ -19,3 +21,13 @@ :members: :undoc-members: :show-inheritance: + +.. autoclass:: matplotlib.text.TextPath + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: matplotlib.text.TextToPath + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/textpath_api.rst b/doc/api/textpath_api.rst deleted file mode 100644 index 875e4b376867..000000000000 --- a/doc/api/textpath_api.rst +++ /dev/null @@ -1,8 +0,0 @@ -*********************** -``matplotlib.textpath`` -*********************** - -.. automodule:: matplotlib.textpath - :members: - :undoc-members: - :show-inheritance: diff --git a/doc/api/tight_bbox_api.rst b/doc/api/tight_bbox_api.rst index 3a96b5b6d027..9e8dd2fa66f9 100644 --- a/doc/api/tight_bbox_api.rst +++ b/doc/api/tight_bbox_api.rst @@ -2,7 +2,12 @@ ``matplotlib.tight_bbox`` ************************* -.. automodule:: matplotlib.tight_bbox +.. attention:: + This module is considered internal. + + Its use is deprecated and it will be removed in a future version. + +.. automodule:: matplotlib._tight_bbox :members: :undoc-members: :show-inheritance: diff --git a/doc/api/tight_layout_api.rst b/doc/api/tight_layout_api.rst index 1f1a32281aa0..35f92e3ddced 100644 --- a/doc/api/tight_layout_api.rst +++ b/doc/api/tight_layout_api.rst @@ -2,7 +2,12 @@ ``matplotlib.tight_layout`` *************************** -.. automodule:: matplotlib.tight_layout +.. attention:: + This module is considered internal. + + Its use is deprecated and it will be removed in a future version. + +.. automodule:: matplotlib._tight_layout :members: :undoc-members: :show-inheritance: diff --git a/doc/api/toolkits/axes_grid.rst b/doc/api/toolkits/axes_grid.rst deleted file mode 100644 index c991ee61ed3c..000000000000 --- a/doc/api/toolkits/axes_grid.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. _axes_grid-api-index: - -``mpl_toolkits.axes_grid`` -========================== - -.. currentmodule:: mpl_toolkits - -.. note:: - AxesGrid has been a part of matplotlib since v 0.99. Originally, the toolkit - used the *axes_grid* namespace. In more recent versions, the toolkit - has been split into *axes_grid1* and *axisartist*. While *axes_grid* - is maintained for the backward compatibility, use of *axes_grid1* and - *axisartist* is recommended. For the documentation on ``axes_grid``, see - the `previous version of the docs`__. - - .. __: https://matplotlib.org/2.0.1/mpl_toolkits/axes_grid/index.html - -.. toctree:: - :maxdepth: 1 - - axes_grid1 - axisartist diff --git a/doc/api/toolkits/axes_grid1.rst b/doc/api/toolkits/axes_grid1.rst index 7dc95026a14d..c48a6a31af90 100644 --- a/doc/api/toolkits/axes_grid1.rst +++ b/doc/api/toolkits/axes_grid1.rst @@ -1,5 +1,7 @@ .. module:: mpl_toolkits.axes_grid1 +.. redirect-from:: /api/toolkits/axes_grid + ``mpl_toolkits.axes_grid1`` =========================== @@ -15,6 +17,13 @@ See :ref:`axes_grid1_users-guide-index` for a guide on the usage of axes_grid1. :align: center :scale: 50 +.. note:: + + This module contains classes and function that were formerly part of the + ``mpl_toolkits.axes_grid`` module that was removed in 3.6. Additional + classes from that older module may also be found in + `mpl_toolkits.axisartist`. + .. currentmodule:: mpl_toolkits **The submodules of the axes_grid1 API are:** diff --git a/doc/api/toolkits/axisartist.rst b/doc/api/toolkits/axisartist.rst index e045e68e54d2..8cac4d68a266 100644 --- a/doc/api/toolkits/axisartist.rst +++ b/doc/api/toolkits/axisartist.rst @@ -17,6 +17,13 @@ You can find a tutorial describing usage of axisartist at the :align: center :scale: 50 +.. note:: + + This module contains classes and function that were formerly part of the + ``mpl_toolkits.axes_grid`` module that was removed in 3.6. Additional + classes from that older module may also be found in + `mpl_toolkits.axes_grid1`. + .. currentmodule:: mpl_toolkits **The submodules of the axisartist API are:** @@ -32,7 +39,6 @@ You can find a tutorial describing usage of axisartist at the axisartist.axis_artist axisartist.axisline_style axisartist.axislines - axisartist.clip_path axisartist.floating_axes axisartist.grid_finder axisartist.grid_helper_curvelinear diff --git a/doc/api/toolkits/mplot3d.rst b/doc/api/toolkits/mplot3d.rst index 5b3cb52571bb..fc6c4cbad6d1 100644 --- a/doc/api/toolkits/mplot3d.rst +++ b/doc/api/toolkits/mplot3d.rst @@ -20,13 +20,16 @@ more information. The interactive backends also provide the ability to rotate and zoom the 3D scene. One can rotate the 3D scene by simply clicking-and-dragging the scene. -Zooming is done by right-clicking the scene and dragging the mouse up and down -(unlike 2D plots, the toolbar zoom button is not used). +Panning is done by clicking the middle mouse button, and zooming is done by +right-clicking the scene and dragging the mouse up and down. Unlike 2D plots, +the toolbar pan and zoom buttons are not used. .. toctree:: :maxdepth: 2 mplot3d/faq.rst + mplot3d/view_angles.rst + mplot3d/axes3d.rst .. note:: `.pyplot` cannot be used to add content to 3D plots, because its function @@ -49,11 +52,8 @@ Zooming is done by right-clicking the scene and dragging the mouse up and down Please report any functions that do not behave as expected as a bug. In addition, help and patches would be greatly appreciated! -.. autosummary:: - :toctree: ../_as_gen - :template: autosummary.rst - axes3d.Axes3D +`axes3d.Axes3D` (fig[, rect, elev, azim, roll, ...]) 3D Axes object. .. module:: mpl_toolkits.mplot3d.axis3d diff --git a/doc/api/toolkits/mplot3d/axes3d.rst b/doc/api/toolkits/mplot3d/axes3d.rst new file mode 100644 index 000000000000..e334fee2fea5 --- /dev/null +++ b/doc/api/toolkits/mplot3d/axes3d.rst @@ -0,0 +1,306 @@ +mpl\_toolkits.mplot3d.axes3d.Axes3D +=================================== + + +.. currentmodule:: mpl_toolkits.mplot3d.axes3d + + +.. autoclass:: Axes3D + :no-members: + :no-undoc-members: + :show-inheritance: + + +.. currentmodule:: mpl_toolkits.mplot3d.axes3d.Axes3D + + +Plotting +-------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + plot + scatter + bar + bar3d + + plot_surface + plot_wireframe + plot_trisurf + + clabel + contour + tricontour + contourf + tricontourf + + quiver + voxels + errorbar + stem + + +Text and annotations +-------------------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + text + text2D + + +Clearing +-------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + clear + + +Appearance +---------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + set_axis_off + set_axis_on + grid + get_frame_on + set_frame_on + + +Axis +---- + +Axis limits and direction +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + get_zaxis + get_xlim + get_ylim + get_zlim + set_zlim + get_w_lims + invert_zaxis + zaxis_inverted + get_zbound + set_zbound + + +Axis labels and title +^^^^^^^^^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + set_zlabel + get_zlabel + set_title + + +Axis scales +^^^^^^^^^^^ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + set_xscale + set_yscale + set_zscale + get_zscale + + +Autoscaling and margins +^^^^^^^^^^^^^^^^^^^^^^^ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + set_zmargin + margins + autoscale + autoscale_view + set_autoscalez_on + get_autoscalez_on + auto_scale_xyz + + +Aspect ratio +^^^^^^^^^^^^ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + set_aspect + set_box_aspect + apply_aspect + + +Ticks +^^^^^ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + tick_params + set_zticks + get_zticks + set_zticklabels + get_zticklines + get_zgridlines + get_zminorticklabels + get_zmajorticklabels + zaxis_date + + +Units +----- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + convert_zunits + + +Adding artists +-------------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + add_collection3d + + +Sharing +------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + sharez + + +Interactive +----------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + can_zoom + can_pan + disable_mouse_rotation + mouse_init + drag_pan + format_zdata + format_coord + + +Projection and perspective +-------------------------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + view_init + set_proj_type + get_proj + set_top_view + + +Drawing +------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + draw + get_tightbbox + + +Aliases and deprecated methods +------------------------------ + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + set_zlim3d + stem3D + text3D + tunit_cube + tunit_edges + unit_cube + w_xaxis + w_yaxis + w_zaxis + + +Other +----- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + get_axis_position + add_contour_set + add_contourf_set + update_datalim + + +.. currentmodule:: mpl_toolkits.mplot3d + +Sample 3D data +-------------- + +.. autosummary:: + :toctree: ../../_as_gen + :template: autosummary.rst + :nosignatures: + + axes3d.get_test_data + + +.. minigallery:: mpl_toolkits.mplot3d.axes3d.Axes3D + :add-heading: diff --git a/doc/api/toolkits/mplot3d/faq.rst b/doc/api/toolkits/mplot3d/faq.rst index dfc23b55e069..7e53cabc6e9a 100644 --- a/doc/api/toolkits/mplot3d/faq.rst +++ b/doc/api/toolkits/mplot3d/faq.rst @@ -49,4 +49,3 @@ Work is being done to eliminate this issue. For matplotlib v1.1.0, there is a semi-official manner to modify these parameters. See the note in the :mod:`.mplot3d.axis3d` section of the mplot3d API documentation for more information. - diff --git a/doc/api/toolkits/mplot3d/view_angles.rst b/doc/api/toolkits/mplot3d/view_angles.rst new file mode 100644 index 000000000000..10d4fac39e8c --- /dev/null +++ b/doc/api/toolkits/mplot3d/view_angles.rst @@ -0,0 +1,40 @@ +.. _toolkit_mplot3d-view-angles: + +******************* +mplot3d View Angles +******************* + +How to define the view angle +============================ + +The position of the viewport "camera" in a 3D plot is defined by three angles: +*elevation*, *azimuth*, and *roll*. From the resulting position, it always +points towards the center of the plot box volume. The angle direction is a +common convention, and is shared with +`PyVista `_ and +`MATLAB `_ +(though MATLAB lacks a roll angle). Note that a positive roll angle rotates the +viewing plane clockwise, so the 3d axes will appear to rotate +counter-clockwise. + +.. image:: /_static/mplot3d_view_angles.png + :align: center + :scale: 50 + +Rotating the plot using the mouse will control only the azimuth and elevation, +but all three angles can be set programmatically:: + + import matplotlib.pyplot as plt + ax = plt.figure().add_subplot(projection='3d') + ax.view_init(elev=30, azim=45, roll=15) + + +Primary view planes +=================== + +To look directly at the primary view planes, the required elevation, azimuth, +and roll angles are shown in the diagram of an "unfolded" plot below. These are +further documented in the `.mplot3d.axes3d.Axes3D.view_init` API. + +.. plot:: gallery/mplot3d/view_planes_3d.py + :align: center diff --git a/doc/api/transformations.rst b/doc/api/transformations.rst index 25d959801c28..186db9aea728 100644 --- a/doc/api/transformations.rst +++ b/doc/api/transformations.rst @@ -15,4 +15,3 @@ interval_contains, interval_contains_open :show-inheritance: :special-members: - diff --git a/doc/api/tri_api.rst b/doc/api/tri_api.rst index 9205e34ff93b..0b4e046eec08 100644 --- a/doc/api/tri_api.rst +++ b/doc/api/tri_api.rst @@ -2,7 +2,9 @@ ``matplotlib.tri`` ****************** -.. automodule:: matplotlib.tri +Unstructured triangular grid functions. + +.. py:module:: matplotlib.tri .. autoclass:: matplotlib.tri.Triangulation :members: @@ -17,7 +19,7 @@ :show-inheritance: .. autoclass:: matplotlib.tri.TriInterpolator - + .. autoclass:: matplotlib.tri.LinearTriInterpolator :members: __call__, gradient :show-inheritance: @@ -30,7 +32,7 @@ .. autoclass:: matplotlib.tri.UniformTriRefiner :show-inheritance: - :members: + :members: .. autoclass:: matplotlib.tri.TriAnalyzer - :members: + :members: diff --git a/doc/api/type1font.rst b/doc/api/type1font.rst index 2cb2a68eb5d5..00ef38f4d447 100644 --- a/doc/api/type1font.rst +++ b/doc/api/type1font.rst @@ -2,7 +2,12 @@ ``matplotlib.type1font`` ************************ -.. automodule:: matplotlib.type1font +.. attention:: + This module is considered internal. + + Its use is deprecated and it will be removed in a future version. + +.. automodule:: matplotlib._type1font :members: :undoc-members: :show-inheritance: diff --git a/doc/conf.py b/doc/conf.py index dfa0bfb495d5..b77dfa3ba85f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -5,25 +5,29 @@ # dir. # # The contents of this file are pickled, so don't put values in the namespace -# that aren't pickleable (module imports are okay, they're removed +# that aren't picklable (module imports are okay, they're removed # automatically). # # All configuration values have a default value; values that are commented out # serve to show the default value. +import logging import os from pathlib import Path import shutil import subprocess import sys +from urllib.parse import urlsplit, urlunsplit import warnings import matplotlib -import sphinx from datetime import datetime import time +# debug that building expected version +print(f"Building Documentation for Matplotlib: {matplotlib.__version__}") + # Release mode enables optimizations and other related options. is_release_build = tags.has('release') # noqa @@ -49,16 +53,11 @@ # usage in the gallery. warnings.filterwarnings('error', append=True) -# Strip backslashes in function's signature -# To be removed when numpydoc > 0.9.x -strip_signature_backslash = True - # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', - 'sphinx.ext.doctest', 'sphinx.ext.inheritance_diagram', 'sphinx.ext.intersphinx', 'sphinx.ext.ifconfig', @@ -77,26 +76,21 @@ 'sphinxext.skip_deprecated', 'sphinxext.redirect_from', 'sphinx_copybutton', - 'sphinx_panels', + 'sphinx_design', ] exclude_patterns = [ 'api/prev_api_changes/api_changes_*/*', ] -panels_add_bootstrap_css = False - def _check_dependencies(): names = { + **{ext: ext.split(".")[0] for ext in extensions}, + # Explicitly list deps that are not extensions, or whose PyPI package + # name does not match the (toplevel) module name. "colorspacious": 'colorspacious', - "IPython.sphinxext.ipython_console_highlighting": 'ipython', - "matplotlib": 'matplotlib', - "numpydoc": 'numpydoc', - "PIL.Image": 'pillow', - "pydata_sphinx_theme": 'pydata_sphinx_theme', - "sphinx_copybutton": 'sphinx_copybutton', - "sphinx_gallery": 'sphinx_gallery', + "mpl_sphinx_theme": 'mpl_sphinx_theme', "sphinxcontrib.inkscapeconverter": 'sphinxcontrib-svg2pdfconverter', } missing = [] @@ -144,9 +138,7 @@ def _check_dependencies(): warnings.filterwarnings('ignore', category=DeprecationWarning, module='sphinx.util.inspect') -# missing-references names matches sphinx>=3 behavior, so we can't be nitpicky -# for older sphinxes. -nitpicky = sphinx.version_info >= (3,) +nitpicky = True # change this to True to update the allowed failures missing_references_write_json = False missing_references_warn_unused_ignores = False @@ -162,6 +154,7 @@ def _check_dependencies(): 'python': ('https://docs.python.org/3/', None), 'scipy': ('https://docs.scipy.org/doc/scipy/', None), 'tornado': ('https://www.tornadoweb.org/en/stable/', None), + 'xarray': ('https://docs.xarray.dev/en/stable/', None), } @@ -183,39 +176,78 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, sphinx_gallery_conf = { + 'backreferences_dir': Path('api') / Path('_as_gen'), + # Compression is a significant effort that we skip for local and CI builds. + 'compress_images': ('thumbnails', 'images') if is_release_build else (), + 'doc_module': ('matplotlib', 'mpl_toolkits'), 'examples_dirs': ['../examples', '../tutorials', '../plot_types'], 'filename_pattern': '^((?!sgskip).)*$', 'gallery_dirs': ['gallery', 'tutorials', 'plot_types'], - 'doc_module': ('matplotlib', 'mpl_toolkits'), - 'reference_url': { - 'matplotlib': None, - }, - 'backreferences_dir': Path('api') / Path('_as_gen'), - 'subsection_order': gallery_order.sectionorder, - 'within_subsection_order': gallery_order.subsectionorder, - 'remove_config_comments': True, - 'min_reported_time': 1, - 'thumbnail_size': (320, 224), 'image_scrapers': (matplotlib_reduced_latex_scraper, ), - # Compression is a significant effort that we skip for local and CI builds. - 'compress_images': ('thumbnails', 'images') if is_release_build else (), - 'matplotlib_animations': True, 'image_srcset': ["2x"], 'junit': '../test-results/sphinx-gallery/junit.xml' if CIRCLECI else '', + 'matplotlib_animations': True, + 'min_reported_time': 1, + 'plot_gallery': 'True', # sphinx-gallery/913 + 'reference_url': {'matplotlib': None}, + 'remove_config_comments': True, + 'reset_modules': ( + 'matplotlib', + # clear basic_units module to re-register with unit registry on import + lambda gallery_conf, fname: sys.modules.pop('basic_units', None) + ), + 'subsection_order': gallery_order.sectionorder, + 'thumbnail_size': (320, 224), + 'within_subsection_order': gallery_order.subsectionorder, + 'capture_repr': (), } +if 'plot_gallery=0' in sys.argv: + # Gallery images are not created. Suppress warnings triggered where other + # parts of the documentation link to these images. + + def gallery_image_warning_filter(record): + msg = record.msg + for gallery_dir in sphinx_gallery_conf['gallery_dirs']: + if msg.startswith(f'image file not readable: {gallery_dir}'): + return False + + if msg == 'Could not obtain image size. :scale: option is ignored.': + return False + + return True + + logger = logging.getLogger('sphinx') + logger.addFilter(gallery_image_warning_filter) + + mathmpl_fontsize = 11.0 mathmpl_srcset = ['2x'] -# Monkey-patching gallery signature to include search keywords -gen_rst.SPHX_GLR_SIG = """\n +# Monkey-patching gallery header to include search keywords +gen_rst.EXAMPLE_HEADER = """ +.. DO NOT EDIT. +.. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY. +.. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE: +.. "{0}" +.. LINE NUMBERS ARE GIVEN BELOW. + .. only:: html - .. rst-class:: sphx-glr-signature + .. meta:: + :keywords: codex + + .. note:: + :class: sphx-glr-download-link-note + + Click :ref:`here ` + to download the full example code{2} - Keywords: matplotlib code example, codex, python plot, pyplot - `Gallery generated by Sphinx-Gallery - `_\n""" +.. rst-class:: sphx-glr-example-title + +.. _sphx_glr_{1}: + +""" # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -238,15 +270,16 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, except (subprocess.CalledProcessError, FileNotFoundError): SHA = matplotlib.__version__ + html_context = { - "sha": SHA, + "doc_version": SHA, } project = 'Matplotlib' copyright = ( - '2002 - 2012 John Hunter, Darren Dale, Eric Firing, Michael Droettboom ' + '2002–2012 John Hunter, Darren Dale, Eric Firing, Michael Droettboom ' 'and the Matplotlib development team; ' - f'2012 - {sourceyear} The Matplotlib development team' + f'2012–{sourceyear} The Matplotlib development team' ) @@ -287,22 +320,65 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, # Plot directive configuration # ---------------------------- -plot_formats = [('png', 100), ('pdf', 100)] +# For speedup, decide which plot_formats to build based on build targets: +# html only -> png +# latex only -> pdf +# all other cases, including html + latex -> png, pdf +# For simplicity, we assume that the build targets appear in the command line. +# We're falling back on using all formats in case that assumption fails. +formats = {'html': ('png', 100), 'latex': ('pdf', 100)} +plot_formats = [formats[target] for target in ['html', 'latex'] + if target in sys.argv] or list(formats.values()) + # GitHub extension github_project_url = "https://github.com/matplotlib/matplotlib/" + # Options for HTML output # ----------------------- +def add_html_cache_busting(app, pagename, templatename, context, doctree): + """ + Add cache busting query on CSS and JavaScript assets. + + This adds the Matplotlib version as a query to the link reference in the + HTML, if the path is not absolute (i.e., it comes from the `_static` + directory) and doesn't already have a query. + """ + from sphinx.builders.html import Stylesheet, JavaScript + + css_tag = context['css_tag'] + js_tag = context['js_tag'] + + def css_tag_with_cache_busting(css): + if isinstance(css, Stylesheet) and css.filename is not None: + url = urlsplit(css.filename) + if not url.netloc and not url.query: + url = url._replace(query=SHA) + css = Stylesheet(urlunsplit(url), priority=css.priority, + **css.attributes) + return css_tag(css) + + def js_tag_with_cache_busting(js): + if isinstance(js, JavaScript) and js.filename is not None: + url = urlsplit(js.filename) + if not url.netloc and not url.query: + url = url._replace(query=SHA) + js = JavaScript(urlunsplit(url), priority=js.priority, + **js.attributes) + return js_tag(js) + + context['css_tag'] = css_tag_with_cache_busting + context['js_tag'] = js_tag_with_cache_busting + + # The style sheet to use for HTML and HTML Help pages. A file of that name # must exist either in Sphinx' static/ path, or in one of the custom paths # given in html_static_path. -# html_style = 'matplotlib.css' -# html_style = f"mpl.css?{SHA}" html_css_files = [ - f"mpl.css?{SHA}", + "mpl.css", ] html_theme = "mpl_sphinx_theme" @@ -315,16 +391,29 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, # the sidebar. html_logo = "_static/logo2.svg" html_theme_options = { - "native_site": True, - "logo_link": "index", + "navbar_links": "internal", # collapse_navigation in pydata-sphinx-theme is slow, so skipped for local # and CI builds https://github.com/pydata/pydata-sphinx-theme/pull/386 "collapse_navigation": not is_release_build, "show_prev_next": False, + "switcher": { + "json_url": "https://matplotlib.org/devdocs/_static/switcher.json", + "version_match": ( + # The start version to show. This must be in switcher.json. + # We either go to 'stable' or to 'devdocs' + 'stable' if matplotlib.__version_info__.releaselevel == 'final' + else 'devdocs') + }, + "logo": {"link": "index", + "image_light": "images/logo2.svg", + "image_dark": "images/logo_dark.svg"}, + "navbar_end": ["theme-switcher", "version-switcher", "mpl_icon_links"], + "secondary_sidebar_items": "page-toc.html", + "footer_items": ["copyright", "sphinx-version", "doc_version"], } include_analytics = is_release_build if include_analytics: - html_theme_options["google_analytics_id"] = "UA-55954603-1" + html_theme_options["analytics"] = {"google_analytics_id": "UA-55954603-1"} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -351,7 +440,6 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, # Custom sidebar templates, maps page names to templates. html_sidebars = { "index": [ - 'search-field.html', # 'sidebar_announcement.html', "sidebar_versions.html", "cheatsheet_sidebar.html", @@ -360,6 +448,10 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, # '**': ['localtoc.html', 'pagesource.html'] } +# Copies only relevant code, not the '>>>' prompt +copybutton_prompt_text = r'>>> |\.\.\. ' +copybutton_prompt_is_regexp = True + # If true, add an index to the HTML documents. html_use_index = False @@ -372,7 +464,7 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. -html_use_opensearch = 'False' +html_use_opensearch = 'https://matplotlib.org/stable' # Output file base name for HTML help builder. htmlhelp_basename = 'Matplotlibdoc' @@ -425,7 +517,7 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, # Sphinx 2.0 adopts GNU FreeFont by default, but it does not have all # the Unicode codepoints needed for the section about Mathtext # "Writing mathematical expressions" -fontpkg = r""" +latex_elements['fontpkg'] = r""" \IfFontExistsTF{XITS}{ \setmainfont{XITS} }{ @@ -465,12 +557,7 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, Extension = .otf, ]} """ -latex_elements['fontpkg'] = fontpkg -# Sphinx <1.8.0 or >=2.0.0 does this by default, but the 1.8.x series -# did not for latex_engine = 'xelatex' (as it used Latin Modern font). -# We need this for code-blocks as FreeMono has wide glyphs. -latex_elements['fvset'] = r'\fvset{fontsize=\small}' # Fix fancyhdr complaining about \headheight being too small latex_elements['passoptionstopackages'] = r""" \PassOptionsToPackage{headheight=14pt}{geometry} @@ -538,40 +625,18 @@ def matplotlib_reduced_latex_scraper(block, block_vars, gallery_conf, numpydoc_show_class_members = False -html4_writer = True - -inheritance_node_attrs = dict(fontsize=16) +# We want to prevent any size limit, as we'll add scroll bars with CSS. +inheritance_graph_attrs = dict(dpi=100, size='1000.0', splines='polyline') +# Also remove minimum node dimensions, and increase line size a bit. +inheritance_node_attrs = dict(height=0.02, margin=0.055, penwidth=1, + width=0.01) +inheritance_edge_attrs = dict(penwidth=1) graphviz_dot = shutil.which('dot') # Still use PNG until SVG linking is fixed # https://github.com/sphinx-doc/sphinx/issues/3176 # graphviz_output_format = 'svg' - -def reduce_plot_formats(app): - # Fox CI and local builds, we don't need all the default plot formats, so - # only generate the directly useful one for the current builder. - if app.builder.name == 'html': - keep = 'png' - elif app.builder.name == 'latex': - keep = 'pdf' - else: - return - app.config.plot_formats = [entry - for entry in app.config.plot_formats - if entry[0] == keep] - - -def setup(app): - if any(st in version for st in ('post', 'alpha', 'beta')): - bld_type = 'dev' - else: - bld_type = 'rel' - app.add_config_value('releaselevel', bld_type, 'env') - - if not is_release_build: - app.connect('builder-inited', reduce_plot_formats) - # ----------------------------------------------------------------------------- # Source code links # ----------------------------------------------------------------------------- @@ -628,14 +693,29 @@ def linkcode_resolve(domain, info): if lineno else "") startdir = Path(matplotlib.__file__).parent.parent - fn = os.path.relpath(fn, start=startdir).replace(os.path.sep, '/') + try: + fn = os.path.relpath(fn, start=startdir).replace(os.path.sep, '/') + except ValueError: + return None if not fn.startswith(('matplotlib/', 'mpl_toolkits/')): return None version = parse(matplotlib.__version__) - tag = 'master' if version.is_devrelease else f'v{version.public}' + tag = 'main' if version.is_devrelease else f'v{version.public}' return ("https://github.com/matplotlib/matplotlib/blob" f"/{tag}/lib/{fn}{linespec}") else: extensions.append('sphinx.ext.viewcode') + + +# ----------------------------------------------------------------------------- +# Sphinx setup +# ----------------------------------------------------------------------------- +def setup(app): + if any(st in version for st in ('post', 'dev', 'alpha', 'beta')): + bld_type = 'dev' + else: + bld_type = 'rel' + app.add_config_value('releaselevel', bld_type, 'env') + app.connect('html-page-context', add_html_cache_busting, priority=1000) diff --git a/doc/devel/MEP/MEP08.rst b/doc/devel/MEP/MEP08.rst index 67ce5d3d76ef..18419ac2bf11 100644 --- a/doc/devel/MEP/MEP08.rst +++ b/doc/devel/MEP/MEP08.rst @@ -9,7 +9,10 @@ Status ====== -**Completed** +**Superseded** + +Current guidelines for style, including usage of pep8 are maintained +in `our pull request guidelines `_. We are currently enforcing a sub-set of pep8 on new code contributions. diff --git a/doc/devel/MEP/MEP14.rst b/doc/devel/MEP/MEP14.rst index 2c680017b515..574c733e10bf 100644 --- a/doc/devel/MEP/MEP14.rst +++ b/doc/devel/MEP/MEP14.rst @@ -78,8 +78,8 @@ number of other projects: - `Microsoft DirectWrite`_ - `Apple Core Text`_ -.. _pango: https://www.pango.org/ -.. _harfbuzz: https://www.freedesktop.org/wiki/Software/HarfBuzz/ +.. _pango: https://pango.gnome.org +.. _harfbuzz: https://github.com/harfbuzz/harfbuzz .. _QtTextLayout: https://doc.qt.io/archives/qt-4.8/qtextlayout.html .. _Microsoft DirectWrite: https://docs.microsoft.com/en-ca/windows/win32/directwrite/introducing-directwrite .. _Apple Core Text: https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/CoreText_Programming/Overview/Overview.html diff --git a/doc/devel/MEP/MEP19.rst b/doc/devel/MEP/MEP19.rst index 05e34132249f..fd93ba619aed 100644 --- a/doc/devel/MEP/MEP19.rst +++ b/doc/devel/MEP/MEP19.rst @@ -67,7 +67,7 @@ great!]: **Documentation** -Documentation of master is now built by travis and uploaded to https://matplotlib.org/devdocs/index.html +Documentation of main is now built by travis and uploaded to https://matplotlib.org/devdocs/index.html @NelleV, I believe, generates the docs automatically and posts them on the web to chart MEP10 progress. diff --git a/doc/devel/MEP/MEP26.rst b/doc/devel/MEP/MEP26.rst index 929393a683d2..9d3af8f8c703 100644 --- a/doc/devel/MEP/MEP26.rst +++ b/doc/devel/MEP/MEP26.rst @@ -34,7 +34,7 @@ Detailed description ==================== Currently, the look and appearance of existing artist objects (figure, -axes, Line2D etc...) can only be updated via ``set_`` and ``get_`` methods +axes, Line2D, etc.) can only be updated via ``set_`` and ``get_`` methods on the artist object, which is quite laborious, especially if no reference to the artist(s) has been stored. The new style sheets introduced in 1.4 allow styling before a plot is created, but do not @@ -51,7 +51,7 @@ of primitives. The new methodology would require development of a number of steps: - A new stylesheet syntax (likely based on CSS) to allow selection of - artists by type, class, id etc... + artists by type, class, id, etc. - A mechanism by which to parse a stylesheet into a tree - A mechanism by which to translate the parse-tree into something which can be used to update the properties of relevant diff --git a/doc/devel/MEP/MEP27.rst b/doc/devel/MEP/MEP27.rst index d4ea57f1b6a6..81eca8f9c53d 100644 --- a/doc/devel/MEP/MEP27.rst +++ b/doc/devel/MEP/MEP27.rst @@ -13,9 +13,11 @@ Status Branches and Pull requests ========================== Main PR (including GTK3): + + https://github.com/matplotlib/matplotlib/pull/4143 Backend specific branch diffs: + + https://github.com/OceanWolf/matplotlib/compare/backend-refactor...OceanWolf:backend-refactor-tkagg + https://github.com/OceanWolf/matplotlib/compare/backend-refactor...OceanWolf:backend-refactor-qt + https://github.com/OceanWolf/matplotlib/compare/backend-refactor...backend-refactor-wx @@ -49,15 +51,14 @@ Two main places for generic code appear in the classes derived from 1. ``FigureManagerBase`` has **three** jobs at the moment: - 1. The documentation describes it as a *``Helper class for pyplot - mode, wraps everything up into a neat bundle''* + 1. The documentation describes it as a *Helper class for pyplot + mode, wraps everything up into a neat bundle* 2. But it doesn't just wrap the canvas and toolbar, it also does all of the windowing tasks itself. The conflation of these two - tasks gets seen the best in the following line: ```python - self.set_window_title("Figure %d" % num) ``` This combines + tasks gets seen the best in the following line: + ``self.set_window_title("Figure %d" % num)`` This combines backend specific code ``self.set_window_title(title)`` with matplotlib generic code ``title = "Figure %d" % num``. - 3. Currently the backend specific subclass of ``FigureManager`` decides when to end the mainloop. This also seems very wrong as the figure should have no control over the other figures. @@ -95,7 +96,7 @@ The description of this MEP gives us most of the solution: 1. This allows us to break up the conversion of backends into separate PRs as we can keep the existing ``FigureManagerBase`` class and its dependencies intact. - 2. and this also anticipates MEP22 where the new + 2. And this also anticipates MEP22 where the new ``NavigationBase`` has morphed into a backend independent ``ToolManager``. diff --git a/doc/devel/MEP/MEP28.rst b/doc/devel/MEP/MEP28.rst index 631be1e2b548..07b83c17800e 100644 --- a/doc/devel/MEP/MEP28.rst +++ b/doc/devel/MEP/MEP28.rst @@ -46,7 +46,7 @@ Detailed description Currently, the ``Axes.boxplot`` method accepts parameters that allow the users to specify medians and confidence intervals for each box that -will be drawn in the plot. These were provided so that avdanced users +will be drawn in the plot. These were provided so that advanced users could provide statistics computed in a different fashion that the simple method provided by matplotlib. However, handling this input requires complex logic to make sure that the forms of the data structure match what diff --git a/doc/devel/MEP/MEP29.rst b/doc/devel/MEP/MEP29.rst index 0a42e7d1eff7..d937889d55de 100644 --- a/doc/devel/MEP/MEP29.rst +++ b/doc/devel/MEP/MEP29.rst @@ -34,7 +34,7 @@ one has to look at the gallery where one such example is provided: This example takes a list of strings as well as a list of colors which makes it cumbersome to use. An alternative would be to use a restricted set of pango_-like markup and to interpret this markup. -.. _pango: https://developer.gnome.org/pygtk/stable/pango-markup-language.html +.. _pango: https://docs.gtk.org/Pango/pango_markup.html#pango-markup Some markup examples:: diff --git a/doc/devel/MEP/template.rst b/doc/devel/MEP/template.rst index 81191fc44eeb..00bdbc87a95e 100644 --- a/doc/devel/MEP/template.rst +++ b/doc/devel/MEP/template.rst @@ -24,7 +24,7 @@ MEPs go through a number of phases in their lifetime: - **Progress**: Consensus was reached and implementation work has begun. -- **Completed**: The implementation has been merged into master. +- **Completed**: The implementation has been merged into main. - **Superseded**: This MEP has been abandoned in favor of another approach. diff --git a/doc/devel/coding_guide.rst b/doc/devel/coding_guide.rst index ccc6d8a5d0ef..d584a1c986e7 100644 --- a/doc/devel/coding_guide.rst +++ b/doc/devel/coding_guide.rst @@ -13,8 +13,93 @@ Pull request guidelines *********************** -Pull requests (PRs) are the mechanism for contributing to Matplotlibs code and -documentation. +`Pull requests (PRs) on GitHub +`__ +are the mechanism for contributing to Matplotlib's code and documentation. + +It is recommended to check that your contribution complies with the following +rules before submitting a pull request: + +* If your pull request addresses an issue, please use the title to describe the + issue (e.g. "Add ability to plot timedeltas") and mention the issue number + in the pull request description to ensure that a link is created to the + original issue (e.g. "Closes #8869" or "Fixes #8869"). This will ensure the + original issue mentioned is automatically closed when your PR is merged. See + `the GitHub documentation + `__ + for more details. + +* Formatting should follow the recommendations of PEP8_, as enforced by + flake8_. Matplotlib modifies PEP8 to extend the maximum line length to 88 + characters. You can check flake8 compliance from the command line with :: + + python -m pip install flake8 + flake8 /path/to/module.py + + or your editor may provide integration with it. Note that Matplotlib + intentionally does not use the black_ auto-formatter (1__), in particular due + to its unability to understand the semantics of mathematical expressions + (2__, 3__). + + .. _PEP8: https://www.python.org/dev/peps/pep-0008/ + .. _flake8: https://flake8.pycqa.org/ + .. _black: https://black.readthedocs.io/ + .. __: https://github.com/matplotlib/matplotlib/issues/18796 + .. __: https://github.com/psf/black/issues/148 + .. __: https://github.com/psf/black/issues/1984 + +* All public methods should have informative docstrings with sample usage when + appropriate. Use the :ref:`docstring standards `. + +* For high-level plotting functions, consider adding a simple example either in + the ``Example`` section of the docstring or the + :ref:`examples gallery `. + +* Changes (both new features and bugfixes) should have good test coverage. See + :ref:`testing` for more details. + +* Import the following modules using the standard scipy conventions:: + + import numpy as np + import numpy.ma as ma + import matplotlib as mpl + import matplotlib.pyplot as plt + import matplotlib.cbook as cbook + import matplotlib.patches as mpatches + + In general, Matplotlib modules should **not** import `.rcParams` using ``from + matplotlib import rcParams``, but rather access it as ``mpl.rcParams``. This + is because some modules are imported very early, before the `.rcParams` + singleton is constructed. + +* If your change is a major new feature, add an entry to the ``What's new`` + section by adding a new file in ``doc/users/next_whats_new`` (see + :file:`doc/users/next_whats_new/README.rst` for more information). + +* If you change the API in a backward-incompatible way, please document it in + :file:`doc/api/next_api_changes/behavior`, by adding a new file with the + naming convention ``99999-ABC.rst`` where the pull request number is followed + by the contributor's initials. (see :file:`doc/api/api_changes.rst` for more + information) + +* See below for additional points about :ref:`keyword-argument-processing`, if + applicable for your pull request. + +.. note:: + + The current state of the Matplotlib code base is not compliant with all + of these guidelines, but we expect that enforcing these constraints on all + new contributions will move the overall code base quality in the right + direction. + + +.. seealso:: + + * :ref:`coding_guidelines` + * :ref:`testing` + * :ref:`documenting-matplotlib` + + Summary for pull request authors ================================ @@ -34,7 +119,7 @@ When making a PR, pay attention to: .. rst-class:: checklist -* :ref:`Target the master branch `. +* :ref:`Target the main branch `. * Adhere to the :ref:`coding_guidelines`. * Update the :ref:`documentation ` if necessary. * Aim at making the PR as "ready-to-go" as you can. This helps to speed up @@ -45,7 +130,7 @@ When making a PR, pay attention to: on GitHub. * When updating your PR, instead of adding new commits to fix something, please consider amending your initial commit(s) to keep the history clean. - You can achieve this using + You can achieve this by using .. code-block:: bash @@ -72,13 +157,18 @@ Content topics: * Does the PR conform with the :ref:`coding_guidelines`? * Is the :ref:`documentation ` (docstrings, examples, what's new, API changes) updated? +* Is the change purely stylistic? Generally, such changes are discouraged when + not part of other non-stylistic work because it obscures the git history of + functional changes to the code. Reflowing a method or docstring as part of a + larger refactor/rewrite is acceptable. + Organizational topics: .. rst-class:: checklist * Make sure all :ref:`automated tests ` pass. -* The PR should :ref:`target the master branch `. +* The PR should :ref:`target the main branch `. * Tag with descriptive :ref:`labels `. * Set the :ref:`milestone `. * Keep an eye on the :ref:`number of commits `. @@ -108,13 +198,60 @@ Documentation * See :ref:`documenting-matplotlib` for our documentation style guide. -* If your change is a major new feature, add an entry to - :file:`doc/users/whats_new.rst`. +.. _release_notes: + +New features and API changes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +When adding a major new feature or changing the API in a backward incompatible +way, please document it by including a versioning directive in the docstring +and adding an entry to the folder for either the what's new or API change notes. + ++-------------------+-----------------------------+----------------------------------+ +| for this addition | include this directive | create entry in this folder | ++===================+=============================+==================================+ +| new feature | ``.. versionadded:: 3.N`` | :file:`doc/users/next_whats_new/`| ++-------------------+-----------------------------+----------------------------------+ +| API change | ``.. versionchanged:: 3.N`` | :file:`doc/api/next_api_changes/`| +| | | | +| | | probably in ``behavior/`` | ++-------------------+-----------------------------+----------------------------------+ + +The directives should be placed at the end of a description block. For example:: + + class Foo: + """ + This is the summary. + + Followed by a longer description block. + + Consisting of multiple lines and paragraphs. + + .. versionadded:: 3.5 + + Parameters + ---------- + a : int + The first parameter. + b: bool, default: False + This was added later. + + .. versionadded:: 3.6 + """ + + def set_b(b): + """ + Set b. + + .. versionadded:: 3.6 + + Parameters + ---------- + b: bool -* If you change the API in a backward-incompatible way, please - document it by adding a file in the relevant subdirectory of - :file:`doc/api/next_api_changes/`, probably in the ``behavior/`` - subdirectory. +For classes and functions, the directive should be placed before the +*Parameters* section. For parameters, the directive should be placed at the +end of the parameter description. The patch release version is omitted and +the directive should not be added to entire modules. .. _pr-labels: @@ -134,20 +271,20 @@ Milestones * Set the milestone according to these rules: * *New features and API changes* are milestoned for the next minor release - ``v3.X.0``. + ``v3.N.0``. - * *Bugfixes and docstring changes* are milestoned for the next patch - release ``v3.X.Y`` + * *Bugfixes, tests for released code, and docstring changes* are milestoned + for the next patch release ``v3.N.M``. * *Documentation changes* (all .rst files and examples) are milestoned - ``v3.X-doc`` + ``v3.N-doc``. If multiple rules apply, choose the first matching from the above list. Setting a milestone does not imply or guarantee that a PR will be merged for that release, but if it were to be merged what release it would be in. - All of these PRs should target the master branch. The milestone tag triggers + All of these PRs should target the main branch. The milestone tag triggers an :ref:`automatic backport ` for milestones which have a corresponding branch. @@ -269,13 +406,13 @@ Current branches ---------------- The current active branches are -*master* +*main* The current development version. Future minor releases (*v3.N.0*) will be - branched from this. Supports Python 3.7+. + branched from this. *v3.N.x* Maintenance branch for Matplotlib 3.N. Future patch releases will be - branched from this. Supports Python 3.6+. + branched from this. *v3.N.M-doc* Documentation for the current release. On a patch release, this will be @@ -287,7 +424,7 @@ The current active branches are Branch selection for pull requests ---------------------------------- -Generally, all pull requests should target the master branch. +Generally, all pull requests should target the main branch. Other branches are fed through :ref:`automatic ` or :ref:`manual `. Directly @@ -346,7 +483,7 @@ When doing backports please copy the form used by meeseekdev, conflicts make note of them and how you resolved them in the commit message. -We do a backport from master to v2.2.x assuming: +We do a backport from main to v2.2.x assuming: * ``matplotlib`` is a read-only remote branch of the matplotlib/matplotlib repo @@ -378,4 +515,4 @@ and then continue the cherry pick: git cherry-pick --continue Use your discretion to push directly to upstream or to open a PR; be -sure to push or PR against the ``v2.2.x`` upstream branch, not ``master``! +sure to push or PR against the ``v2.2.x`` upstream branch, not ``main``! diff --git a/doc/devel/color_changes.rst b/doc/devel/color_changes.rst index 24aa15f29abf..f7646ded7c14 100644 --- a/doc/devel/color_changes.rst +++ b/doc/devel/color_changes.rst @@ -4,7 +4,8 @@ Default color changes ********************* -As discussed at length elsewhere [insert links], ``jet`` is an +As discussed at length `elsewhere `__ , +``jet`` is an empirically bad colormap and should not be the default colormap. Due to the position that changing the appearance of the plot breaks backward compatibility, this change has been put off for far longer diff --git a/doc/devel/contributing.rst b/doc/devel/contributing.rst index 225219cc5837..6026c7b4d443 100644 --- a/doc/devel/contributing.rst +++ b/doc/devel/contributing.rst @@ -4,27 +4,67 @@ Contributing ============ +You've discovered a bug or something else you want to change +in Matplotlib — excellent! + +You've worked out a way to fix it — even better! + +You want to tell us about it — best of all! + This project is a community effort, and everyone is welcome to contribute. Everyone within the community is expected to abide by our -`code of conduct `_. +`code of conduct `_. -The project is hosted on -https://github.com/matplotlib/matplotlib +Below, you can find a number of ways to contribute, and how to connect with the +Matplotlib community. -Contributor incubator -===================== +Get Connected +============= + +Do I really have something to contribute to Matplotlib? +------------------------------------------------------- -If you are interested in becoming a regular contributor to Matplotlib, but -don't know where to start or feel insecure about it, you can join our non-public -communication channel for new contributors. To do so, please go to `gitter -`_ and ask to be added to '#incubator'. -This is a private gitter room moderated by core Matplotlib developers where you can -get guidance and support for your first few PRs. This is a place you can ask questions -about anything: how to use git, github, how our PR review process works, technical questions -about the code, what makes for good documentation or a blog post, how to get involved involved -in community work, or get "pre-review" on your PR. +100% yes. There are so many ways to contribute to our community. +When in doubt, we recommend going together! Get connected with our community of +active contributors, many of whom felt just like you when they started out and +are happy to welcome you and support you as you get to know how we work, and +where things are. Take a look at the next sections to learn more. + +Contributor incubator +--------------------- + +The incubator is our non-public communication channel for new contributors. It +is a private gitter room moderated by core Matplotlib developers where you can +get guidance and support for your first few PRs. It's a place you can ask +questions about anything: how to use git, GitHub, how our PR review process +works, technical questions about the code, what makes for good documentation +or a blog post, how to get involved in community work, or get +"pre-review" on your PR. + +To join, please go to our public `gitter +`_ community channel, and ask to be +added to '#incubator'. One of our core developers will see your message and will +add you. + +New Contributors meeting +------------------------ + +Once a month, we host a meeting to discuss topics that interest new +contributors. Anyone can attend, present, or sit in and listen to the call. +Among our attendees are fellow new contributors, as well as maintainers, and +veteran contributors, who are keen to support onboarding of new folks and +share their experience. You can find our community calendar link at the +`Scientific Python website `_, and +you can browse previous meeting notes on `GitHub +`_. +We recommend joining the meeting to clarify any doubts, or lingering +questions you might have, and to get to know a few of the people behind the +GitHub handles 😉. You can reach out to @noatamir on `gitter +`_ for any clarifications or +suggestions. We <3 feedback! .. _new_contributors: @@ -32,13 +72,32 @@ Issues for new contributors --------------------------- While any contributions are welcome, we have marked some issues as -particularly suited for new contributors by the label -`good first issue `_ -These are well documented issues, that do not require a deep understanding of -the internals of Matplotlib. The issues may additionally be tagged with a -difficulty. ``Difficulty: Easy`` is suited for people with little Python experience. -``Difficulty: Medium`` and ``Difficulty: Hard`` are not trivial to solve and -require more thought and programming experience. +particularly suited for new contributors by the label `good first issue +`_. These +are well documented issues, that do not require a deep understanding of the +internals of Matplotlib. The issues may additionally be tagged with a +difficulty. ``Difficulty: Easy`` is suited for people with little Python +experience. ``Difficulty: Medium`` and ``Difficulty: Hard`` require more +programming experience. This could be for a variety of reasons, among them, +though not necessarily all at the same time: + +- The issue is in areas of the code base which have more interdependencies, + or legacy code. +- It has less clearly defined tasks, which require some independent + exploration, making suggestions, or follow-up discussions to clarify a good + path to resolve the issue. +- It involves Python features such as decorators and context managers, which + have subtleties due to our implementation decisions. + +In general, the Matplotlib project does not assign issues. Issues are +"assigned" or "claimed" by opening a PR; there is no other assignment +mechanism. If you have opened such a PR, please comment on the issue thread to +avoid duplication of work. Please check if there is an existing PR for the +issue you are addressing. If there is, try to work with the author by +submitting reviews of their code or commenting on the PR rather than opening +a new PR; duplicate PRs are subject to being closed. However, if the existing +PR is an outline, unlikely to work, or stalled, and the original author is +unresponsive, feel free to open a new PR referencing the old one. .. _submitting-a-bug-report: @@ -73,7 +132,7 @@ If you are reporting a bug, please do your best to include the following: >>> platform.python_version() '3.9.2' -We have preloaded the issue creation page with a Markdown template that you can +We have preloaded the issue creation page with a Markdown form that you can use to organize this information. Thank you for your help in keeping bug reports complete, targeted and descriptive. @@ -123,9 +182,9 @@ A brief overview is: 5. Create a branch to hold your changes:: - git checkout -b my-feature origin/master + git checkout -b my-feature origin/main - and start making changes. Never work in the ``master`` branch! + and start making changes. Never work in the ``main`` branch! 6. Work on this copy, on your computer, using Git to do the version control. When you're done editing e.g., ``lib/matplotlib/collections.py``, do:: @@ -140,96 +199,8 @@ A brief overview is: Finally, go to the web page of your fork of the Matplotlib repo, and click 'Pull request' to send your changes to the maintainers for review. -.. seealso:: - - * `Git documentation `_ - * `Git-Contributing to a Project `_ - * `Introduction to GitHub `_ - * :ref:`development-workflow` for best practices for Matplotlib - * :ref:`using-git` - -Contributing pull requests --------------------------- - -It is recommended to check that your contribution complies with the following -rules before submitting a pull request: - -* If your pull request addresses an issue, please use the title to describe the - issue and mention the issue number in the pull request description to ensure - that a link is created to the original issue. - -* All public methods should have informative docstrings with sample usage when - appropriate. Use the `numpy docstring standard - `_. - -* Formatting should follow the recommendations of PEP8_, as enforced by - flake8_. You can check flake8 compliance from the command line with :: - - python -m pip install flake8 - flake8 /path/to/module.py - - or your editor may provide integration with it. Note that Matplotlib - intentionally does not use the black_ auto-formatter (1__), in particular due - to its unability to understand the semantics of mathematical expressions - (2__, 3__). - - .. _PEP8: https://www.python.org/dev/peps/pep-0008/ - .. _flake8: https://flake8.pycqa.org/ - .. _black: https://black.readthedocs.io/ - .. __: https://github.com/matplotlib/matplotlib/issues/18796 - .. __: https://github.com/psf/black/issues/148 - .. __: https://github.com/psf/black/issues/1984 - -* Each high-level plotting function should have a simple example in the - ``Example`` section of the docstring. This should be as simple as possible - to demonstrate the method. More complex examples should go in the - ``examples`` tree. - -* Changes (both new features and bugfixes) should have good test coverage. See - :ref:`testing` for more details. - -* Import the following modules using the standard scipy conventions:: - - import numpy as np - import numpy.ma as ma - import matplotlib as mpl - import matplotlib.pyplot as plt - import matplotlib.cbook as cbook - import matplotlib.patches as mpatches - - In general, Matplotlib modules should **not** import `.rcParams` using ``from - matplotlib import rcParams``, but rather access it as ``mpl.rcParams``. This - is because some modules are imported very early, before the `.rcParams` - singleton is constructed. - -* If your change is a major new feature, add an entry to the ``What's new`` - section by adding a new file in ``doc/users/next_whats_new`` (see - :file:`doc/users/next_whats_new/README.rst` for more information). - -* If you change the API in a backward-incompatible way, please document it in - :file:`doc/api/next_api_changes/behavior`, by adding a new file with the - naming convention ``99999-ABC.rst`` where the pull request number is followed - by the contributor's initials. (see :file:`doc/api/api_changes.rst` for more - information) - -* See below for additional points about :ref:`keyword-argument-processing`, if - applicable for your pull request. - -.. note:: - - The current state of the Matplotlib code base is not compliant with all - of those guidelines, but we expect that enforcing those constraints on all - new contributions will move the overall code base quality in the right - direction. - - -.. seealso:: - - * :ref:`coding_guidelines` - * :ref:`testing` - * :ref:`documenting-matplotlib` - - +For more detailed instructions on how to set up Matplotlib for development and +best practices for contribution, see :ref:`installing_for_devs`. .. _contributing_documentation: @@ -279,6 +250,12 @@ API consistency and stability are of great value. Therefore, API changes (e.g. signature changes, behavior changes, removals) will only be conducted if the added benefit is worth the user effort for adapting. +Because we are a visualization library our primary output is the final +visualization the user sees. Thus it is our :ref:`long standing +` policy that the appearance of the figure is part of the API +and any changes, either semantic or esthetic, will be treated as a +backwards-incompatible API change. + API changes in Matplotlib have to be performed following the deprecation process below, except in very rare circumstances as deemed necessary by the development team. This ensures that users are notified before the change will take effect and thus @@ -288,7 +265,7 @@ Rules ~~~~~ - Deprecations are targeted at the next point.release (e.g. 3.x) -- Deprecated API is generally removed two two point-releases after introduction +- Deprecated API is generally removed two point-releases after introduction of the deprecation. Longer deprecations can be imposed by core developers on a case-by-case basis to give more time for the transition - The old API must remain fully functional during the deprecation period @@ -303,8 +280,8 @@ Introducing 1. Announce the deprecation in a new file :file:`doc/api/next_api_changes/deprecations/99999-ABC.rst` where ``99999`` is the pull request number and ``ABC`` are the contributor's initials. -2. If possible, issue a `.MatplotlibDeprecationWarning` when the deprecated - API is used. There are a number of helper tools for this: +2. If possible, issue a `~matplotlib.MatplotlibDeprecationWarning` when the + deprecated API is used. There are a number of helper tools for this: - Use ``_api.warn_deprecated()`` for general deprecation warnings - Use the decorator ``@_api.deprecated`` to deprecate classes, functions, @@ -526,7 +503,9 @@ running the script:: from matplotlib import my_matplotlib_module my_matplotlib_module.set_range(0, 0) # set range -will display:: +will display + +.. code-block:: none UserWarning: Attempting to set identical bottom==top warnings.warn('Attempting to set identical bottom==top') diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst index 5da29663ad7d..da67cae0061e 100644 --- a/doc/devel/dependencies.rst +++ b/doc/devel/dependencies.rst @@ -7,6 +7,7 @@ Dependencies Runtime dependencies ==================== + Mandatory dependencies ---------------------- @@ -14,15 +15,20 @@ When installing through a package manager like ``pip`` or ``conda``, the mandatory dependencies are automatically installed. This list is mainly for reference. -* `Python `_ (>= 3.7) -* `NumPy `_ (>= 1.17) -* `setuptools `_ +* `Python `_ (>= 3.8) +* `contourpy `_ (>= 1.0.1) * `cycler `_ (>= 0.10.0) * `dateutil `_ (>= 2.7) +* `fontTools `_ (>= 4.22.0) * `kiwisolver `_ (>= 1.0.1) +* `NumPy `_ (>= 1.20) +* `packaging `_ (>= 20.0) * `Pillow `_ (>= 6.2) -* `pyparsing `_ (>=2.2.1) -* `fontTools `_ (>=4.22.0) +* `pyparsing `_ (>= 2.3.1) +* `setuptools `_ +* `pyparsing `_ (>= 2.3.1) +* `importlib-resources `_ + (>= 3.2.0; only required on Python < 3.10) .. _optional_dependencies: @@ -40,13 +46,21 @@ Matplotlib figures can be rendered to various user interfaces. See :ref:`what-is-a-backend` for more details on the optional Matplotlib backends and the capabilities they provide. -* Tk_ (>= 8.4, != 8.6.0 or 8.6.1) [#]_: for the Tk-based backends. +* Tk_ (>= 8.4, != 8.6.0 or 8.6.1): for the Tk-based backends. Tk is part of + most standard Python installations, but it's not part of Python itself and + thus may not be present in rare cases. * PyQt6_ (>= 6.1), PySide6_, PyQt5_, or PySide2_: for the Qt-based backends. -* PyGObject_: for the GTK-based backends [#]_. -* wxPython_ (>= 4) [#]_: for the wx-based backends. -* pycairo_ (>= 1.11.0) or cairocffi_ (>= 0.8): for the GTK and/or cairo-based - backends. -* Tornado_ (>=5): for the WebAgg backend. +* PyGObject_ and pycairo_ (>= 1.14.0): for the GTK-based backends. If using pip + (but not conda or system package manager) PyGObject must be built from + source; see `pygobject documentation + `_. +* pycairo_ (>= 1.14.0) or cairocffi_ (>= 0.8): for cairo-based backends. +* wxPython_ (>= 4): for the wx-based backends. If using pip (but not conda or + system package manager) on Linux wxPython wheels must be manually downloaded + from https://wxpython.org/pages/downloads/. +* Tornado_ (>= 5): for the WebAgg backend. +* ipykernel_: for the nbagg backend. +* macOS (>= 10.12): for the macosx backend. .. _Tk: https://docs.python.org/3/library/tk.html .. _PyQt5: https://pypi.org/project/PyQt5/ @@ -58,13 +72,7 @@ and the capabilities they provide. .. _pycairo: https://pycairo.readthedocs.io/en/latest/ .. _cairocffi: https://cairocffi.readthedocs.io/en/latest/ .. _Tornado: https://pypi.org/project/tornado/ - -.. [#] Tk is part of most standard Python installations, but it's not part of - Python itself and thus may not be present in rare cases. -.. [#] If using pip (and not conda), PyGObject must be built from source; see - https://pygobject.readthedocs.io/en/latest/devguide/dev_environ.html. -.. [#] If using pip (and not conda) on Linux, wxPython wheels must be manually - downloaded from https://wxpython.org/pages/downloads/. +.. _ipykernel: https://pypi.org/project/ipykernel/ Animations ~~~~~~~~~~ @@ -77,8 +85,9 @@ Font handling and rendering ~~~~~~~~~~~~~~~~~~~~~~~~~~~ * `LaTeX `_ (with `cm-super - `__ ) and `GhostScript (>=9.0) - `_ : for rendering text with LaTeX. + `__ and `underscore + `__) and `GhostScript (>= 9.0) + `_: for rendering text with LaTeX. * `fontconfig `_ (>= 2.7): for detection of system fonts on Linux. @@ -165,45 +174,121 @@ remember to clear your artifacts before re-building:: git clean -xfd +Minimum pip / manylinux support (linux) +--------------------------------------- + +Matplotlib publishes `manylinux wheels `_ +which have a minimum version of pip which will recognize the wheels + +- Python 3.8: ``manylinx2010`` / pip >= 19.0 +- Python 3.9+: ``manylinx2014`` / pip >= 19.3 + +In all cases the required version of pip is embedded in the CPython source. + + + .. _development-dependencies: -Additional dependencies for development -======================================= +Dependencies for building Matplotlib +==================================== + +.. _setup-dependencies: + +Setup dependencies +------------------ + +- `certifi `_ (>= 2020.06.20). Used while + downloading the freetype and QHull source during build. This is not a + runtime dependency. +- `setuptools_scm `_ (>= 7). Used to + update the reported ``mpl.__version__`` based on the current git commit. + Also a runtime dependency for editable installs. +- `NumPy `_ (>= 1.20). Also a runtime dependency. + + +.. _compile-dependencies: + +C++ compiler +------------ + +Matplotlib requires a C++ compiler that supports C++11. + +- `gcc 4.8.1 `_ or higher. For gcc <6.5 you will + need to set ``$CFLAGS=-std=c++11`` to enable C++11 support. + `Installing GCC: Binaries `_. +- `clang 3.3 `_ or higher. + `LLVM Download Page `_. +- `Visual Studio 2015 + `_ + (aka VS 14.0) or higher. A free version of Build Tools for Visual Studio is available for + `download `_. + .. _test-dependencies: -Additional dependencies for testing +Dependencies for testing Matplotlib =================================== This section lists the additional software required for :ref:`running the tests `. Required: -- pytest_ (>=3.6) -- Ghostscript_ (>= 9.0, to render PDF files) -- Inkscape_ (to render SVG files) +- pytest_ (>= 3.6) Optional: -- pytest-cov_ (>=2.3.1) to collect coverage information +In addition to all of the optional dependencies on the main library, for +testing the following will be used if they are installed. + +- Ghostscript_ (>= 9.0, to render PDF files) +- Inkscape_ (to render SVG files) +- nbformat_ and nbconvert_ used to test the notebook backend +- pandas_ used to test compatibility with Pandas +- pikepdf_ used in some tests for the pgf and pdf backends +- psutil_ used in testing the interactive backends +- pytest-cov_ (>= 2.3.1) to collect coverage information - pytest-flake8_ to test coding standards using flake8_ - pytest-timeout_ to limit runtime in case of stuck tests - pytest-xdist_ to run tests in parallel +- pytest-xvfb_ to run tests without windows popping up (Linux) +- pytz_ used to test pytz int +- sphinx_ used to test our sphinx extensions +- WenQuanYi Zen Hei and `Noto Sans CJK `_ + fonts for testing font fallback and non-western fonts +- xarray_ used to test compatibility with xarray -.. _pytest: http://doc.pytest.org/en/latest/ -.. _Ghostscript: https://www.ghostscript.com/ +If any of these dependencies are not discovered the tests that rely on them +will be skipped by pytest. + +.. note:: + + When installing Inkscape on Windows, make sure that you select “Add + Inkscape to system PATH”, either for all users or current user, or the + tests will not find it. + +.. _Ghostscript: https://ghostscript.com/ .. _Inkscape: https://inkscape.org +.. _flake8: https://pypi.org/project/flake8/ +.. _nbconvert: https://pypi.org/project/nbconvert/ +.. _nbformat: https://pypi.org/project/nbformat/ +.. _pandas: https://pypi.org/project/pandas/ +.. _pikepdf: https://pypi.org/project/pikepdf/ +.. _psutil: https://pypi.org/project/psutil/ +.. _pytz: https://fonts.google.com/noto/use#faq .. _pytest-cov: https://pytest-cov.readthedocs.io/en/latest/ .. _pytest-flake8: https://pypi.org/project/pytest-flake8/ -.. _pytest-xdist: https://pypi.org/project/pytest-xdist/ .. _pytest-timeout: https://pypi.org/project/pytest-timeout/ -.. _flake8: https://pypi.org/project/flake8/ +.. _pytest-xdist: https://pypi.org/project/pytest-xdist/ +.. _pytest-xvfb: https://pypi.org/project/pytest-xvfb/ +.. _pytest: http://doc.pytest.org/en/latest/ +.. _sphinx: https://pypi.org/project/Sphinx/ +.. _xarray: https://pypi.org/project/xarray/ .. _doc-dependencies: -Additional dependencies for building documentation -================================================== +Dependencies for building Matplotlib's documentation +==================================================== Python packages --------------- @@ -222,9 +307,10 @@ Additional external dependencies -------------------------------- Required: -* a minimal working LaTeX distribution +* a minimal working LaTeX distribution, e.g., `TeX Live `_ or + `MikTeX `_ * `Graphviz `_ -* the following LaTeX packages (if your OS bundles TeXLive, the +* the following LaTeX packages (if your OS bundles TeX Live, the "complete" version of the installer, e.g. "texlive-full" or "texlive-all", will often automatically include these packages): diff --git a/doc/devel/development_setup.rst b/doc/devel/development_setup.rst index 468a7add7d75..afcd63b3bf15 100644 --- a/doc/devel/development_setup.rst +++ b/doc/devel/development_setup.rst @@ -1,49 +1,127 @@ +.. redirect-from:: /devel/gitwash/configure_git +.. redirect-from:: /devel/gitwash/dot2_dot3 +.. redirect-from:: /devel/gitwash/following_latest +.. redirect-from:: /devel/gitwash/forking_hell +.. redirect-from:: /devel/gitwash/git_development +.. redirect-from:: /devel/gitwash/git_install +.. redirect-from:: /devel/gitwash/git_intro +.. redirect-from:: /devel/gitwash/git_resources +.. redirect-from:: /devel/gitwash/patching +.. redirect-from:: /devel/gitwash/set_up_fork +.. redirect-from:: /devel/gitwash/index + .. _installing_for_devs: ===================================== Setting up Matplotlib for development ===================================== +To set up Matplotlib for development follow these steps: + +.. contents:: + :local: + +Fork the Matplotlib repository +============================== + +Matplotlib is hosted at https://github.com/matplotlib/matplotlib.git. If you +plan on solving issues or submit pull requests to the main Matplotlib +repository, you should first *fork* this repository by visiting +https://github.com/matplotlib/matplotlib.git and clicking on the +``Fork`` button on the top right of the page (see +`the GitHub documentation `__ for more details.) + +Retrieve the latest version of the code +======================================= + +Now that your fork of the repository lives under your GitHub username, you can +retrieve the latest sources with one of the following commands (where your +should replace ```` with your GitHub username): + +.. tab-set:: + + .. tab-item:: https + + .. code-block:: bash + + git clone https://github.com//matplotlib.git + + .. tab-item:: ssh + + .. code-block:: bash + + git clone git@github.com:/matplotlib.git + + This requires you to setup an `SSH key`_ in advance, but saves you from + typing your password at every connection. + + .. _SSH key: https://docs.github.com/en/authentication/connecting-to-github-with-ssh + + +This will place the sources in a directory :file:`matplotlib` below your +current working directory, set up the ``origin`` remote to point to your own +fork, and set up the ``upstream`` remote to point to the Matplotlib main +repository (see also `Managing remote repositories `__.) +Change into this directory before continuing:: + + cd matplotlib + +.. note:: + + For more information on ``git`` and ``GitHub``, check the following resources. + + * `Git documentation `_ + * `GitHub-Contributing to a Project `_ + * `GitHub Skills `_ + * :ref:`using-git` + * :ref:`git-resources` + * `Installing git `_ + * https://tacaswell.github.io/think-like-git.html + * https://tom.preston-werner.com/2009/05/19/the-git-parable.html + .. _dev-environment: -Creating a dedicated environment -================================ +Create a dedicated environment +============================== You should set up a dedicated environment to decouple your Matplotlib development from other Python and Matplotlib installations on your system. -Here we use python's virtual environment `venv`_, but you may also use others -such as conda. + +The simplest way to do this is to use either Python's virtual environment +`venv`_ or `conda`_. .. _venv: https://docs.python.org/3/library/venv.html +.. _conda: https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html -A new environment can be set up with :: +.. tab-set:: - python -m venv + .. tab-item:: venv environment -and activated with one of the following:: + Create a new `venv`_ environment with :: - source /bin/activate # Linux/macOS - \Scripts\activate.bat # Windows cmd.exe - \Scripts\Activate.ps1 # Windows PowerShell + python -m venv -Whenever you plan to work on Matplotlib, remember to activate the development -environment in your shell. + and activate it with one of the following :: -Retrieving the latest version of the code -========================================= + source /bin/activate # Linux/macOS + \Scripts\activate.bat # Windows cmd.exe + \Scripts\Activate.ps1 # Windows PowerShell -Matplotlib is hosted at https://github.com/matplotlib/matplotlib.git. + .. tab-item:: conda environment -You can retrieve the latest sources with the command (see -:ref:`set-up-fork` for more details):: + Create a new `conda`_ environment with :: - git clone https://github.com/matplotlib/matplotlib.git + conda env create -f environment.yml -This will place the sources in a directory :file:`matplotlib` below your -current working directory. + You can use ``mamba`` instead of ``conda`` in the above command if + you have `mamba`_ installed. -If you have the proper privileges, you can use ``git@`` instead of -``https://``, which works through the ssh protocol and might be easier to use -if you are using 2-factor authentication. + .. _mamba: https://mamba.readthedocs.io/en/latest/ + + Activate the environment using :: + + conda activate mpl-dev + +Remember to activate the environment whenever you start working on Matplotlib. Installing Matplotlib in editable mode ====================================== @@ -60,6 +138,20 @@ true for ``*.py`` files. If you change the C-extension source (which might also happen if you change branches) you will have to re-run ``python -m pip install -ve .`` -Installing additional dependencies for development -================================================== -See :ref:`development-dependencies`. +Install pre-commit hooks (optional) +=================================== +`pre-commit `_ hooks automatically check flake8 and +other style issues when you run ``git commit``. The hooks are defined in the +top level ``.pre-commit-config.yaml`` file. To install the hooks :: + + python -m pip install pre-commit + pre-commit install + +The hooks can also be run manually. All the hooks can be run, in order as +listed in ``.pre-commit-config.yaml``, against the full codebase with :: + + pre-commit run --all-files + +To run a particular hook manually, run ``pre-commit run`` with the hook id :: + + pre-commit run --all-files diff --git a/doc/devel/development_workflow.rst b/doc/devel/development_workflow.rst new file mode 100644 index 000000000000..cd902f3f30e5 --- /dev/null +++ b/doc/devel/development_workflow.rst @@ -0,0 +1,430 @@ +.. highlight:: bash + +.. _development-workflow: + +#################### +Development workflow +#################### + +Workflow summary +================ + +To keep your work well organized, with readable history, and in turn make it +easier for project maintainers (that might be you) to see what you've done, and +why you did it, we recommend the following: + +* Don't use your ``main`` branch for anything. Consider deleting it. +* Before starting a new set of changes, fetch all changes from + ``upstream/main``, and start a new *feature branch* from that. +* Make a new branch for each feature or bug fix — "one task, one branch". +* Name your branch for the purpose of the changes - e.g. + ``bugfix-for-issue-14`` or ``refactor-database-code``. +* If you get stuck, reach out on Gitter or + `discourse `__. +* When you're ready or need feedback on your code, open a pull request so that the + Matplotlib developers can give feedback and eventually include your suggested + code into the ``main`` branch. + +.. note:: + + It may sound strange, but deleting your own ``main`` branch can help reduce + confusion about which branch you are on. See `deleting main on GitHub`_ for + details. + +.. _deleting main on GitHub: https://matthew-brett.github.io/pydagogue/gh_delete_master.html + +.. _update-mirror-main: + +Update the ``main`` branch +========================== + +First make sure you have followed :ref:`installing_for_devs`. + +From time to time you should fetch the upstream changes from GitHub:: + + git fetch upstream + +This will pull down any commits you don't have, and set the remote branches to +point to the right commit. + +.. _make-feature-branch: + +Make a new feature branch +========================= + +When you are ready to make some changes to the code, you should start a new +branch. Branches that are for a collection of related edits are often called +'feature branches'. + +Making a new branch for each set of related changes will make it easier for +someone reviewing your branch to see what you are doing. + +Choose an informative name for the branch to remind yourself and the rest of us +what the changes in the branch are for. For example ``add-ability-to-fly``, or +``bugfix-for-issue-42``. + +:: + + # Update the main branch + git fetch upstream + # Make new feature branch starting at current main + git branch my-new-feature upstream/main + git checkout my-new-feature + +Generally, you will want to keep your feature branches on your public GitHub +fork of Matplotlib. To do this, you ``git push`` this new branch up to your +GitHub repo. Generally (if you followed the instructions in these pages, and by +default), git will have a link to your fork of the GitHub repo, called +``origin``. You push up to your own fork with:: + + git push origin my-new-feature + +In git >= 1.7 you can ensure that the link is correctly set by using the +``--set-upstream`` option:: + + git push --set-upstream origin my-new-feature + +From now on git will know that ``my-new-feature`` is related to the +``my-new-feature`` branch in the GitHub repo. + +.. _edit-flow: + +The editing workflow +==================== + +Overview +-------- + +:: + + # hack hack + git add my_new_file + git commit -am 'NF - some message' + git push + +In more detail +-------------- + +#. Make some changes +#. See which files have changed with ``git status``. + You'll see a listing like this one: + + .. code-block:: none + + # On branch ny-new-feature + # Changed but not updated: + # (use "git add ..." to update what will be committed) + # (use "git checkout -- ..." to discard changes in working directory) + # + # modified: README + # + # Untracked files: + # (use "git add ..." to include in what will be committed) + # + # INSTALL + no changes added to commit (use "git add" and/or "git commit -a") + +#. Check what the actual changes are with ``git diff``. +#. Add any new files to version control ``git add new_file_name``. +#. To commit all modified files into the local copy of your repo,, do + ``git commit -am 'A commit message'``. Note the ``-am`` options to + ``commit``. The ``m`` flag just signals that you're going to type a + message on the command line. The ``a`` flag — you can just take on + faith — or see `why the -a flag?`_. The + `git commit `_ manual page might also be + useful. +#. To push the changes up to your forked repo on GitHub, do a ``git + push``. + +.. _why the -a flag?: http://gitready.com/beginner/2009/01/18/the-staging-area.html + + +Open a pull request +=================== + +When you are ready to ask for someone to review your code and consider a merge, +`submit your Pull Request (PR) `_. + +Enter a title for the set of changes with some explanation of what you've done. +Mention anything you'd like particular attention for - such as a +complicated change or some code you are not happy with. + +If you don't think your request is ready to be merged, just say so in your pull +request message and use the "Draft PR" feature of GitHub. This is a good way of +getting some preliminary code review. + +Some other things you might want to do +====================================== + +Explore your repository +----------------------- + +To see a graphical representation of the repository branches and +commits:: + + gitk --all + +To see a linear list of commits for this branch:: + + git log + + +.. _recovering-from-mess-up: + +Recovering from mess-ups +------------------------ + +Sometimes, you mess up merges or rebases. Luckily, in git it is +relatively straightforward to recover from such mistakes. + +If you mess up during a rebase:: + + git rebase --abort + +If you notice you messed up after the rebase:: + + # reset branch back to the saved point + git reset --hard tmp + +If you forgot to make a backup branch:: + + # look at the reflog of the branch + git reflog show cool-feature + + 8630830 cool-feature@{0}: commit: BUG: io: close file handles immediately + 278dd2a cool-feature@{1}: rebase finished: refs/heads/my-feature-branch onto 11ee694744f2552d + 26aa21a cool-feature@{2}: commit: BUG: lib: make seek_gzip_factory not leak gzip obj + ... + + # reset the branch to where it was before the botched rebase + git reset --hard cool-feature@{2} + +.. _rewriting-commit-history: + +Rewriting commit history +------------------------ + +.. note:: + + Do this only for your own feature branches. + +Is there an embarrassing typo in a commit you made? Or perhaps you +made several false starts you don't want posterity to see. + +This can be done via *interactive rebasing*. + +Suppose that the commit history looks like this:: + + git log --oneline + eadc391 Fix some remaining bugs + a815645 Modify it so that it works + 2dec1ac Fix a few bugs + disable + 13d7934 First implementation + 6ad92e5 * masked is now an instance of a new object, MaskedConstant + 29001ed Add pre-nep for a copule of structured_array_extensions. + ... + +and ``6ad92e5`` is the last commit in the ``cool-feature`` branch. Suppose we +want to make the following changes: + +* Rewrite the commit message for ``13d7934`` to something more sensible. +* Combine the commits ``2dec1ac``, ``a815645``, ``eadc391`` into a single one. + +We do as follows:: + + # make a backup of the current state + git branch tmp HEAD + # interactive rebase + git rebase -i 6ad92e5 + +This will open an editor with the following text in it:: + + pick 13d7934 First implementation + pick 2dec1ac Fix a few bugs + disable + pick a815645 Modify it so that it works + pick eadc391 Fix some remaining bugs + + # Rebase 6ad92e5..eadc391 onto 6ad92e5 + # + # Commands: + # p, pick = use commit + # r, reword = use commit, but edit the commit message + # e, edit = use commit, but stop for amending + # s, squash = use commit, but meld into previous commit + # f, fixup = like "squash", but discard this commit's log message + # + # If you remove a line here THAT COMMIT WILL BE LOST. + # However, if you remove everything, the rebase will be aborted. + # + +To achieve what we want, we will make the following changes to it:: + + r 13d7934 First implementation + pick 2dec1ac Fix a few bugs + disable + f a815645 Modify it so that it works + f eadc391 Fix some remaining bugs + +This means that (i) we want to edit the commit message for +``13d7934``, and (ii) collapse the last three commits into one. Now we +save and quit the editor. + +Git will then immediately bring up an editor for editing the commit +message. After revising it, we get the output:: + + [detached HEAD 721fc64] FOO: First implementation + 2 files changed, 199 insertions(+), 66 deletions(-) + [detached HEAD 0f22701] Fix a few bugs + disable + 1 files changed, 79 insertions(+), 61 deletions(-) + Successfully rebased and updated refs/heads/my-feature-branch. + +and now, the history looks like this:: + + 0f22701 Fix a few bugs + disable + 721fc64 ENH: Sophisticated feature + 6ad92e5 * masked is now an instance of a new object, MaskedConstant + +If it went wrong, recovery is again possible as explained :ref:`above +`. + +If you have not yet pushed this branch to github, you can carry on as normal, +however if you *have* already pushed this commit see :ref:`force-push` for how +to replace your already published commits with the new ones. + + +.. _rebase-on-main: + +Rebasing on ``upstream/main`` +----------------------------- + +Let's say you thought of some work you'd like to do. You +:ref:`update-mirror-main` and :ref:`make-feature-branch` called +``cool-feature``. At this stage, ``main`` is at some commit, let's call it E. +Now you make some new commits on your ``cool-feature`` branch, let's call them +A, B, C. Maybe your changes take a while, or you come back to them after a +while. In the meantime, ``main`` has progressed from commit E to commit (say) G: + +.. code-block:: none + + A---B---C cool-feature + / + D---E---F---G main + +At this stage you consider merging ``main`` into your feature branch, and you +remember that this page sternly advises you not to do that, because the +history will get messy. Most of the time, you can just ask for a review without +worrying about whether ``main`` has got a little ahead; however sometimes, the changes in +``main`` might affect your changes, and you need to harmonize them. In this +situation you may prefer to do a rebase. + +``rebase`` takes your changes (A, B, C) and replays them as if they had been +made to the current state of ``main``. In other words, in this case, it takes +the changes represented by A, B, C and replays them on top of G. After the +rebase, your history will look like this: + +.. code-block:: none + + A'--B'--C' cool-feature + / + D---E---F---G main + +See `rebase without tears`_ for more detail. + +.. _rebase without tears: https://matthew-brett.github.io/pydagogue/rebase_without_tears.html + +To do a rebase on ``upstream/main``:: + + # Fetch changes from upstream/main + git fetch upstream + # go to the feature branch + git checkout cool-feature + # make a backup in case you mess up + git branch tmp cool-feature + # rebase cool-feature onto main + git rebase --onto upstream/main upstream/main cool-feature + +In this situation, where you are already on branch ``cool-feature``, the last +command can be written more succinctly as:: + + git rebase upstream/main + +When all looks good, you can delete your backup branch:: + + git branch -D tmp + +If it doesn't look good you may need to have a look at +:ref:`recovering-from-mess-up`. + +If you have made changes to files that have also changed in ``main``, this may +generate merge conflicts that you need to resolve - see the `git rebase`_ man +page for some instructions at the end of the "Description" section. There is +some related help on merging in the git user manual - see `resolving a merge`_. + +.. _git rebase: https://git-scm.com/docs/git-rebase +.. _resolving a merge: https://schacon.github.io/git/user-manual.html#resolving-a-merge + + +If you have not yet pushed this branch to github, you can carry on as normal, +however if you *have* already pushed this commit see :ref:`force-push` for how +to replace your already published commits with the new ones. + + +.. _force-push: + + +Pushing, with force +------------------- + + +If you have in some way re-written already pushed history (e.g. via +:ref:`rewriting-commit-history` or :ref:`rebase-on-main`) leaving you with +a git history that looks something like + +.. code-block:: none + + A'--E cool-feature + / + D---A---B---C origin/cool-feature + +where you have pushed the commits ``A,B,C`` to your fork on GitHub (under the +remote name *origin*) but now have the commits ``A'`` and ``E`` on your local +branch *cool-feature*. If you try to push the new commits to GitHub, it will +fail and show an error that looks like :: + + $ git push + Pushing to github.com:origin/matplotlib.git + To github.com:origin/matplotlib.git + ! [rejected] cool_feature -> cool_feature (non-fast-forward) + error: failed to push some refs to 'github.com:origin/matplotlib.git' + hint: Updates were rejected because the tip of your current branch is behind + hint: its remote counterpart. Integrate the remote changes (e.g. + hint: 'git pull ...') before pushing again. + hint: See the 'Note about fast-forwards' in 'git push --help' for details. + +If this push had succeeded, the commits ``A``, ``B``, and ``C`` would no +longer be referenced by any branch and they would be discarded: + +.. code-block:: none + + D---A'---E cool-feature, origin/cool-feature + +By default ``git push`` helpfully tries to protect you from accidentally +discarding commits by rejecting the push to the remote. When this happens, +GitHub also adds the helpful suggestion to pull the remote changes and then try +pushing again. In some cases, such as if you and a colleague are both +committing and pushing to the same branch, this is a correct course of action. + +However, in the case of having intentionally re-written history, we *want* to +discard the commits on the remote and replace them with the new-and-improved +versions from our local branch. In this case, what we want to do is :: + + $ git push --force-with-lease + +which tells git you are aware of the risks and want to do the push anyway. We +recommend using ``--force-with-lease`` over the ``--force`` flag. The +``--force`` will do the push no matter what, whereas ``--force-with-lease`` +will only do the push if the remote branch is where the local ``git`` client +thought it was. + +Be judicious with force-pushing. It is effectively re-writing published +history, and if anyone has fetched the old commits, it will have a different view +of history which can cause confusion. diff --git a/doc/devel/documenting_mpl.rst b/doc/devel/documenting_mpl.rst index beed5b6549a7..b814e67cd308 100644 --- a/doc/devel/documenting_mpl.rst +++ b/doc/devel/documenting_mpl.rst @@ -67,6 +67,10 @@ Other useful invocations include .. code-block:: sh + # Build the html documentation, but skip generation of the gallery images to + # save time. + make html-noplot + # Delete built files. May help if you get errors about missing paths or # broken links. make clean @@ -82,27 +86,19 @@ it, use make SPHINXOPTS= html -On Windows the arguments must be at the end of the statement: - -.. code-block:: bat - - make html SPHINXOPTS= - You can use the ``O`` variable to set additional options: * ``make O=-j4 html`` runs a parallel build with 4 processes. * ``make O=-Dplot_formats=png:100 html`` saves figures in low resolution. -* ``make O=-Dplot_gallery=0 html`` skips the gallery build. -Multiple options can be combined using e.g. ``make O='-j4 -Dplot_gallery=0' +Multiple options can be combined, e.g. ``make O='-j4 -Dplot_formats=png:100' html``. -On Windows, either use the format shown above or set options as environment variables, e.g.: +On Windows, set the options as environment variables, e.g.: .. code-block:: bat - set O=-W --keep-going -j4 - make html + set SPHINXOPTS= & set O=-j4 -Dplot_formats=png:100 & make html Showing locally built docs -------------------------- @@ -199,7 +195,7 @@ Documents can be linked with the ``:doc:`` directive: See the :doc:`/users/installing/index` - See the tutorial :doc:`/tutorials/introductory/usage` + See the tutorial :doc:`/tutorials/introductory/quick_start` See the example :doc:`/gallery/lines_bars_and_markers/simple_plot` @@ -207,7 +203,7 @@ will render as: See the :doc:`/users/installing/index` - See the tutorial :doc:`/tutorials/introductory/usage` + See the tutorial :doc:`/tutorials/introductory/quick_start` See the example :doc:`/gallery/lines_bars_and_markers/simple_plot` @@ -269,7 +265,7 @@ generates a link like this: `matplotlib.collections.LineCollection`. have to use qualifiers like ``:class:``, ``:func:``, ``:meth:`` and the likes. Often, you don't want to show the full package and module name. As long as the -target is unanbigous you can simply leave them out: +target is unambiguous you can simply leave them out: .. code-block:: rst @@ -368,7 +364,7 @@ An example docstring looks like: .. code-block:: python - def hlines(self, y, xmin, xmax, colors='k', linestyles='solid', + def hlines(self, y, xmin, xmax, colors=None, linestyles='solid', label='', **kwargs): """ Plot horizontal lines at each *y* from *xmin* to *xmax*. @@ -382,24 +378,26 @@ An example docstring looks like: Respective beginning and end of each line. If scalars are provided, all lines will have the same length. - colors : array-like of colors, default: 'k' + colors : list of colors, default: :rc:`lines.color` - linestyles : {'solid', 'dashed', 'dashdot', 'dotted'}, default: 'solid' + linestyles : {'solid', 'dashed', 'dashdot', 'dotted'}, optional label : str, default: '' Returns ------- - lines : `~matplotlib.collections.LineCollection` + `~matplotlib.collections.LineCollection` Other Parameters ---------------- - **kwargs : `~matplotlib.collections.LineCollection` properties + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + **kwargs : `~matplotlib.collections.LineCollection` properties. - See also + See Also -------- vlines : vertical lines - axhline: horizontal line across the axes + axhline : horizontal line across the Axes """ See the `~.Axes.hlines` documentation for how this renders. @@ -566,10 +564,10 @@ effect. Sphinx automatically links code elements in the definition blocks of ``See also`` sections. No need to use backticks there:: - See also + See Also -------- vlines : vertical lines - axhline: horizontal line across the axes + axhline : horizontal line across the Axes Wrapping parameter lists ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -673,7 +671,7 @@ Keyword arguments Since Matplotlib uses a lot of pass-through ``kwargs``, e.g., in every function that creates a line (`~.pyplot.plot`, `~.pyplot.semilogx`, `~.pyplot.semilogy`, -etc...), it can be difficult for the new user to know which ``kwargs`` are +etc.), it can be difficult for the new user to know which ``kwargs`` are supported. Matplotlib uses a docstring interpolation scheme to support documentation of every function that takes a ``**kwargs``. The requirements are: @@ -684,14 +682,14 @@ are: 2. as automated as possible so that as properties change, the docs are updated automatically. -The ``@docstring.interpd`` decorator implements this. Any function accepting +The ``@_docstring.interpd`` decorator implements this. Any function accepting `.Line2D` pass-through ``kwargs``, e.g., `matplotlib.axes.Axes.plot`, can list a summary of the `.Line2D` properties, as follows: .. code-block:: python # in axes.py - @docstring.interpd + @_docstring.interpd def plot(self, *args, **kwargs): """ Some stuff omitted @@ -726,7 +724,7 @@ gets interpolated into the docstring. Note that this scheme does not work for decorating an Artist's ``__init__``, as the subclass and its properties are not defined yet at that point. Instead, -``@docstring.interpd`` can be used to decorate the class itself -- at that +``@_docstring.interpd`` can be used to decorate the class itself -- at that point, `.kwdoc` can list the properties and interpolate them into ``__init__.__doc__``. @@ -847,7 +845,7 @@ render as comments in :doc:`/gallery/lines_bars_and_markers/simple_plot`. Tutorials are made with the exact same mechanism, except they are longer, and typically have more than one comment block (i.e. -:doc:`/tutorials/introductory/usage`). The first comment block +:doc:`/tutorials/introductory/quick_start`). The first comment block can be the same as the example above. Subsequent blocks of ReST text are delimited by a line of ``###`` characters: @@ -963,41 +961,6 @@ found by users at ``http://matplotlib.org/stable/old_topic/old_info2``. For clarity, do not use relative links. -Adding animations ------------------ - -Animations are scraped automatically by Sphinx-gallery. If this is not -desired, -there is also a Matplotlib Google/Gmail account with username ``mplgithub`` -which was used to setup the github account but can be used for other -purposes, like hosting Google docs or Youtube videos. You can embed a -Matplotlib animation in the docs by first saving the animation as a -movie using :meth:`matplotlib.animation.Animation.save`, and then -uploading to `Matplotlib's Youtube -channel `_ and inserting the -embedding string youtube provides like: - -.. code-block:: rst - - .. raw:: html - - - -An example save command to generate a movie looks like this - -.. code-block:: python - - ani = animation.FuncAnimation(fig, animate, np.arange(1, len(y)), - interval=25, blit=True, init_func=init) - - ani.save('double_pendulum.mp4', fps=15) - -Contact Michael Droettboom for the login password to upload youtube videos of -google docs to the mplgithub account. - .. _inheritance-diagrams: Generating inheritance diagrams diff --git a/doc/devel/gitwash/branch_dropdown.png b/doc/devel/gitwash/branch_dropdown.png deleted file mode 100644 index 1bb7a577732c..000000000000 Binary files a/doc/devel/gitwash/branch_dropdown.png and /dev/null differ diff --git a/doc/devel/gitwash/configure_git.rst b/doc/devel/gitwash/configure_git.rst deleted file mode 100644 index eb7ce2662c93..000000000000 --- a/doc/devel/gitwash/configure_git.rst +++ /dev/null @@ -1,172 +0,0 @@ -.. highlight:: bash - -.. _configure-git: - -=============== - Configure git -=============== - -.. _git-config-basic: - -Overview -======== - -Your personal git configurations are saved in the ``.gitconfig`` file in -your home directory. - -Here is an example ``.gitconfig`` file: - -.. code-block:: none - - [user] - name = Your Name - email = you@yourdomain.example.com - - [alias] - ci = commit -a - co = checkout - st = status - stat = status - br = branch - wdiff = diff --color-words - - [core] - editor = vim - - [merge] - summary = true - -You can check what is already in your config file using the ``git config --list`` command. You can edit the ``.gitconfig`` file directly or you can use the ``git config --global`` -command.:: - - git config --global user.name "Your Name" - git config --global user.email you@yourdomain.example.com - git config --global alias.ci "commit -a" - git config --global alias.co checkout - git config --global alias.st "status -a" - git config --global alias.stat "status -a" - git config --global alias.br branch - git config --global alias.wdiff "diff --color-words" - git config --global core.editor vim - git config --global merge.summary true - -To set up on another computer, you can copy your ``~/.gitconfig`` file, -or run the commands above. - -In detail -========= - -user.name and user.email ------------------------- - -It is good practice to tell git_ who you are, for labeling any changes -you make to the code. The simplest way to do this is from the command -line:: - - git config --global user.name "Your Name" - git config --global user.email you@yourdomain.example.com - -This will write the settings into your git configuration file, which -should now contain a user section with your name and email: - -.. code-block:: none - - [user] - name = Your Name - email = you@yourdomain.example.com - -You'll need to replace ``Your Name`` and ``you@yourdomain.example.com`` -with your actual name and email address. - -Aliases -------- - -You might well benefit from some aliases to common commands. - -For example, you might well want to be able to shorten ``git checkout`` -to ``git co``. Or you may want to alias ``git diff --color-words`` -(which gives a nicely formatted output of the diff) to ``git wdiff`` - -The following ``git config --global`` commands:: - - git config --global alias.ci "commit -a" - git config --global alias.co checkout - git config --global alias.st "status -a" - git config --global alias.stat "status -a" - git config --global alias.br branch - git config --global alias.wdiff "diff --color-words" - -will create an ``alias`` section in your ``.gitconfig`` file with contents -like this: - -.. code-block:: none - - [alias] - ci = commit -a - co = checkout - st = status -a - stat = status -a - br = branch - wdiff = diff --color-words - -Editor ------- - -You may also want to make sure that your editor of choice is used :: - - git config --global core.editor vim - -Merging -------- - -To enforce summaries when doing merges (``~/.gitconfig`` file again): - -.. code-block:: none - - [merge] - log = true - -Or from the command line:: - - git config --global merge.log true - -.. _fancy-log: - -Fancy log output ----------------- - -This is a very nice alias to get a fancy log output; it should go in the -``alias`` section of your ``.gitconfig`` file: - -.. code-block:: none - - lg = log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)[%an]%Creset' --abbrev-commit --date=relative - -You use the alias with:: - - git lg - -and it gives graph / text output something like this (but with color!): - -.. code-block:: none - - * 6d8e1ee - (HEAD, origin/my-fancy-feature, my-fancy-feature) NF - a fancy file (45 minutes ago) [Matthew Brett] - * d304a73 - (origin/placeholder, placeholder) Merge pull request #48 from hhuuggoo/master (2 weeks ago) [Jonathan Terhorst] - |\ - | * 4aff2a8 - fixed bug 35, and added a test in test_bugfixes (2 weeks ago) [Hugo] - |/ - * a7ff2e5 - Added notes on discussion/proposal made during Data Array Summit. (2 weeks ago) [Corran Webster] - * 68f6752 - Initial implementation of AxisIndexer - uses 'index_by' which needs to be changed to a call on an Axes object - this is all very sketchy right now. (2 weeks ago) [Corr - * 376adbd - Merge pull request #46 from terhorst/master (2 weeks ago) [Jonathan Terhorst] - |\ - | * b605216 - updated joshu example to current api (3 weeks ago) [Jonathan Terhorst] - | * 2e991e8 - add testing for outer ufunc (3 weeks ago) [Jonathan Terhorst] - | * 7beda5a - prevent axis from throwing an exception if testing equality with non-axis object (3 weeks ago) [Jonathan Terhorst] - | * 65af65e - convert unit testing code to assertions (3 weeks ago) [Jonathan Terhorst] - | * 956fbab - Merge remote-tracking branch 'upstream/master' (3 weeks ago) [Jonathan Terhorst] - | |\ - | |/ - -Thanks to Yury V. Zaytsev for posting it. - -.. include:: links.inc diff --git a/doc/devel/gitwash/development_workflow.rst b/doc/devel/gitwash/development_workflow.rst deleted file mode 100644 index 9ac5c05e2b16..000000000000 --- a/doc/devel/gitwash/development_workflow.rst +++ /dev/null @@ -1,423 +0,0 @@ -.. highlight:: bash - -.. _development-workflow: - -#################### -Development workflow -#################### - -You already have your own forked copy of the `Matplotlib`_ repository, by -following :ref:`forking`. You have :ref:`set-up-fork`. You have configured -git by following :ref:`configure-git`. Now you are ready for some real work. - -Workflow summary -================ - -In what follows we'll refer to the upstream Matplotlib ``master`` branch, as -"trunk". - -* Don't use your ``master`` branch for anything. Consider deleting it. -* When you are starting a new set of changes, fetch any changes from trunk, - and start a new *feature branch* from that. -* Make a new branch for each separable set of changes |emdash| "one task, one - branch" (`ipython git workflow`_). -* Name your branch for the purpose of the changes - e.g. - ``bugfix-for-issue-14`` or ``refactor-database-code``. -* If you can possibly avoid it, avoid merging trunk or any other branches into - your feature branch while you are working. -* If you do find yourself merging from trunk, consider :ref:`rebase-on-trunk` -* Ask on the `Matplotlib mailing list`_ if you get stuck. -* Ask for code review! - -This way of working helps to keep work well organized, with readable history. -This in turn makes it easier for project maintainers (that might be you) to see -what you've done, and why you did it. - -See `linux git workflow`_ and `ipython git workflow`_ for some explanation. - -Consider deleting your master branch -==================================== - -It may sound strange, but deleting your own ``master`` branch can help reduce -confusion about which branch you are on. See `deleting master on github`_ for -details. - -.. _update-mirror-trunk: - -Update the mirror of trunk -========================== - -First make sure you have done :ref:`linking-to-upstream`. - -From time to time you should fetch the upstream (trunk) changes from github:: - - git fetch upstream - -This will pull down any commits you don't have, and set the remote branches to -point to the right commit. For example, 'trunk' is the branch referred to by -(remote/branchname) ``upstream/master`` - and if there have been commits since -you last checked, ``upstream/master`` will change after you do the fetch. - -.. _make-feature-branch: - -Make a new feature branch -========================= - -When you are ready to make some changes to the code, you should start a new -branch. Branches that are for a collection of related edits are often called -'feature branches'. - -Making an new branch for each set of related changes will make it easier for -someone reviewing your branch to see what you are doing. - -Choose an informative name for the branch to remind yourself and the rest of us -what the changes in the branch are for. For example ``add-ability-to-fly``, or -``buxfix-for-issue-42``. - -:: - - # Update the mirror of trunk - git fetch upstream - # Make new feature branch starting at current trunk - git branch my-new-feature upstream/master - git checkout my-new-feature - -Generally, you will want to keep your feature branches on your public github_ -fork of `Matplotlib`_. To do this, you `git push`_ this new branch up to your -github repo. Generally (if you followed the instructions in these pages, and by -default), git will have a link to your github repo, called ``origin``. You push -up to your own repo on github with:: - - git push origin my-new-feature - -In git >= 1.7 you can ensure that the link is correctly set by using the -``--set-upstream`` option:: - - git push --set-upstream origin my-new-feature - -From now on git will know that ``my-new-feature`` is related to the -``my-new-feature`` branch in the github repo. - -.. _edit-flow: - -The editing workflow -==================== - -Overview --------- - -:: - - # hack hack - git add my_new_file - git commit -am 'NF - some message' - git push - -In more detail --------------- - -#. Make some changes -#. See which files have changed with ``git status`` (see `git status`_). - You'll see a listing like this one: - - .. code-block:: none - - # On branch ny-new-feature - # Changed but not updated: - # (use "git add ..." to update what will be committed) - # (use "git checkout -- ..." to discard changes in working directory) - # - # modified: README - # - # Untracked files: - # (use "git add ..." to include in what will be committed) - # - # INSTALL - no changes added to commit (use "git add" and/or "git commit -a") - -#. Check what the actual changes are with ``git diff`` (`git diff`_). -#. Add any new files to version control ``git add new_file_name`` (see - `git add`_). -#. To commit all modified files into the local copy of your repo,, do - ``git commit -am 'A commit message'``. Note the ``-am`` options to - ``commit``. The ``m`` flag just signals that you're going to type a - message on the command line. The ``a`` flag |emdash| you can just take on - faith |emdash| or see `why the -a flag?`_ |emdash| and the helpful use-case - description in the `tangled working copy problem`_. The `git commit`_ manual - page might also be useful. -#. To push the changes up to your forked repo on github, do a ``git - push`` (see `git push`_). - -Ask for your changes to be reviewed or merged -============================================= - -When you are ready to ask for someone to review your code and consider a merge: - -#. Go to the URL of your forked repo, say - ``https://github.com/your-user-name/matplotlib``. -#. Use the 'Switch Branches' dropdown menu near the top left of the page to - select the branch with your changes: - - .. image:: branch_dropdown.png - -#. Click on the 'Pull request' button: - - .. image:: pull_button.png - - Enter a title for the set of changes, and some explanation of what you've - done. Say if there is anything you'd like particular attention for - like a - complicated change or some code you are not happy with. - - If you don't think your request is ready to be merged, just say so in your - pull request message. This is still a good way of getting some preliminary - code review. - -Some other things you might want to do -====================================== - -Delete a branch on github -------------------------- - -:: - - git checkout master - # delete branch locally - git branch -D my-unwanted-branch - # delete branch on github - git push origin :my-unwanted-branch - -Note the colon ``:`` before ``my-unwanted-branch``. See also: -https://help.github.com/articles/pushing-to-a-remote/#deleting-a-remote-branch-or-tag - -Several people sharing a single repository ------------------------------------------- - -If you want to work on some stuff with other people, where you are all -committing into the same repository, or even the same branch, then just -share it via github. - -First fork Matplotlib into your account, as from :ref:`forking`. - -Then, go to your forked repository github page, say -``https://github.com/your-user-name/matplotlib`` - -Click on the 'Admin' button, and add anyone else to the repo as a -collaborator: - - .. image:: pull_button.png - -Now all those people can do:: - - git clone https://github.com/your-user-name/matplotlib.git - -Remember that links starting with ``https`` or ``git@`` are read-write, and that -``git@`` uses the ssh protocol. - -Your collaborators can then commit directly into that repo with the -usual:: - - git commit -am 'ENH - much better code' - git push origin master # pushes directly into your repo - -Explore your repository ------------------------ - -To see a graphical representation of the repository branches and -commits:: - - gitk --all - -To see a linear list of commits for this branch:: - - git log - -You can also look at the `network graph visualizer`_ for your github -repo. - -Finally the :ref:`fancy-log` ``lg`` alias will give you a reasonable text-based -graph of the repository. - -.. _rebase-on-trunk: - -Rebasing on trunk ------------------ - -Let's say you thought of some work you'd like to do. You -:ref:`update-mirror-trunk` and :ref:`make-feature-branch` called -``cool-feature``. At this stage trunk is at some commit, let's call it E. Now -you make some new commits on your ``cool-feature`` branch, let's call them A, B, -C. Maybe your changes take a while, or you come back to them after a while. In -the meantime, trunk has progressed from commit E to commit (say) G: - -.. code-block:: none - - A---B---C cool-feature - / - D---E---F---G trunk - -At this stage you consider merging trunk into your feature branch, and you -remember that this here page sternly advises you not to do that, because the -history will get messy. Most of the time you can just ask for a review, and not -worry that trunk has got a little ahead. But sometimes, the changes in trunk -might affect your changes, and you need to harmonize them. In this situation -you may prefer to do a rebase. - -rebase takes your changes (A, B, C) and replays them as if they had been made to -the current state of ``trunk``. In other words, in this case, it takes the -changes represented by A, B, C and replays them on top of G. After the rebase, -your history will look like this: - -.. code-block:: none - - A'--B'--C' cool-feature - / - D---E---F---G trunk - -See `rebase without tears`_ for more detail. - -To do a rebase on trunk:: - - # Update the mirror of trunk - git fetch upstream - # go to the feature branch - git checkout cool-feature - # make a backup in case you mess up - git branch tmp cool-feature - # rebase cool-feature onto trunk - git rebase --onto upstream/master upstream/master cool-feature - -In this situation, where you are already on branch ``cool-feature``, the last -command can be written more succinctly as:: - - git rebase upstream/master - -When all looks good you can delete your backup branch:: - - git branch -D tmp - -If it doesn't look good you may need to have a look at -:ref:`recovering-from-mess-up`. - -If you have made changes to files that have also changed in trunk, this may -generate merge conflicts that you need to resolve - see the `git rebase`_ man -page for some instructions at the end of the "Description" section. There is -some related help on merging in the git user manual - see `resolving a merge`_. - -.. _recovering-from-mess-up: - -Recovering from mess-ups ------------------------- - -Sometimes, you mess up merges or rebases. Luckily, in git it is -relatively straightforward to recover from such mistakes. - -If you mess up during a rebase:: - - git rebase --abort - -If you notice you messed up after the rebase:: - - # reset branch back to the saved point - git reset --hard tmp - -If you forgot to make a backup branch:: - - # look at the reflog of the branch - git reflog show cool-feature - - 8630830 cool-feature@{0}: commit: BUG: io: close file handles immediately - 278dd2a cool-feature@{1}: rebase finished: refs/heads/my-feature-branch onto 11ee694744f2552d - 26aa21a cool-feature@{2}: commit: BUG: lib: make seek_gzip_factory not leak gzip obj - ... - - # reset the branch to where it was before the botched rebase - git reset --hard cool-feature@{2} - -.. _rewriting-commit-history: - -Rewriting commit history ------------------------- - -.. note:: - - Do this only for your own feature branches. - -There's an embarrassing typo in a commit you made? Or perhaps the you -made several false starts you would like the posterity not to see. - -This can be done via *interactive rebasing*. - -Suppose that the commit history looks like this:: - - git log --oneline - eadc391 Fix some remaining bugs - a815645 Modify it so that it works - 2dec1ac Fix a few bugs + disable - 13d7934 First implementation - 6ad92e5 * masked is now an instance of a new object, MaskedConstant - 29001ed Add pre-nep for a copule of structured_array_extensions. - ... - -and ``6ad92e5`` is the last commit in the ``cool-feature`` branch. Suppose we -want to make the following changes: - -* Rewrite the commit message for ``13d7934`` to something more sensible. -* Combine the commits ``2dec1ac``, ``a815645``, ``eadc391`` into a single one. - -We do as follows:: - - # make a backup of the current state - git branch tmp HEAD - # interactive rebase - git rebase -i 6ad92e5 - -This will open an editor with the following text in it:: - - pick 13d7934 First implementation - pick 2dec1ac Fix a few bugs + disable - pick a815645 Modify it so that it works - pick eadc391 Fix some remaining bugs - - # Rebase 6ad92e5..eadc391 onto 6ad92e5 - # - # Commands: - # p, pick = use commit - # r, reword = use commit, but edit the commit message - # e, edit = use commit, but stop for amending - # s, squash = use commit, but meld into previous commit - # f, fixup = like "squash", but discard this commit's log message - # - # If you remove a line here THAT COMMIT WILL BE LOST. - # However, if you remove everything, the rebase will be aborted. - # - -To achieve what we want, we will make the following changes to it:: - - r 13d7934 First implementation - pick 2dec1ac Fix a few bugs + disable - f a815645 Modify it so that it works - f eadc391 Fix some remaining bugs - -This means that (i) we want to edit the commit message for -``13d7934``, and (ii) collapse the last three commits into one. Now we -save and quit the editor. - -Git will then immediately bring up an editor for editing the commit -message. After revising it, we get the output:: - - [detached HEAD 721fc64] FOO: First implementation - 2 files changed, 199 insertions(+), 66 deletions(-) - [detached HEAD 0f22701] Fix a few bugs + disable - 1 files changed, 79 insertions(+), 61 deletions(-) - Successfully rebased and updated refs/heads/my-feature-branch. - -and the history looks now like this:: - - 0f22701 Fix a few bugs + disable - 721fc64 ENH: Sophisticated feature - 6ad92e5 * masked is now an instance of a new object, MaskedConstant - -If it went wrong, recovery is again possible as explained :ref:`above -`. - -.. include:: links.inc diff --git a/doc/devel/gitwash/dot2_dot3.rst b/doc/devel/gitwash/dot2_dot3.rst deleted file mode 100644 index 7759e2e60d68..000000000000 --- a/doc/devel/gitwash/dot2_dot3.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. _dot2-dot3: - -======================================== - Two and three dots in difference specs -======================================== - -Thanks to Yarik Halchenko for this explanation. - -Imagine a series of commits A, B, C, D... Imagine that there are two -branches, *topic* and *master*. You branched *topic* off *master* when -*master* was at commit 'E'. The graph of the commits looks like this:: - - - A---B---C topic - / - D---E---F---G master - -Then:: - - git diff master..topic - -will output the difference from G to C (i.e. with effects of F and G), -while:: - - git diff master...topic - -would output just differences in the topic branch (i.e. only A, B, and -C). diff --git a/doc/devel/gitwash/following_latest.rst b/doc/devel/gitwash/following_latest.rst deleted file mode 100644 index 5c90f5b84446..000000000000 --- a/doc/devel/gitwash/following_latest.rst +++ /dev/null @@ -1,38 +0,0 @@ -.. highlight:: bash - -.. _following-latest: - -============================= - Following the latest source -============================= - -These are the instructions if you just want to follow the latest -*Matplotlib* source, but you don't need to do any development for now. - -The steps are: - -* :ref:`install-git` -* get local copy of the `Matplotlib github`_ git repository -* update local copy from time to time - -Get the local copy of the code -============================== - -From the command line:: - - git clone https://github.com/matplotlib/matplotlib.git - -You now have a copy of the code tree in the new ``matplotlib`` directory. - -Updating the code -================= - -From time to time you may want to pull down the latest code. Do this with:: - - cd matplotlib - git pull - -The tree in ``matplotlib`` will now have the latest changes from the initial -repository. - -.. include:: links.inc diff --git a/doc/devel/gitwash/forking_button.png b/doc/devel/gitwash/forking_button.png deleted file mode 100644 index d0e04134d4d0..000000000000 Binary files a/doc/devel/gitwash/forking_button.png and /dev/null differ diff --git a/doc/devel/gitwash/forking_hell.rst b/doc/devel/gitwash/forking_hell.rst deleted file mode 100644 index b79e13400a62..000000000000 --- a/doc/devel/gitwash/forking_hell.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. highlight:: bash - -.. _forking: - -====================================================== -Making your own copy (fork) of Matplotlib -====================================================== - -You need to do this only once. The instructions here are very similar -to the instructions at https://help.github.com/forking/ |emdash| please see -that page for more detail. We're repeating some of it here just to give the -specifics for the `Matplotlib`_ project, and to suggest some default names. - -Set up and configure a github account -===================================== - -If you don't have a github account, go to the github page, and make one. - -You then need to configure your account to allow write access |emdash| see -the ``Generating SSH keys`` help on `github help`_. - -Create your own forked copy of `Matplotlib`_ -====================================================== - -#. Log into your github account. -#. Go to the `Matplotlib`_ github home at `Matplotlib github`_. -#. Click on the *fork* button: - - .. image:: forking_button.png - - Now, after a short pause, you should find yourself at the home page for - your own forked copy of `Matplotlib`_. - -.. include:: links.inc diff --git a/doc/devel/gitwash/git_development.rst b/doc/devel/gitwash/git_development.rst deleted file mode 100644 index c5b910d86342..000000000000 --- a/doc/devel/gitwash/git_development.rst +++ /dev/null @@ -1,16 +0,0 @@ -.. _git-development: - -===================== - Git for development -===================== - -Contents: - -.. toctree:: - :maxdepth: 2 - - forking_hell - set_up_fork - configure_git - development_workflow - maintainer_workflow diff --git a/doc/devel/gitwash/git_install.rst b/doc/devel/gitwash/git_install.rst deleted file mode 100644 index 66eca8c29bde..000000000000 --- a/doc/devel/gitwash/git_install.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. highlight:: bash - -.. _install-git: - -============= - Install git -============= - -Overview -======== - -================ ============= -Debian / Ubuntu ``sudo apt-get install git`` -Fedora ``sudo yum install git`` -Windows Download and install msysGit_ -OS X Use the git-osx-installer_ -================ ============= - -In detail -========= - -See the git page for the most recent information. - -Have a look at the github install help pages available from `github help`_ - -There are good instructions here: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git - -.. include:: links.inc diff --git a/doc/devel/gitwash/git_intro.rst b/doc/devel/gitwash/git_intro.rst deleted file mode 100644 index 1f89b7e9fb51..000000000000 --- a/doc/devel/gitwash/git_intro.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. highlight:: bash - -============== - Introduction -============== - -These pages describe a git_ and github_ workflow for the `Matplotlib`_ -project. - -There are several different workflows here, for different ways of -working with *Matplotlib*. - -This is not a comprehensive git reference, it's just a workflow for our -own project. It's tailored to the github hosting service. You may well -find better or quicker ways of getting stuff done with git, but these -should get you started. - -For general resources for learning git, see :ref:`git-resources`. - -.. include:: links.inc diff --git a/doc/devel/gitwash/git_links.inc b/doc/devel/gitwash/git_links.inc deleted file mode 100644 index c26173367c9b..000000000000 --- a/doc/devel/gitwash/git_links.inc +++ /dev/null @@ -1,59 +0,0 @@ -.. This (-*- rst -*-) format file contains commonly used link targets - and name substitutions. It may be included in many files, - therefore it should only contain link targets and name - substitutions. Try grepping for "^\.\. _" to find plausible - candidates for this list. - -.. NOTE: reST targets are - __not_case_sensitive__, so only one target definition is needed for - nipy, NIPY, Nipy, etc... - -.. git stuff -.. _git: https://git-scm.com/ -.. _github: https://github.com -.. _github help: https://help.github.com -.. _msysgit: https://git-scm.com/download/win -.. _git-osx-installer: https://git-scm.com/download/mac -.. _subversion: https://subversion.apache.org/ -.. _git cheat sheet: https://help.github.com/git-cheat-sheets/ -.. _pro git book: https://git-scm.com/book/en/v2 -.. _git svn crash course: https://git-scm.com/course/svn.html -.. _network graph visualizer: https://github.com/blog/39-say-hello-to-the-network-graph-visualizer -.. _git user manual: https://schacon.github.io/git/user-manual.html -.. _git tutorial: https://schacon.github.io/git/gittutorial.html -.. _git community book: https://git-scm.com/book/en/v2 -.. _git ready: http://gitready.com/ -.. _Fernando's git page: http://www.fperez.org/py4science/git.html -.. _git magic: http://www-cs-students.stanford.edu/~blynn/gitmagic/index.html -.. _git concepts: https://www.sbf5.com/~cduan/technical/git/ -.. _git clone: https://schacon.github.io/git/git-clone.html -.. _git checkout: https://schacon.github.io/git/git-checkout.html -.. _git commit: https://schacon.github.io/git/git-commit.html -.. _git push: https://schacon.github.io/git/git-push.html -.. _git pull: https://schacon.github.io/git/git-pull.html -.. _git add: https://schacon.github.io/git/git-add.html -.. _git status: https://schacon.github.io/git/git-status.html -.. _git diff: https://schacon.github.io/git/git-diff.html -.. _git log: https://schacon.github.io/git/git-log.html -.. _git branch: https://schacon.github.io/git/git-branch.html -.. _git remote: https://schacon.github.io/git/git-remote.html -.. _git rebase: https://schacon.github.io/git/git-rebase.html -.. _git config: https://schacon.github.io/git/git-config.html -.. _why the -a flag?: http://gitready.com/beginner/2009/01/18/the-staging-area.html -.. _git staging area: http://gitready.com/beginner/2009/01/18/the-staging-area.html -.. _tangled working copy problem: http://2ndscale.com/rtomayko/2008/the-thing-about-git -.. _git management: https://web.archive.org/web/20090224195437/http://kerneltrap.org/Linux/Git_Management -.. _linux git workflow: https://www.mail-archive.com/dri-devel@lists.sourceforge.net/msg39091.html -.. _git parable: http://tom.preston-werner.com/2009/05/19/the-git-parable.html -.. _git foundation: https://matthew-brett.github.io/pydagogue/foundation.html -.. _deleting master on github: https://matthew-brett.github.io/pydagogue/gh_delete_master.html -.. _rebase without tears: https://matthew-brett.github.io/pydagogue/rebase_without_tears.html -.. _resolving a merge: https://schacon.github.io/git/user-manual.html#resolving-a-merge -.. _ipython git workflow: https://mail.python.org/pipermail/ipython-dev/2010-October/005632.html - -.. other stuff -.. _python: https://www.python.org - -.. |emdash| unicode:: U+02014 - -.. vim: ft=rst diff --git a/doc/devel/gitwash/git_resources.rst b/doc/devel/gitwash/git_resources.rst deleted file mode 100644 index 2787a575cc43..000000000000 --- a/doc/devel/gitwash/git_resources.rst +++ /dev/null @@ -1,59 +0,0 @@ -.. highlight:: bash - -.. _git-resources: - -============= -git resources -============= - -Tutorials and summaries -======================= - -* `github help`_ has an excellent series of how-to guides. -* The `pro git book`_ is a good in-depth book on git. -* A `git cheat sheet`_ is a page giving summaries of common commands. -* The `git user manual`_ -* The `git tutorial`_ -* The `git community book`_ -* `git ready`_ |emdash| a nice series of tutorials -* `git magic`_ |emdash| extended introduction with intermediate detail -* The `git parable`_ is an easy read explaining the concepts behind git. -* `git foundation`_ expands on the `git parable`_. -* Fernando Perez' git page |emdash| `Fernando's git page`_ |emdash| many - links and tips -* A good but technical page on `git concepts`_ -* `git svn crash course`_: git for those of us used to subversion_ - -Advanced git workflow -===================== - -There are many ways of working with git; here are some posts on the -rules of thumb that other projects have come up with: - -* Linus Torvalds on `git management`_ -* Linus Torvalds on `linux git workflow`_ . Summary; use the git tools - to make the history of your edits as clean as possible; merge from - upstream edits as little as possible in branches where you are doing - active development. - -Manual pages online -=================== - -You can get these on your own machine with (e.g) ``git help push`` or -(same thing) ``git push --help``, but, for convenience, here are the -online manual pages for some common commands: - -* `git add`_ -* `git branch`_ -* `git checkout`_ -* `git clone`_ -* `git commit`_ -* `git config`_ -* `git diff`_ -* `git log`_ -* `git pull`_ -* `git push`_ -* `git remote`_ -* `git status`_ - -.. include:: links.inc diff --git a/doc/devel/gitwash/index.rst b/doc/devel/gitwash/index.rst deleted file mode 100644 index 9ee965d626ff..000000000000 --- a/doc/devel/gitwash/index.rst +++ /dev/null @@ -1,19 +0,0 @@ -.. _using-git: - -Working with *Matplotlib* source code -================================================ - -Contents: - -.. toctree:: - :maxdepth: 2 - - git_intro - git_install - following_latest - patching - git_development - git_resources - dot2_dot3 - - diff --git a/doc/devel/gitwash/known_projects.inc b/doc/devel/gitwash/known_projects.inc deleted file mode 100644 index 710abe08e477..000000000000 --- a/doc/devel/gitwash/known_projects.inc +++ /dev/null @@ -1,41 +0,0 @@ -.. Known projects - -.. PROJECTNAME placeholders -.. _PROJECTNAME: http://nipy.org -.. _`PROJECTNAME github`: https://github.com/nipy -.. _`PROJECTNAME mailing list`: https://mail.python.org/mailman/listinfo/neuroimaging - -.. numpy -.. _numpy: http://www.numpy.org -.. _`numpy github`: https://github.com/numpy/numpy -.. _`numpy mailing list`: https://mail.scipy.org/mailman/listinfo/numpy-discussion - -.. scipy -.. _scipy: https://www.scipy.org -.. _`scipy github`: https://github.com/scipy/scipy -.. _`scipy mailing list`: https://mail.scipy.org/mailman/listinfo/scipy-dev - -.. nipy -.. _nipy: http://nipy.org/nipy/ -.. _`nipy github`: https://github.com/nipy/nipy -.. _`nipy mailing list`: https://mail.python.org/mailman/listinfo/neuroimaging - -.. ipython -.. _ipython: https://ipython.org -.. _`ipython github`: https://github.com/ipython/ipython -.. _`ipython mailing list`: https://mail.scipy.org/mailman/listinfo/IPython-dev - -.. dipy -.. _dipy: http://nipy.org/dipy/ -.. _`dipy github`: https://github.com/Garyfallidis/dipy -.. _`dipy mailing list`: https://mail.python.org/mailman/listinfo/neuroimaging - -.. nibabel -.. _nibabel: http://nipy.org/nibabel/ -.. _`nibabel github`: https://github.com/nipy/nibabel -.. _`nibabel mailing list`: https://mail.python.org/mailman/listinfo/neuroimaging - -.. marsbar -.. _marsbar: http://marsbar.sourceforge.net -.. _`marsbar github`: https://github.com/matthew-brett/marsbar -.. _`MarsBaR mailing list`: https://lists.sourceforge.net/lists/listinfo/marsbar-users diff --git a/doc/devel/gitwash/links.inc b/doc/devel/gitwash/links.inc deleted file mode 100644 index 20f4dcfffd4a..000000000000 --- a/doc/devel/gitwash/links.inc +++ /dev/null @@ -1,4 +0,0 @@ -.. compiling links file -.. include:: known_projects.inc -.. include:: this_project.inc -.. include:: git_links.inc diff --git a/doc/devel/gitwash/patching.rst b/doc/devel/gitwash/patching.rst deleted file mode 100644 index aab5039c2fbc..000000000000 --- a/doc/devel/gitwash/patching.rst +++ /dev/null @@ -1,138 +0,0 @@ -.. highlight:: bash - -================ - Making a patch -================ - -You've discovered a bug or something else you want to change -in `Matplotlib`_ .. |emdash| excellent! - -You've worked out a way to fix it |emdash| even better! - -You want to tell us about it |emdash| best of all! - -The easiest way is to make a *patch* or set of patches. Here -we explain how. Making a patch is the simplest and quickest, -but if you're going to be doing anything more than simple -quick things, please consider following the -:ref:`git-development` model instead. - -.. _making-patches: - -Making patches -============== - -Overview --------- - -:: - - # tell git who you are - git config --global user.email you@yourdomain.example.com - git config --global user.name "Your Name Comes Here" - # get the repository if you don't have it - git clone https://github.com/matplotlib/matplotlib.git - # make a branch for your patching - cd matplotlib - git branch the-fix-im-thinking-of - git checkout the-fix-im-thinking-of - # hack, hack, hack - # Tell git about any new files you've made - git add somewhere/tests/test_my_bug.py - # commit work in progress as you go - git commit -am 'BF - added tests for Funny bug' - # hack hack, hack - git commit -am 'BF - added fix for Funny bug' - # make the patch files - git format-patch -M -C master - -Then, send the generated patch files to the `Matplotlib -mailing list`_ |emdash| where we will thank you warmly. - -In detail ---------- - -#. Tell git who you are so it can label the commits you've - made:: - - git config --global user.email you@yourdomain.example.com - git config --global user.name "Your Name Comes Here" - -#. If you don't already have one, clone a copy of the - `Matplotlib`_ repository:: - - git clone https://github.com/matplotlib/matplotlib.git - cd matplotlib - -#. Make a 'feature branch'. This will be where you work on - your bug fix. It's nice and safe and leaves you with - access to an unmodified copy of the code in the main - branch:: - - git branch the-fix-im-thinking-of - git checkout the-fix-im-thinking-of - -#. Do some edits, and commit them as you go:: - - # hack, hack, hack - # Tell git about any new files you've made - git add somewhere/tests/test_my_bug.py - # commit work in progress as you go - git commit -am 'BF - added tests for Funny bug' - # hack hack, hack - git commit -am 'BF - added fix for Funny bug' - - Note the ``-am`` options to ``commit``. The ``m`` flag just - signals that you're going to type a message on the command - line. The ``a`` flag |emdash| you can just take on faith |emdash| - or see `why the -a flag?`_. - -#. When you have finished, check you have committed all your - changes:: - - git status - -#. Finally, make your commits into patches. You want all the - commits since you branched from the ``master`` branch:: - - git format-patch -M -C master - - You will now have several files named for the commits: - - .. code-block:: none - - 0001-BF-added-tests-for-Funny-bug.patch - 0002-BF-added-fix-for-Funny-bug.patch - - Send these files to the `Matplotlib mailing list`_. - -When you are done, to switch back to the main copy of the -code, just return to the ``master`` branch:: - - git checkout master - -Moving from patching to development -=================================== - -If you find you have done some patches, and you have one or -more feature branches, you will probably want to switch to -development mode. You can do this with the repository you -have. - -Fork the `Matplotlib`_ repository on github |emdash| :ref:`forking`. -Then:: - - # checkout and refresh master branch from main repo - git checkout master - git pull origin master - # rename pointer to main repository to 'upstream' - git remote rename origin upstream - # point your repo to default read / write to your fork on github - git remote add origin git@github.com:your-user-name/matplotlib.git - # push up any branches you've made and want to keep - git push origin the-fix-im-thinking-of - -Then you can, if you want, follow the -:ref:`development-workflow`. - -.. include:: links.inc diff --git a/doc/devel/gitwash/pull_button.png b/doc/devel/gitwash/pull_button.png deleted file mode 100644 index e5031681b97b..000000000000 Binary files a/doc/devel/gitwash/pull_button.png and /dev/null differ diff --git a/doc/devel/gitwash/set_up_fork.rst b/doc/devel/gitwash/set_up_fork.rst deleted file mode 100644 index 205fdaafd947..000000000000 --- a/doc/devel/gitwash/set_up_fork.rst +++ /dev/null @@ -1,68 +0,0 @@ -.. highlight:: bash - -.. _set-up-fork: - -================== - Set up your fork -================== - -First you follow the instructions for :ref:`forking`. - -Overview -======== - -:: - - git clone https://github.com/your-user-name/matplotlib.git - cd matplotlib - git remote add upstream https://github.com/matplotlib/matplotlib.git - -In detail -========= - -Clone your fork ---------------- - -#. Clone your fork to the local computer with ``git clone - https://github.com/your-user-name/matplotlib.git`` -#. Investigate. Change directory to your new repo: ``cd matplotlib``. Then - ``git branch -a`` to show you all branches. You'll get something - like: - - .. code-block:: none - - * master - remotes/origin/master - - This tells you that you are currently on the ``master`` branch, and - that you also have a ``remote`` connection to ``origin/master``. - What remote repository is ``remote/origin``? Try ``git remote -v`` to - see the URLs for the remote. They will point to your github fork. - - Now you want to connect to the upstream `Matplotlib github`_ repository, so - you can merge in changes from trunk. - -.. _linking-to-upstream: - -Linking your repository to the upstream repo --------------------------------------------- - -:: - - cd matplotlib - git remote add upstream https://github.com/matplotlib/matplotlib.git - -``upstream`` here is just the arbitrary name we're using to refer to the -main `Matplotlib`_ repository at `Matplotlib github`_. - -Just for your own satisfaction, show yourself that you now have a new -'remote', with ``git remote -v show``, giving you something like: - -.. code-block:: none - - upstream https://github.com/matplotlib/matplotlib.git (fetch) - upstream https://github.com/matplotlib/matplotlib.git (push) - origin https://github.com/your-user-name/matplotlib.git (fetch) - origin https://github.com/your-user-name/matplotlib.git (push) - -.. include:: links.inc diff --git a/doc/devel/gitwash/this_project.inc b/doc/devel/gitwash/this_project.inc deleted file mode 100644 index e8863d5f78f0..000000000000 --- a/doc/devel/gitwash/this_project.inc +++ /dev/null @@ -1,5 +0,0 @@ -.. Matplotlib -.. _`Matplotlib`: http://matplotlib.org -.. _`Matplotlib github`: https://github.com/matplotlib/matplotlib - -.. _`Matplotlib mailing list`: https://mail.python.org/mailman/listinfo/matplotlib-devel diff --git a/doc/devel/index.rst b/doc/devel/index.rst index 60f5ef6ff267..e0fc978a6659 100644 --- a/doc/devel/index.rst +++ b/doc/devel/index.rst @@ -19,14 +19,37 @@ process or how to fix something feel free to ask on `gitter `_ for short questions and on `discourse `_ for longer questions. -.. raw:: html +.. rst-class:: sd-d-inline-block - + .. button-ref:: submitting-a-bug-report + :class: sd-fs-6 + :color: primary + + Report a bug + +.. rst-class:: sd-d-inline-block + + .. button-ref:: request-a-new-feature + :class: sd-fs-6 + :color: primary + + Request a feature + +.. rst-class:: sd-d-inline-block + + .. button-ref:: contributing-code + :class: sd-fs-6 + :color: primary + + Contribute code + +.. rst-class:: sd-d-inline-block + + .. button-ref:: documenting-matplotlib + :class: sd-fs-6 + :color: primary + + Write documentation .. toctree:: :maxdepth: 2 @@ -34,14 +57,15 @@ process or how to fix something feel free to ask on `gitter contributing.rst triage.rst development_setup.rst + development_workflow.rst testing.rst documenting_mpl.rst style_guide.rst - gitwash/index.rst coding_guide.rst release_guide.rst dependencies.rst min_dep_policy.rst + maintainer_workflow.rst MEP/index .. toctree:: diff --git a/doc/devel/gitwash/maintainer_workflow.rst b/doc/devel/maintainer_workflow.rst similarity index 85% rename from doc/devel/gitwash/maintainer_workflow.rst rename to doc/devel/maintainer_workflow.rst index 50c27aa8ff40..7723e6cb8c0c 100644 --- a/doc/devel/gitwash/maintainer_workflow.rst +++ b/doc/devel/maintainer_workflow.rst @@ -6,7 +6,7 @@ Maintainer workflow ################### -This page is for maintainers |emdash| those of us who merge our own or other +This page is for maintainers — those of us who merge our own or other peoples' changes into the upstream repository. Being as how you're a maintainer, you are completely on top of the basic stuff @@ -26,7 +26,7 @@ Integrating changes ******************* Let's say you have some changes that need to go into trunk -(``upstream-rw/master``). +(``upstream-rw/main``). The changes are in some branch that you are currently on. For example, you are looking at someone's changes like this:: @@ -47,10 +47,10 @@ If there are only a few commits, consider rebasing to upstream:: # Fetch upstream changes git fetch upstream-rw # rebase - git rebase upstream-rw/master + git rebase upstream-rw/main Remember that, if you do a rebase, and push that, you'll have to close any -github pull requests manually, because github will not be able to detect the +GitHub pull requests manually, because GitHub will not be able to detect the changes have already been merged. A long series of commits @@ -59,9 +59,9 @@ A long series of commits If there are a longer series of related commits, consider a merge instead:: git fetch upstream-rw - git merge --no-ff upstream-rw/master + git merge --no-ff upstream-rw/main -The merge will be detected by github, and should close any related pull requests +The merge will be detected by GitHub, and should close any related pull requests automatically. Note the ``--no-ff`` above. This forces git to make a merge commit, rather than @@ -76,11 +76,11 @@ Now, in either case, you should check that the history is sensible and you have the right commits:: git log --oneline --graph - git log -p upstream-rw/master.. + git log -p upstream-rw/main.. The first line above just shows the history in a compact way, with a text representation of the history graph. The second line shows the log of commits -excluding those that can be reached from trunk (``upstream-rw/master``), and +excluding those that can be reached from trunk (``upstream-rw/main``), and including those that can be reached from current HEAD (implied with the ``..`` at the end). So, it shows the commits unique to this branch compared to trunk. The ``-p`` option shows the diff for these commits in patch form. @@ -90,9 +90,7 @@ Push to trunk :: - git push upstream-rw my-new-feature:master + git push upstream-rw my-new-feature:main -This pushes the ``my-new-feature`` branch in this repository to the ``master`` +This pushes the ``my-new-feature`` branch in this repository to the ``main`` branch in the ``upstream-rw`` repository. - -.. include:: links.inc diff --git a/doc/devel/min_dep_policy.rst b/doc/devel/min_dep_policy.rst index 306ec0ea90dc..0836315b8dd3 100644 --- a/doc/devel/min_dep_policy.rst +++ b/doc/devel/min_dep_policy.rst @@ -61,7 +61,7 @@ We will support at least minor versions of the development dependencies released in the 12 months prior to our planned release. We will only bump these as needed or versions no longer support our -minimum Python and numpy. +minimum Python and NumPy. System and C-dependencies ========================= @@ -71,6 +71,10 @@ Ghostscript, FFmpeg) support as old as practical. These can be difficult to install for end-users and we want to be usable on as many systems as possible. We will bump these on a case-by-case basis. +In the case of GUI frameworks for which we rely on Python bindings being +available, we will also drop support for bindings so old that they don't +support any Python version that we support. + .. _list-of-dependency-min-versions: List of dependency versions @@ -83,6 +87,8 @@ specification of the dependencies. ========== ======== ====== Matplotlib Python NumPy ========== ======== ====== +`3.7`_ 3.8 1.20.0 +`3.6`_ 3.8 1.19.0 `3.5`_ 3.7 1.17.0 `3.4`_ 3.7 1.16.0 `3.3`_ 3.6 1.15.0 @@ -100,6 +106,8 @@ Matplotlib Python NumPy 1.0 2.4 1.1 ========== ======== ====== +.. _`3.7`: https://matplotlib.org/3.7.0/devel/dependencies.html +.. _`3.6`: https://matplotlib.org/3.6.0/devel/dependencies.html .. _`3.5`: https://matplotlib.org/3.5.0/devel/dependencies.html .. _`3.4`: https://matplotlib.org/3.4.0/devel/dependencies.html .. _`3.3`: https://matplotlib.org/3.3.0/users/installing.html#dependencies diff --git a/doc/devel/release_guide.rst b/doc/devel/release_guide.rst index 6bb3a744d96a..3f49631d00d8 100644 --- a/doc/devel/release_guide.rst +++ b/doc/devel/release_guide.rst @@ -141,7 +141,6 @@ Update :file:`doc/users/release_notes.rst`: prev_whats_new/whats_new_X.Y.0.rst ../api/prev_api_changes/api_changes_X.Y.0.rst prev_whats_new/github_stats_X.Y.0.rst - - For bugfix releases add the GitHub stats and (if present) the API changes to the existing X.Y section @@ -150,6 +149,15 @@ Update :file:`doc/users/release_notes.rst`: ../api/prev_api_changes/api_changes_X.Y.Z.rst prev_whats_new/github_stats_X.Y.Z.rst +Update version switcher +~~~~~~~~~~~~~~~~~~~~~~~ + +Update ``doc/_static/switcher.json``. If a minor release, ``X.Y.Z``, create +a new entry ``version: X.Y.(Z-1)``, and change the name of stable +``name: stable/X.Y.Z``. If a major release, ``X.Y.0``, change the name +of ``name: devel/X.(Y+1)`` and ``name: stable/X.Y.0`` as well as adding +a new version for the previous stable. + Verify that docs build ---------------------- @@ -212,7 +220,7 @@ with the tag [#]_:: Finally, push the tag to GitHub:: - git push DANGER master v2.0.0 + git push DANGER main v2.0.0 Congratulations, the scariest part is done! @@ -365,12 +373,12 @@ which will build both the html and pdf version of the documentation. The built documentation exists in the `matplotlib.github.com `__ repository. -Pushing changes to master automatically updates the website. +Pushing changes to main automatically updates the website. The documentation is organized by version. At the root of the tree is always the documentation for the latest stable release. Under that, there are directories containing the documentation for older versions. The documentation -for current master is built on Circle CI and pushed to the `devdocs +for current main is built on Circle CI and pushed to the `devdocs `__ repository. These are available at `matplotlib.org/devdocs `__. @@ -395,7 +403,7 @@ the newly released version. Now commit and push everything to GitHub :: git add * git commit -a -m 'Updating docs for v2.0.0' - git push DANGER master + git push DANGER main Congratulations you have now done the third scariest part! diff --git a/doc/devel/style_guide.rst b/doc/devel/style_guide.rst index 65779e18a734..9dab7a6d99d2 100644 --- a/doc/devel/style_guide.rst +++ b/doc/devel/style_guide.rst @@ -29,7 +29,7 @@ reliability and consistency in documentation. They are not interchangeable. +------------------+--------------------------+--------------+--------------+ | Term | Description | Correct | Incorrect | +==================+==========================+==============+==============+ - | Figure_ | Matplotlib working space | - *For | - "The figure| + | |Figure| | Matplotlib working space | - *For | - "The figure| | | for programming. | Matplotlib | is the | | | | objects*: | working | | | | Figure, | space for | @@ -39,11 +39,11 @@ reliability and consistency in documentation. They are not interchangeable. | | | space for | provide the| | | | the visual.| visuals." | | | | - *Referring | - "The | - | | | to class*: | Figure_ | - | | | Figure_ , | Four | + | | | to class*: | |Figure| | + | | | |Figure|, | Four | | | | "Methods | leglock is | | | | within the | a wrestling| - | | | Figure_ | move." | + | | | |Figure| | move." | | | | provide the| | | | | visuals." | | | | | - *General | | @@ -55,28 +55,29 @@ reliability and consistency in documentation. They are not interchangeable. | | | figure | | | | | skater." | | +------------------+--------------------------+--------------+--------------+ - | Axes_ | Subplots within Figure. | - *For | - "The axes | + | |Axes| | Subplots within Figure. | - *For | - "The axes | | | Contains plot elements | Matplotlib | methods | | | and is responsible for | objects*: | transform | | | plotting and configuring | Axes, "An | the data." | - | | additional details. | Axes is a | - "Each Axes_| - | | | subplot | is specific| - | | | within the | to a | - | | | Figure." | Figure." | + | | additional details. | Axes is a | - "Each | + | | | subplot | |Axes| is | + | | | within the | specific to| + | | | Figure." | a Figure." | | | | - *Referring | - "The | | | | to class*: | musicians | - | | | Axes_ , | on stage | - | | | "Each Axes_| call their | - | | | is specific| guitars | - | | | to one | Axes." | - | | | Figure." | - "The point | - | | | - *General | where the | - | | | language*: | Axes meet | - | | | axes, "Both| is the | - | | | loggers and| origin of | - | | | lumberjacks| the | - | | | use axes to| coordinate | - | | | chop wood."| system." | + | | | |Axes|, | on stage | + | | | "Each | call their | + | | | |Axes| is | guitars | + | | | specific to| Axes." | + | | | one | - "The point | + | | | Figure." | where the | + | | | - *General | Axes meet | + | | | language*: | is the | + | | | axes, "Both| origin of | + | | | loggers and| the | + | | | lumberjacks| coordinate | + | | | use axes to| system." | + | | | chop wood."| | | | | OR "There | | | | | are no | | | | | standard | | @@ -89,24 +90,25 @@ reliability and consistency in documentation. They are not interchangeable. | | | (Plural of | | | | | axis) | | +------------------+--------------------------+--------------+--------------+ - | Artist_ | Broad variety of | - *For | - "Configure | + | |Artist| | Broad variety of | - *For | - "Configure | | | Matplotlib objects that | Matplotlib | the legend | | | display visuals. | objects*: | artist with| | | | Artist, | its | | | | "Artists | respective | | | | display | method." | | | | visuals and| - "There is | - | | | are the | an Artist_ | - | | | visible | for that | - | | | elements | visual in | - | | | when the | the graph."| - | | | rendering | - "Some | - | | | a Figure." | Artists | - | | | - *Referring | became | - | | | to class*: | famous only| - | | | Artist_ , | by | - | | | "Each | accident." | - | | | Artist_ has| | + | | | are the | an | + | | | visible | |Artist| | + | | | elements | for that | + | | | when | visual in | + | | | rendering a| the graph."| + | | | Figure." | - "Some | + | | | - *Referring | Artists | + | | | to class*: | became | + | | | |Artist| , | famous only| + | | | "Each | by | + | | | |Artist| | accident." | + | | | has | | | | | respective | | | | | methods and| | | | | functions."| | @@ -119,7 +121,7 @@ reliability and consistency in documentation. They are not interchangeable. | | | is from | | | | | France." | | +------------------+--------------------------+--------------+--------------+ - | Axis_ | Human-readable single | - *For | - "Plot the | + | |Axis| | Human-readable single | - *For | - "Plot the | | | dimensional object | Matplotlib | graph onto | | | of reference marks | objects*: | the axis." | | | containing ticks, tick | Axis, "The | - "Each Axis | @@ -133,12 +135,13 @@ reliability and consistency in documentation. They are not interchangeable. | | | objects) | - "In some | | | | - *Referring | computer | | | | to class*: | graphics | - | | | Axis_ , | contexts, | - | | | "The Axis_ | the | - | | | contains | ordinate | - | | | respective | Axis_ may | - | | | XAxis and | be oriented| - | | | YAxis | downwards."| + | | | |Axis|, | contexts, | + | | | "The | the | + | | | |Axis| | ordinate | + | | | contains | |Axis| may | + | | | respective | be oriented| + | | | XAxis and | downwards."| + | | | YAxis | | | | | objects." | | | | | - *General | | | | | language*: | | @@ -162,10 +165,10 @@ reliability and consistency in documentation. They are not interchangeable. | | | | interface | +------------------+--------------------------+--------------+--------------+ -.. _Figure: :class:`~matplotlib.figure.Figure` -.. _Axes: :class:`~matplotlib.axes.Axes` -.. _Artist: :class:`~matplotlib.artist.Artist` -.. _Axis: :class:`matplotlib.axis.Axis` +.. |Figure| replace:: :class:`~matplotlib.figure.Figure` +.. |Axes| replace:: :class:`~matplotlib.axes.Axes` +.. |Artist| replace:: :class:`~matplotlib.artist.Artist` +.. |Axis| replace:: :class:`~matplotlib.axis.Axis` Grammar @@ -406,4 +409,3 @@ resources contain common best practices for writing documentation. * `Google Developer Style Guide `_ * `IBM Style Guide `_ * `Red Hat Style Guide `_ - diff --git a/doc/devel/testing.rst b/doc/devel/testing.rst index 65898b95ee0c..06296f5dc701 100644 --- a/doc/devel/testing.rst +++ b/doc/devel/testing.rst @@ -87,10 +87,12 @@ Random data in tests Random data is a very convenient way to generate data for examples, however the randomness is problematic for testing (as the tests must be deterministic!). To work around this set the seed in each test. -For numpy use:: +For numpy's default random number generator use:: import numpy as np - np.random.seed(19680801) + rng = np.random.default_rng(19680801) + +and then use ``rng`` when generating the random numbers. The seed is John Hunter's birthday. @@ -107,7 +109,7 @@ tests it:: import matplotlib.pyplot as plt @image_comparison(baseline_images=['line_dashes'], remove_text=True, - extensions=['png']) + extensions=['png'], style='mpl20') def test_line_dashes(): fig, ax = plt.subplots() ax.plot(range(10), linestyle=(0, (3, 3)), lw=5) @@ -128,6 +130,12 @@ images on the figures using two different methods (the tested method and the baseline method). The decorator will arrange for setting up the figures and then collect the drawn results and compare them. +It is preferred that new tests use ``style='mpl20'`` as this leads to smaller +figures and reflects the newer look of default Matplotlib plots. Also, if the +texts (labels, tick labels, etc) are not really part of what is tested, use +``remove_text=True`` as this will lead to smaller figures and reduce possible +issues with font mismatch on different platforms. + See the documentation of `~matplotlib.testing.decorators.image_comparison` and `~matplotlib.testing.decorators.check_figures_equal` for additional information about their use. @@ -157,7 +165,9 @@ workflows GitHub Actions should be automatically enabled for your personal Matplotlib fork once the YAML workflow files are in it. It generally isn't necessary to look at these workflows, since any pull request submitted against the main -Matplotlib repository will be tested. +Matplotlib repository will be tested. The Tests workflow is skipped in forked +repositories but you can trigger a run manually from the `GitHub web interface +`_. You can see the GitHub Actions results at https://github.com/your_GitHub_user_name/matplotlib/actions -- here's `an @@ -169,7 +179,7 @@ Using tox `Tox `_ is a tool for running tests against multiple Python environments, including multiple versions of Python -(e.g., 3.6, 3.7) and even different Python implementations altogether +(e.g., 3.7, 3.8) and even different Python implementations altogether (e.g., CPython, PyPy, Jython, etc.), as long as all these versions are available on your system's $PATH (consider using your system package manager, e.g. apt-get, yum, or Homebrew, to install them). @@ -186,7 +196,7 @@ You can also run tox on a subset of environments: .. code-block:: bash - $ tox -e py37,py38 + $ tox -e py38,py39 Tox processes everything serially so it can take a long time to test several environments. To speed it up, you might try using a new, @@ -256,4 +266,3 @@ The test discovery scope can be narrowed to single test modules or even single functions:: python -m pytest --pyargs matplotlib.tests.test_simplification.py::test_clipping - diff --git a/doc/devel/triage.rst b/doc/devel/triage.rst index 5f274f482f13..2106ac99c606 100755 --- a/doc/devel/triage.rst +++ b/doc/devel/triage.rst @@ -87,7 +87,7 @@ Triaging operations for members of the core and triage teams In addition to the above, members of the core team and the triage team can do the following important tasks: -- Update labels for issues and PRs: see the list of `available github +- Update labels for issues and PRs: see the list of `available GitHub labels `_. - Triage issues: @@ -208,7 +208,7 @@ The following workflow [1]_ is a good way to approach issue triaging: .. [1] Adapted from the pandas project `maintainers guide - `_ and + `_ and `the scikit-learn project `_ . diff --git a/doc/index.rst b/doc/index.rst index 7c314814e769..1c608c0d62cb 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -16,9 +16,9 @@ and interactive visualizations in Python. Installation ************ -.. container:: twocol +.. grid:: 1 1 2 2 - .. container:: + .. grid-item:: Install using `pip `__: @@ -26,13 +26,13 @@ Installation pip install matplotlib - .. container:: + .. grid-item:: Install using `conda `__: .. code-block:: bash - conda install matplotlib + conda install -c conda-forge matplotlib Further details are available in the :doc:`Installation Guide `. @@ -41,46 +41,53 @@ Further details are available in the :doc:`Installation Guide ` - - :doc:`Plot types ` - - `Introductory tutorials <../tutorials/index.html#introductory>`_ - - :doc:`External learning resources ` + Tutorials + ^^^ - --- + - :doc:`Quick-start guide ` + - :doc:`Plot types ` + - :ref:`Introductory tutorials ` + - :doc:`External learning resources ` - How-tos - ^^^^^^^ - - :doc:`Example gallery ` - - :doc:`Matplotlib FAQ ` + .. grid-item-card:: + :padding: 2 - --- + How-tos + ^^^ - Understand how Matplotlib works - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + - :doc:`Example gallery ` + - :doc:`Matplotlib FAQ ` - - The :ref:`users-guide-explain` in the :doc:`Users guide ` - - Many of the :ref:`Intermediate ` and - :ref:`Advanced ` tutorials - have explanatory material + .. grid-item-card:: + :padding: 2 - --- + Understand how Matplotlib works + ^^^ - Reference - ^^^^^^^^^ + - The :ref:`users-guide-explain` in the :doc:`Users guide + ` + - Many of the :ref:`Intermediate ` and + :ref:`Advanced ` tutorials have explanatory + material - - :doc:`API Reference ` - - :doc:`Axes API ` for most plotting methods - - :doc:`Figure API ` for figure-level methods - - Top-level interfaces to create: + .. grid-item-card:: + :padding: 2 - - Figures (`.pyplot.figure`) - - Subplots (`.pyplot.subplots`, `.pyplot.subplot_mosaic`) + Reference + ^^^ + - :doc:`API Reference ` + - :doc:`Axes API ` for most plotting methods + - :doc:`Figure API ` for figure-level methods + - Top-level interfaces to create: + + - Figures (`.pyplot.figure`) + - Subplots (`.pyplot.subplots`, `.pyplot.subplot_mosaic`) ******************** diff --git a/doc/make.bat b/doc/make.bat index 042c9ef3543b..aa6612f08eae 100644 --- a/doc/make.bat +++ b/doc/make.bat @@ -10,8 +10,9 @@ if "%SPHINXBUILD%" == "" ( set SOURCEDIR=. set BUILDDIR=build set SPHINXPROJ=matplotlib -set SPHINXOPTS=-W -set O= +if defined SPHINXOPTS goto skipopts +set SPHINXOPTS=-W --keep-going +:skipopts %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( @@ -27,12 +28,32 @@ if errorlevel 9009 ( ) if "%1" == "" goto help +if "%1" == "html-noplot" goto html-noplot +if "%1" == "show" goto show %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +if "%1" == "clean" ( + REM workaround because sphinx does not completely clean up (#11139) + rmdir /s /q "%SOURCEDIR%\build" + rmdir /s /q "%SOURCEDIR%\api\_as_gen" + rmdir /s /q "%SOURCEDIR%\gallery" + rmdir /s /q "%SOURCEDIR%\plot_types" + rmdir /s /q "%SOURCEDIR%\tutorials" + rmdir /s /q "%SOURCEDIR%\savefig" + rmdir /s /q "%SOURCEDIR%\sphinxext\__pycache__" +) goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:html-noplot +%SPHINXBUILD% -M html %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -D plot_gallery=0 +goto end + +:show +python -m webbrowser -t "%~dp0\build\html\index.html" :end popd diff --git a/doc/missing-references.json b/doc/missing-references.json index f3cb9c305610..d9a64ae39c2a 100644 --- a/doc/missing-references.json +++ b/doc/missing-references.json @@ -1,387 +1,155 @@ { "py:attr": { - "axis": [ - "lib/matplotlib/category.py:docstring of matplotlib.category.StrCategoryLocator.tick_values:5", - "lib/matplotlib/dates.py:docstring of matplotlib.dates.AutoDateLocator.tick_values:5", - "lib/matplotlib/dates.py:docstring of matplotlib.dates.MicrosecondLocator.tick_values:5", - "lib/matplotlib/dates.py:docstring of matplotlib.dates.RRuleLocator.tick_values:5", - "lib/matplotlib/dates.py:docstring of matplotlib.dates.YearLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.AutoMinorLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.IndexLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.LinearLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.Locator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.LogLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.LogitLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.MaxNLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.MultipleLocator.tick_values:5", - "lib/matplotlib/ticker.py:docstring of matplotlib.ticker.SymmetricalLogLocator.tick_values:5" - ], "cbar_axes": [ - "lib/mpl_toolkits/axes_grid1/axes_grid.py:docstring of mpl_toolkits.axes_grid1.axes_grid.ImageGrid:45", - "lib/mpl_toolkits/axisartist/axes_grid.py:docstring of mpl_toolkits.axisartist.axes_grid.ImageGrid:45" + "lib/mpl_toolkits/axes_grid1/axes_grid.py:docstring of mpl_toolkits.axes_grid1.axes_grid.ImageGrid:41", + "lib/mpl_toolkits/axisartist/axes_grid.py:docstring of mpl_toolkits.axisartist.axes_grid.ImageGrid:41" ], "eventson": [ - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.CheckButtons.set_active:4", - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.RadioButtons.set_active:4" + "lib/matplotlib/widgets.py:docstring of matplotlib.widgets:1" ], "fmt_zdata": [ - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.format_zdata:2" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.format_zdata:1" ], "height": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.bounds:2" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "input_dims": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.AitoffTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.AitoffTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.InvertedAitoffTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.InvertedAitoffTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.HammerTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.HammerTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.InvertedHammerTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.InvertedHammerTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.InvertedLambertTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.InvertedLambertTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.LambertTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.LambertTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.InvertedMollweideTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.InvertedMollweideTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.MollweideTransform.transform_non_affine:14", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.MollweideTransform.transform_non_affine:20", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform:8", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform_affine:15", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform_affine:21", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform_non_affine:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform_non_affine:20", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.CompositeGenericTransform.transform_affine:15", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.CompositeGenericTransform.transform_affine:21", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.CompositeGenericTransform.transform_non_affine:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.CompositeGenericTransform.transform_non_affine:20", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform:8", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform_affine:15", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform_affine:21", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform_non_affine:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform_non_affine:20" + "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes:1", + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:10", + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:11", + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:4" ], "lines": [ - "lib/matplotlib/colorbar.py:docstring of matplotlib.colorbar.ColorbarBase.add_lines:4" - ], - "matplotlib.axes.Axes.dataLim": [ - "doc/api/prev_api_changes/api_changes_0.99.x.rst:23" - ], - "matplotlib.axes.Axes.frame": [ - "doc/api/prev_api_changes/api_changes_0.98.x.rst:89" - ], - "matplotlib.axes.Axes.lines": [ - "doc/tutorials/intermediate/artists.rst:100", - "doc/tutorials/intermediate/artists.rst:442" + "lib/matplotlib/colorbar.py:docstring of matplotlib.colorbar:1" ], "matplotlib.axes.Axes.patch": [ - "doc/api/prev_api_changes/api_changes_0.98.x.rst:89", - "doc/tutorials/intermediate/artists.rst:187", - "doc/tutorials/intermediate/artists.rst:426" + "doc/tutorials/intermediate/artists.rst:184", + "doc/tutorials/intermediate/artists.rst:423" ], "matplotlib.axes.Axes.patches": [ - "doc/tutorials/intermediate/artists.rst:465" + "doc/tutorials/intermediate/artists.rst:461" ], "matplotlib.axes.Axes.transAxes": [ - "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredDirectionArrows:8" + "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredDirectionArrows:4" ], "matplotlib.axes.Axes.transData": [ - "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredAuxTransformBox:11", - "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredEllipse:8", - "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredSizeBar:8" - ], - "matplotlib.axes.Axes.viewLim": [ - "doc/api/prev_api_changes/api_changes_0.99.x.rst:23" + "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredAuxTransformBox:7", + "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredEllipse:4", + "lib/mpl_toolkits/axes_grid1/anchored_artists.py:docstring of mpl_toolkits.axes_grid1.anchored_artists.AnchoredSizeBar:4" ], "matplotlib.axes.Axes.xaxis": [ - "doc/tutorials/intermediate/artists.rst:610" + "doc/tutorials/intermediate/artists.rst:607" ], "matplotlib.axes.Axes.yaxis": [ - "doc/tutorials/intermediate/artists.rst:610" + "doc/tutorials/intermediate/artists.rst:607" ], "matplotlib.axis.Axis.label": [ - "doc/tutorials/intermediate/artists.rst:657" - ], - "matplotlib.cm.ScalarMappable.callbacksSM": [ - "doc/api/prev_api_changes/api_changes_0.98.0.rst:10" - ], - "matplotlib.colorbar.ColorbarBase.ax": [ - "doc/api/prev_api_changes/api_changes_1.3.x.rst:100" + "doc/tutorials/intermediate/artists.rst:654" ], "matplotlib.colors.Colormap.name": [ - "lib/matplotlib/cm.py:docstring of matplotlib.cm.register_cmap:14" + "lib/matplotlib/cm.py:docstring of matplotlib.cm:10" ], "matplotlib.figure.Figure.patch": [ - "doc/api/prev_api_changes/api_changes_0.98.x.rst:89", - "doc/tutorials/intermediate/artists.rst:187", - "doc/tutorials/intermediate/artists.rst:320" + "doc/tutorials/intermediate/artists.rst:184", + "doc/tutorials/intermediate/artists.rst:317" ], "matplotlib.figure.Figure.transFigure": [ - "doc/tutorials/intermediate/artists.rst:369" + "doc/tutorials/intermediate/artists.rst:366" ], "max": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.p1:4" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "min": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.p0:4" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "mpl_toolkits.mplot3d.axis3d._axinfo": [ - "doc/api/toolkits/mplot3d.rst:42" + "doc/api/toolkits/mplot3d.rst:66" ], "name": [ - "lib/matplotlib/scale.py:docstring of matplotlib.scale.ScaleBase:8" + "lib/matplotlib/scale.py:docstring of matplotlib.scale:7" ], "output_dims": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.AitoffTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.InvertedAitoffTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.HammerTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.InvertedHammerTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.InvertedLambertTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.LambertTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.InvertedMollweideTransform.transform_non_affine:20", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.MollweideTransform.transform_non_affine:20", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform_affine:21", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.AffineBase.transform_non_affine:20", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.CompositeGenericTransform.transform_affine:21", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.CompositeGenericTransform.transform_non_affine:20", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform:14", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform_affine:21", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.IdentityTransform.transform_non_affine:20" + "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes:6", + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:10", + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:16", + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:17" ], "triangulation": [ - "lib/matplotlib/tri/trirefine.py:docstring of matplotlib.tri.trirefine.UniformTriRefiner.refine_triangulation:2" + "lib/matplotlib/tri/_trirefine.py:docstring of matplotlib.tri._trirefine.UniformTriRefiner:1" ], "use_sticky_edges": [ - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.margins:53" + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.margins:48" ], "width": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.bounds:2" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "xmax": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.x1:4" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "xmin": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.x0:4" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "ymax": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.y1:4" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "ymin": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Bbox.y0:4" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ] }, "py:class": { - "Cursors": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.NavigationToolbar2.set_cursor:2" - ], - "FigureCanvasQTAgg": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:215" - ], - "Patch3DCollection": [ - "doc/api/toolkits/mplot3d.rst:85::1" - ], - "Path3DCollection": [ - "doc/api/toolkits/mplot3d.rst:85::1" - ], - "backend_qt5.FigureCanvasQT": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:199" - ], - "backend_qt5.FigureCanvasQTAgg": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:210" - ], - "dateutil.rrule.rrulebase": [ - "/rrule.py:docstring of dateutil.rrule.rrule:1" - ], - "matplotlib._mathtext.Box": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Char": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.ComputerModernFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.DejaVuSansFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.DejaVuSerifFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.FontConstantsBase": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Fonts": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Glue": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Kern": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Node": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Parser": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.STIXFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.STIXSansFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.Ship": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.StandardPsFonts": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib._mathtext.TruetypeFonts": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.axes.Subplot": [ - "doc/tutorials/intermediate/artists.rst:44", - "doc/tutorials/intermediate/artists.rst:67" - ], "matplotlib.axes._base._AxesBase": [ - "doc/api/artist_api.rst:189", - "doc/api/axes_api.rst:609", + "doc/api/artist_api.rst:202", + "doc/api/axes_api.rst:603", "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes:1" ], "matplotlib.backend_bases.FigureCanvas": [ - "doc/tutorials/intermediate/artists.rst:29", - "doc/tutorials/intermediate/artists.rst:31", - "doc/tutorials/intermediate/artists.rst:36" + "doc/tutorials/intermediate/artists.rst:32", + "doc/tutorials/intermediate/artists.rst:34", + "doc/tutorials/intermediate/artists.rst:39" ], "matplotlib.backend_bases.Renderer": [ - "doc/tutorials/intermediate/artists.rst:31", - "doc/tutorials/intermediate/artists.rst:36" + "doc/tutorials/intermediate/artists.rst:34", + "doc/tutorials/intermediate/artists.rst:39" ], "matplotlib.backend_bases._Backend": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.ShowBase:1" + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:1" ], "matplotlib.backends._backend_pdf_ps.RendererPDFPSBase": [ - "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.RendererPdf:1", - "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.RendererPS:1" + "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf:1", + "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps:1" ], "matplotlib.backends._backend_tk.FigureCanvasTk": [ - "lib/matplotlib/backends/backend_tkagg.py:docstring of matplotlib.backends.backend_tkagg.FigureCanvasTkAgg:1", - "lib/matplotlib/backends/backend_tkcairo.py:docstring of matplotlib.backends.backend_tkcairo.FigureCanvasTkCairo:1" + "lib/matplotlib/backends/backend_tkagg.py:docstring of matplotlib.backends.backend_tkagg:1", + "lib/matplotlib/backends/backend_tkcairo.py:docstring of matplotlib.backends.backend_tkcairo:1" ], "matplotlib.backends.backend_webagg_core.FigureCanvasWebAggCore": [ - "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg.FigureCanvasNbAgg:1" + "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg:1", + "lib/matplotlib/backends/backend_webagg.py:docstring of matplotlib.backends.backend_webagg:1" ], "matplotlib.backends.backend_webagg_core.FigureManagerWebAgg": [ - "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg.FigureManagerNbAgg:1" + "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg:1", + "lib/matplotlib/backends/backend_webagg.py:docstring of matplotlib.backends.backend_webagg:1" ], "matplotlib.backends.backend_webagg_core.NavigationToolbar2WebAgg": [ - "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg.NavigationIPy:1" + "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg:1" ], "matplotlib.collections._CollectionWithSizes": [ - "doc/api/artist_api.rst:189", + "doc/api/artist_api.rst:202", "doc/api/collections_api.rst:13", - "lib/matplotlib/collections.py:docstring of matplotlib.collections.CircleCollection:1", - "lib/matplotlib/collections.py:docstring of matplotlib.collections.PathCollection:1", - "lib/matplotlib/collections.py:docstring of matplotlib.collections.PolyCollection:1", - "lib/matplotlib/collections.py:docstring of matplotlib.collections.RegularPolyCollection:1" - ], - "matplotlib.dates.rrulewrapper": [ - "doc/api/dates_api.rst:11" + "lib/matplotlib/collections.py:docstring of matplotlib.collections:1" ], "matplotlib.image._ImageBase": [ - "doc/api/artist_api.rst:189", - "lib/matplotlib/image.py:docstring of matplotlib.image.AxesImage:1", - "lib/matplotlib/image.py:docstring of matplotlib.image.BboxImage:1", - "lib/matplotlib/image.py:docstring of matplotlib.image.FigureImage:1" - ], - "matplotlib.mathtext.Box": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Char": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.ComputerModernFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.DejaVuSansFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.DejaVuSerifFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.FontConstantsBase": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Fonts": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Glue": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Kern": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Node": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Parser": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.STIXFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.STIXSansFontConstants": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.Ship": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.StandardPsFonts": [ - "doc/api/mathtext_api.rst:12" - ], - "matplotlib.mathtext.TruetypeFonts": [ - "doc/api/mathtext_api.rst:12" + "doc/api/artist_api.rst:202", + "lib/matplotlib/image.py:docstring of matplotlib.image:1" ], "matplotlib.patches.ArrowStyle._Base": [ - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.Fancy:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.Simple:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.Wedge:1" - ], - "matplotlib.patches.ArrowStyle._Bracket": [ - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.BarAB:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.BracketA:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.BracketAB:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.BracketB:1" + "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle:1" ], "matplotlib.patches.ArrowStyle._Curve": [ - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.Curve:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.CurveA:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.CurveAB:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.CurveB:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.CurveFilledA:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.CurveFilledAB:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle.CurveFilledB:1" - ], - "matplotlib.patches.BoxStyle._Base": [ - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.Circle:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.DArrow:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.LArrow:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.Round4:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.Round:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.Sawtooth:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.BoxStyle.Square:1" + "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle:1" ], "matplotlib.patches.ConnectionStyle._Base": [ - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ConnectionStyle.Angle3:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ConnectionStyle.Angle:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ConnectionStyle.Arc3:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ConnectionStyle.Arc:1", - "lib/matplotlib/patches.py:docstring of matplotlib.patches.ConnectionStyle.Bar:1" + "lib/matplotlib/patches.py:docstring of matplotlib.patches.ConnectionStyle:1" ], "matplotlib.patches._Style": [ "lib/matplotlib/patches.py:docstring of matplotlib.patches.ArrowStyle:1", @@ -390,29 +158,18 @@ "lib/mpl_toolkits/axisartist/axisline_style.py:docstring of mpl_toolkits.axisartist.axisline_style.AxislineStyle:1" ], "matplotlib.projections.geo._GeoTransform": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.AitoffTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.AitoffAxes.InvertedAitoffTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.HammerTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.HammerAxes.InvertedHammerTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.InvertedLambertTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.LambertAxes.LambertTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.InvertedMollweideTransform:1", - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.MollweideAxes.MollweideTransform:1" + "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo:1" ], "matplotlib.text._AnnotationBase": [ - "doc/api/artist_api.rst:189", - "lib/matplotlib/offsetbox.py:docstring of matplotlib.offsetbox.AnnotationBbox:1", + "doc/api/artist_api.rst:202", + "lib/matplotlib/offsetbox.py:docstring of matplotlib.offsetbox:1", "lib/matplotlib/text.py:docstring of matplotlib.text.Annotation:1" ], "matplotlib.transforms._BlendedMixin": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.BlendedAffine2D:1", - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.BlendedGenericTransform:1" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:1" ], "matplotlib.widgets._SelectorWidget": [ - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.LassoSelector:1", - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.PolygonSelector:1", - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.RectangleSelector:1", - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.SpanSelector:1" + "lib/matplotlib/widgets.py:docstring of matplotlib.widgets:1" ], "mpl_toolkits.axes_grid1.axes_size._Base": [ "lib/mpl_toolkits/axes_grid1/axes_size.py:docstring of mpl_toolkits.axes_grid1.axes_size.Add:1", @@ -427,521 +184,260 @@ "lib/mpl_toolkits/axes_grid1/axes_size.py:docstring of mpl_toolkits.axes_grid1.axes_size.SizeFromFunc:1" ], "mpl_toolkits.axes_grid1.parasite_axes.AxesHostAxes": [ - ":1", - "doc/api/_as_gen/mpl_toolkits.axes_grid1.parasite_axes.rst:31::1" + "doc/api/_as_gen/mpl_toolkits.axes_grid1.parasite_axes.rst:30::1", + "lib/mpl_toolkits/axes_grid1/parasite_axes.py:docstring of mpl_toolkits.axes_grid1.parasite_axes.AxesHostAxes:1" ], "mpl_toolkits.axes_grid1.parasite_axes.AxesParasite": [ - ":1", - "doc/api/_as_gen/mpl_toolkits.axes_grid1.parasite_axes.rst:31::1" - ], - "mpl_toolkits.axes_grid1.parasite_axes.AxesParasiteParasiteAuxTrans": [ - ":1", - "doc/api/_as_gen/mpl_toolkits.axes_grid1.parasite_axes.rst:31::1" + "doc/api/_as_gen/mpl_toolkits.axes_grid1.parasite_axes.rst:30::1", + "lib/mpl_toolkits/axes_grid1/parasite_axes.py:docstring of mpl_toolkits.axes_grid1.parasite_axes.AxesParasite:1" ], "mpl_toolkits.axisartist.Axes": [ - "doc/api/toolkits/axisartist.rst:5", "doc/api/toolkits/axisartist.rst:6" ], "mpl_toolkits.axisartist.axisline_style.AxislineStyle._Base": [ - "lib/mpl_toolkits/axisartist/axisline_style.py:docstring of mpl_toolkits.axisartist.axisline_style.AxislineStyle.SimpleArrow:1" + "lib/mpl_toolkits/axisartist/axisline_style.py:docstring of mpl_toolkits.axisartist.axisline_style.AxislineStyle:1" ], "mpl_toolkits.axisartist.axisline_style._FancyAxislineStyle.FilledArrow": [ - ":1" + "lib/mpl_toolkits/axisartist/axisline_style.py:docstring of mpl_toolkits.axisartist.axisline_style.AxislineStyle:1" ], "mpl_toolkits.axisartist.axisline_style._FancyAxislineStyle.SimpleArrow": [ - ":1" + "lib/mpl_toolkits/axisartist/axisline_style.py:docstring of mpl_toolkits.axisartist.axisline_style.AxislineStyle:1" ], "mpl_toolkits.axisartist.axislines.AxisArtistHelper._Base": [ - "lib/mpl_toolkits/axisartist/axislines.py:docstring of mpl_toolkits.axisartist.axislines.AxisArtistHelper.Fixed:1", - "lib/mpl_toolkits/axisartist/axislines.py:docstring of mpl_toolkits.axisartist.axislines.AxisArtistHelper.Floating:1" + "lib/mpl_toolkits/axisartist/axislines.py:docstring of mpl_toolkits.axisartist.axislines.AxisArtistHelper:1" ], "mpl_toolkits.axisartist.floating_axes.FloatingAxesHostAxes": [ - ":1", - "doc/api/_as_gen/mpl_toolkits.axisartist.floating_axes.rst:31::1" + "doc/api/_as_gen/mpl_toolkits.axisartist.floating_axes.rst:32::1", + "lib/mpl_toolkits/axisartist/floating_axes.py:docstring of mpl_toolkits.axisartist.floating_axes.FloatingAxesHostAxes:1" ], "numpy.uint8": [ - ":1" + "lib/matplotlib/path.py:docstring of matplotlib.path:1" ], "unittest.case.TestCase": [ - "lib/matplotlib/testing/decorators.py:docstring of matplotlib.testing.decorators.CleanupTestCase:1" + "lib/matplotlib/testing/decorators.py:docstring of matplotlib.testing.decorators:1" ] }, "py:data": { "matplotlib.axes.Axes.transAxes": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.legend:222", - "lib/matplotlib/figure.py:docstring of matplotlib.figure.FigureBase.legend:223", - "lib/matplotlib/legend.py:docstring of matplotlib.legend.Legend:182", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figlegend:223", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.legend:222" + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.legend:234", + "lib/matplotlib/figure.py:docstring of matplotlib.figure.FigureBase.add_axes:2", + "lib/matplotlib/legend.py:docstring of matplotlib.legend.Legend:91", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figlegend:233", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.legend:234" ] }, "py:meth": { "AbstractPathEffect._update_gc": [ - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.SimpleLineShadow:44", - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.SimplePatchShadow:43", - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.TickedStroke:60", - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.withSimplePatchShadow:52", - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.withTickedStroke:55" - ], - "FigureCanvasQTAgg.blit": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:199" - ], - "FigureCanvasQTAgg.paintEvent": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:199" - ], - "FigureCanvasQTAgg.print_figure": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:199" + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:26", + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:28", + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:35", + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:39", + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:44" ], "IPython.terminal.interactiveshell.TerminalInteractiveShell.inputhook": [ - "doc/users/interactive_guide.rst:420" + "doc/users/explain/interactive_guide.rst:420" ], "_find_tails": [ - "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:9" - ], - "_iter_collection": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.RendererBase.draw_path_collection:12", - "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.RendererPdf.draw_path_collection:12", - "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.RendererPS.draw_path_collection:12", - "lib/matplotlib/backends/backend_svg.py:docstring of matplotlib.backends.backend_svg.RendererSVG.draw_path_collection:12", - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.PathEffectRenderer.draw_path_collection:12" - ], - "_iter_collection_raw_paths": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.RendererBase.draw_path_collection:12", - "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.RendererPdf.draw_path_collection:12", - "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.RendererPS.draw_path_collection:12", - "lib/matplotlib/backends/backend_svg.py:docstring of matplotlib.backends.backend_svg.RendererSVG.draw_path_collection:12", - "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.PathEffectRenderer.draw_path_collection:12" + "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:5" ], "_make_barbs": [ - "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:9" - ], - "autoscale_view": [ - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.margins:32" - ], - "draw_image": [ - "lib/matplotlib/backends/backend_agg.py:docstring of matplotlib.backends.backend_agg.RendererAgg.option_scale_image:2" + "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:5" ], "get_matrix": [ - "lib/matplotlib/transforms.py:docstring of matplotlib.transforms.Affine2DBase:13" + "lib/matplotlib/transforms.py:docstring of matplotlib.transforms:12" ], "matplotlib.collections._CollectionWithSizes.set_sizes": [ "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.barbs:171", "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.broken_barh:81", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.fill_between:111", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.fill_betweenx:111", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.hexbin:167", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.pcolor:164", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.quiver:201", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.fill_between:113", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.fill_betweenx:113", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.hexbin:201", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.pcolor:173", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.quiver:207", + "lib/matplotlib/collections.py:docstring of matplotlib.collections.AsteriskPolygonCollection:22", "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.barbs:171", "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.broken_barh:81", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.fill_between:111", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.fill_betweenx:111", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.hexbin:167", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.pcolor:164", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.quiver:201", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.fill_between:113", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.fill_betweenx:113", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.hexbin:201", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.pcolor:173", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.quiver:207", "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:205", - "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Quiver:238" - ], - "matplotlib.dates.MicrosecondLocator.__call__": [ - "doc/api/prev_api_changes/api_changes_1.5.0.rst:122" - ], - "option_scale_image": [ - "lib/matplotlib/backends/backend_cairo.py:docstring of matplotlib.backends.backend_cairo.RendererCairo.draw_image:22", - "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.RendererPdf.draw_image:22", - "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.RendererPS.draw_image:22", - "lib/matplotlib/backends/backend_template.py:docstring of matplotlib.backends.backend_template.RendererTemplate.draw_image:22" - ] - }, - "py:mod": { - "IPython.terminal.pt_inputhooks": [ - "doc/users/interactive_guide.rst:420" - ], - "dateutil": [ - "lib/matplotlib/dates.py:docstring of matplotlib.dates:1" - ], - "matplotlib.ft2font": [ - "doc/api/prev_api_changes/api_changes_0.91.0.rst:34" + "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:38", + "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Quiver:244", + "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Quiver:38", + "lib/mpl_toolkits/mplot3d/art3d.py:docstring of mpl_toolkits.mplot3d.art3d.Path3DCollection:39", + "lib/mpl_toolkits/mplot3d/art3d.py:docstring of mpl_toolkits.mplot3d.art3d.Poly3DCollection:37" ] }, "py:obj": { "Artist.stale_callback": [ - "doc/users/interactive_guide.rst:323" + "doc/users/explain/interactive_guide.rst:323" ], "Artist.sticky_edges": [ - "doc/api/axes_api.rst:364::1", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes.Axes.use_sticky_edges:2" - ], - "ArtistInspector.aliasd": [ - "doc/api/prev_api_changes/api_changes_3.0.0.rst:332" - ], - "ArtistInspector.get_aliases": [ - "doc/api/prev_api_changes/api_changes_3.0.0.rst:327" + "doc/api/axes_api.rst:352::1", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes.Axes.use_sticky_edges:1" ], "Axes.dataLim": [ - "doc/api/axes_api.rst:304::1", - "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.update_datalim:2", - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.update_datalim:2" - ], - "Axes.datalim": [ - "doc/api/axes_api.rst:304::1", - "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.update_datalim_bounds:2" - ], - "Axes.fmt_xdata": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:397", - "doc/api/prev_api_changes/api_changes_3.1.0.rst:400" - ], - "Axes.fmt_ydata": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:397", - "doc/api/prev_api_changes/api_changes_3.1.0.rst:400" - ], - "Axes.transData": [ - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:91", - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:93", - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:95", - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:98" + "doc/api/axes_api.rst:291::1", + "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.update_datalim:1" ], "AxesBase": [ - "doc/api/axes_api.rst:456::1", - "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.add_child_axes:2" - ], - "Axis._update_ticks": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:1072" - ], - "Axis.units": [ - "doc/api/prev_api_changes/api_changes_2.2.0.rst:77" - ], - "FT2Font": [ - "doc/gallery/misc/ftface_props.rst:25", - "lib/matplotlib/font_manager.py:docstring of matplotlib.font_manager.ttfFontProperty:8" + "doc/api/axes_api.rst:444::1", + "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.add_child_axes:1" ], "Figure.stale_callback": [ - "doc/users/interactive_guide.rst:333" - ], - "Formatter.__call__": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:1122", - "doc/api/prev_api_changes/api_changes_3.1.0.rst:1128" - ], - "GaussianKDE": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.violinplot:46", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.violinplot:46" + "doc/users/explain/interactive_guide.rst:333" ], "Glyph": [ - "doc/gallery/misc/ftface_props.rst:25" + "doc/gallery/misc/ftface_props.rst:28" ], "Image": [ - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.gci:4" + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.gci:1" ], "ImageComparisonFailure": [ - "lib/matplotlib/testing/decorators.py:docstring of matplotlib.testing.decorators.image_comparison:2" - ], - "ImageComparisonTest": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:706" + "lib/matplotlib/testing/decorators.py:docstring of matplotlib.testing.decorators:1" ], "Line2D.pick": [ - "doc/users/event_handling.rst:467" - ], - "MicrosecondLocator.__call__": [ - "doc/api/prev_api_changes/api_changes_1.5.0.rst:119" - ], - "MovieWriter.saving": [ - "doc/api/animation_api.rst:221" - ], - "MovieWriterBase": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.AVConvBase:4", - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegBase:4", - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickBase:4" - ], - "MovieWriterRegistry": [ - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:49", - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:51", - "doc/api/prev_api_changes/api_changes_3.2.0/deprecations.rst:121" + "doc/users/explain/event_handling.rst:468" ], "QuadContourSet.changed()": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.contour:131", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.contourf:131", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.contour:131", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.contourf:131" - ], - "Quiver.pivot": [ - "doc/api/prev_api_changes/api_changes_1.5.0.rst:44" + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.contour:147", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.contourf:147", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.contour:147", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.contourf:147" ], "Rectangle.contains": [ - "doc/users/event_handling.rst:179" + "doc/users/explain/event_handling.rst:180" ], "Size.from_any": [ - "lib/mpl_toolkits/axes_grid1/axes_grid.py:docstring of mpl_toolkits.axes_grid1.axes_grid.ImageGrid:57", - "lib/mpl_toolkits/axisartist/axes_grid.py:docstring of mpl_toolkits.axisartist.axes_grid.ImageGrid:57" + "lib/mpl_toolkits/axes_grid1/axes_grid.py:docstring of mpl_toolkits.axes_grid1.axes_grid.ImageGrid:53", + "lib/mpl_toolkits/axisartist/axes_grid.py:docstring of mpl_toolkits.axisartist.axes_grid.ImageGrid:53" ], "Timer": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.FigureCanvasBase.new_timer:2", - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.TimerBase:14" + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:1", + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:13" ], "ToolContainer": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.ToolContainerBase.remove_toolitem:2", - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.ToolContainerBase:20" - ], - "_read": [ - "lib/matplotlib/dviread.py:docstring of matplotlib.dviread.Vf:20" + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:1", + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:19" ], - "active": [ - "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.AxesWidget:34" + "_iter_collection": [ + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:11", + "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.FigureCanvasPdf:1", + "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.FigureCanvasPS:1", + "lib/matplotlib/backends/backend_svg.py:docstring of matplotlib.backends.backend_svg.FigureCanvasSVG:1", + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:1" ], - "add_subplot": [ - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figure:50" + "_iter_collection_raw_paths": [ + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:11", + "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.FigureCanvasPdf:1", + "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.FigureCanvasPS:1", + "lib/matplotlib/backends/backend_svg.py:docstring of matplotlib.backends.backend_svg.FigureCanvasSVG:1", + "lib/matplotlib/patheffects.py:docstring of matplotlib.patheffects.AbstractPathEffect:1" ], - "add_tool": [ - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.add_tools_to_container:11", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.add_tools_to_manager:11" + "_read": [ + "lib/matplotlib/dviread.py:docstring of matplotlib.dviread:19" ], - "autoscale_view": [ - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.autoscale:19", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.plot:128" + "active": [ + "lib/matplotlib/widgets.py:docstring of matplotlib.widgets.AxesWidget:15" ], "ax.transAxes": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.indicate_inset:19", - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.inset_axes:11" - ], - "axes.Axes.patch": [ - "doc/api/prev_api_changes/api_changes_1.3.x.rst:36" + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.indicate_inset:14", + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.inset_axes:6" ], "axes.bbox": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.legend:128", - "lib/matplotlib/figure.py:docstring of matplotlib.figure.FigureBase.legend:129", - "lib/matplotlib/legend.py:docstring of matplotlib.legend.Legend:88", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figlegend:129", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.legend:128" - ], - "axes3d.Axes3D.xaxis": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:939" - ], - "axes3d.Axes3D.yaxis": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:939" - ], - "axes3d.Axes3D.zaxis": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:939" - ], - "axis.Axis.get_ticks_position": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:826" - ], - "backend_bases.RendererBase": [ - "lib/matplotlib/backends/backend_template.py:docstring of matplotlib.backends.backend_template.RendererTemplate:4" - ], - "backend_bases.ToolContainerBase": [ - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.add_tools_to_container:8" - ], - "blocking_input.BlockingInput.__call__": [ - "doc/api/prev_api_changes/api_changes_3.2.0/behavior.rst:295" + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.legend:137", + "lib/matplotlib/figure.py:docstring of matplotlib.figure.Figure:116", + "lib/matplotlib/legend.py:docstring of matplotlib.legend.DraggableLegend.finalize_offset:20", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figlegend:136", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.legend:137" ], "can_composite": [ - "lib/matplotlib/image.py:docstring of matplotlib.image.composite_images:9" + "lib/matplotlib/image.py:docstring of matplotlib.image:5" ], "cleanup": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.setup:18", - "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.setup:18" - ], - "colorbar.ColorbarBase.outline": [ - "doc/api/prev_api_changes/api_changes_1.4.x.rst:83" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter:13", + "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter:13" ], "converter": [ - "lib/matplotlib/testing/compare.py:docstring of matplotlib.testing.compare.compare_images:4" + "lib/matplotlib/testing/compare.py:docstring of matplotlib.testing.compare:1" ], - "fc-list": [ - "lib/matplotlib/font_manager.py:docstring of matplotlib.font_manager.get_fontconfig_fonts:2" + "draw_image": [ + "lib/matplotlib/backends/backend_agg.py:docstring of matplotlib.backends.backend_agg:1" ], "figure.bbox": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.legend:128", - "lib/matplotlib/figure.py:docstring of matplotlib.figure.FigureBase.legend:129", - "lib/matplotlib/legend.py:docstring of matplotlib.legend.Legend:88", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figlegend:129", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.legend:128" - ], - "floating_axes.FloatingSubplot": [ - "doc/gallery/axisartist/demo_floating_axes.rst:31" + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.legend:137", + "lib/matplotlib/figure.py:docstring of matplotlib.figure.Figure:116", + "lib/matplotlib/legend.py:docstring of matplotlib.legend.DraggableLegend.finalize_offset:20", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.figlegend:136", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.legend:137" ], "fmt_xdata": [ - "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.format_xdata:4" + "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.format_xdata:1" ], "fmt_ydata": [ - "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.format_ydata:4" - ], - "gaussian_kde": [ - "lib/matplotlib/mlab.py:docstring of matplotlib.mlab.GaussianKDE:32" + "lib/matplotlib/axes/_base.py:docstring of matplotlib.axes._base._AxesBase.format_ydata:1" ], "get_size": [ "lib/mpl_toolkits/axes_grid1/axes_size.py:docstring of mpl_toolkits.axes_grid1.axes_size:1" ], "get_xbound": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.GeoAxes.set_xlim:49", - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim3d:22" - ], - "get_xlim": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.GeoAxes.set_xlim:47" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim:17" ], "get_ybound": [ - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim3d:22" - ], - "h_pad": [ - "lib/matplotlib/figure.py:docstring of matplotlib.figure.Figure.set_constrained_layout:5" - ], - "image": [ - "lib/matplotlib/sphinxext/plot_directive.py:docstring of matplotlib.sphinxext.plot_directive:78" - ], - "interactive": [ - "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg._BackendNbAgg.show:4" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim:17" ], "invert_xaxis": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.GeoAxes.set_xlim:51", - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim3d:24" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim:19" ], "invert_yaxis": [ - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim3d:24" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim:19" ], "ipykernel.pylab.backend_inline": [ - "doc/users/interactive.rst:257" + "doc/users/explain/interactive.rst:255" ], "kde.covariance_factor": [ - "lib/matplotlib/mlab.py:docstring of matplotlib.mlab.GaussianKDE:41" + "lib/matplotlib/mlab.py:docstring of matplotlib.mlab:40" ], "kde.factor": [ - "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.violinplot:46", - "lib/matplotlib/mlab.py:docstring of matplotlib.mlab.GaussianKDE:12", - "lib/matplotlib/mlab.py:docstring of matplotlib.mlab.GaussianKDE:45", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.violinplot:46" - ], - "legend.Legend.set_draggable()": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:499" - ], - "levels": [ - "doc/api/prev_api_changes/api_changes_3.0.0.rst:197" - ], - "load_char": [ - "doc/gallery/misc/ftface_props.rst:25" - ], - "mainloop": [ - "lib/matplotlib/backends/backend_nbagg.py:docstring of matplotlib.backends.backend_nbagg._BackendNbAgg.show:4" + "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.violinplot:41", + "lib/matplotlib/mlab.py:docstring of matplotlib.mlab:11", + "lib/matplotlib/mlab.py:docstring of matplotlib.mlab:44", + "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.violinplot:41" ], "make_image": [ - "lib/matplotlib/image.py:docstring of matplotlib.image.composite_images:9" - ], - "matplotlib.animation.AVConvBase.output_args": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.MovieWriter.isAvailable:1::1" - ], - "matplotlib.animation.AVConvFileWriter.args_key": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvFileWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.clear_temp": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvFileWriter.exec_key": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvFileWriter.finish": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.frame_format": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvFileWriter.frame_size": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvFileWriter.grab_frame": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.output_args": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvFileWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.setup": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:28::1" - ], - "matplotlib.animation.AVConvFileWriter.supported_formats": [ - "doc/api/_as_gen/matplotlib.animation.AVConvFileWriter.rst:39::1" - ], - "matplotlib.animation.AVConvWriter.args_key": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:37::1" - ], - "matplotlib.animation.AVConvWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.exec_key": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:37::1" - ], - "matplotlib.animation.AVConvWriter.finish": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.frame_size": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:37::1" - ], - "matplotlib.animation.AVConvWriter.grab_frame": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.output_args": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:37::1" - ], - "matplotlib.animation.AVConvWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.setup": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:28::1" - ], - "matplotlib.animation.AVConvWriter.supported_formats": [ - "doc/api/_as_gen/matplotlib.animation.AVConvWriter.rst:37::1" + "lib/matplotlib/image.py:docstring of matplotlib.image:5" ], "matplotlib.animation.ArtistAnimation.new_frame_seq": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" ], "matplotlib.animation.ArtistAnimation.new_saved_frame_seq": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" ], "matplotlib.animation.ArtistAnimation.pause": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" + ], + "matplotlib.animation.ArtistAnimation.repeat": [ + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:33::1" ], "matplotlib.animation.ArtistAnimation.resume": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" ], "matplotlib.animation.ArtistAnimation.save": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" ], "matplotlib.animation.ArtistAnimation.to_html5_video": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" ], "matplotlib.animation.ArtistAnimation.to_jshtml": [ - "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:23::1" - ], - "matplotlib.animation.FFMpegFileWriter.args_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegFileWriter.supported_formats:1::1" + "doc/api/_as_gen/matplotlib.animation.ArtistAnimation.rst:28::1" ], "matplotlib.animation.FFMpegFileWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" - ], - "matplotlib.animation.FFMpegFileWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" - ], - "matplotlib.animation.FFMpegFileWriter.clear_temp": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegFileWriter.supported_formats:1::1" - ], - "matplotlib.animation.FFMpegFileWriter.exec_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegFileWriter.supported_formats:1::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:27::1" ], "matplotlib.animation.FFMpegFileWriter.finish": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:27::1" ], "matplotlib.animation.FFMpegFileWriter.frame_format": [ "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegFileWriter.supported_formats:1::1" @@ -950,202 +446,157 @@ "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegFileWriter.supported_formats:1::1" ], "matplotlib.animation.FFMpegFileWriter.grab_frame": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:27::1" ], "matplotlib.animation.FFMpegFileWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:27::1" ], "matplotlib.animation.FFMpegFileWriter.output_args": [ "lib/matplotlib/animation.py:docstring of matplotlib.animation.FFMpegFileWriter.supported_formats:1::1" ], "matplotlib.animation.FFMpegFileWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:27::1" ], "matplotlib.animation.FFMpegFileWriter.setup": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:28::1" - ], - "matplotlib.animation.FFMpegWriter.args_key": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:37::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegFileWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" - ], - "matplotlib.animation.FFMpegWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" - ], - "matplotlib.animation.FFMpegWriter.exec_key": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:37::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.finish": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.frame_size": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:37::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:34::1" ], "matplotlib.animation.FFMpegWriter.grab_frame": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.output_args": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:37::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:34::1" ], "matplotlib.animation.FFMpegWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.setup": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:27::1" ], "matplotlib.animation.FFMpegWriter.supported_formats": [ - "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:37::1" - ], - "matplotlib.animation.FileMovieWriter.args_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.clear_temp:1::1" + "doc/api/_as_gen/matplotlib.animation.FFMpegWriter.rst:34::1" ], "matplotlib.animation.FileMovieWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:28::1" - ], - "matplotlib.animation.FileMovieWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:28::1" - ], - "matplotlib.animation.FileMovieWriter.exec_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.clear_temp:1::1" + "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:27::1" ], "matplotlib.animation.FileMovieWriter.frame_size": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.clear_temp:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.finish:1::1" ], "matplotlib.animation.FileMovieWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:27::1" ], "matplotlib.animation.FileMovieWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.FileMovieWriter.rst:27::1" ], "matplotlib.animation.FileMovieWriter.supported_formats": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.clear_temp:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.FileMovieWriter.finish:1::1" ], "matplotlib.animation.FuncAnimation.pause": [ + "doc/api/_as_gen/matplotlib.animation.FuncAnimation.rst:28::1" + ], + "matplotlib.animation.FuncAnimation.repeat": [ "lib/matplotlib/animation.py:docstring of matplotlib.animation.FuncAnimation.new_frame_seq:1::1" ], "matplotlib.animation.FuncAnimation.resume": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FuncAnimation.new_frame_seq:1::1" + "doc/api/_as_gen/matplotlib.animation.FuncAnimation.rst:28::1" ], "matplotlib.animation.FuncAnimation.save": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FuncAnimation.new_frame_seq:1::1" + "doc/api/_as_gen/matplotlib.animation.FuncAnimation.rst:28::1" ], "matplotlib.animation.FuncAnimation.to_html5_video": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FuncAnimation.new_frame_seq:1::1" + "doc/api/_as_gen/matplotlib.animation.FuncAnimation.rst:28::1" ], "matplotlib.animation.FuncAnimation.to_jshtml": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.FuncAnimation.new_frame_seq:1::1" + "doc/api/_as_gen/matplotlib.animation.FuncAnimation.rst:28::1" ], "matplotlib.animation.HTMLWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.HTMLWriter.rst:28::1" - ], - "matplotlib.animation.HTMLWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.HTMLWriter.rst:28::1" - ], - "matplotlib.animation.HTMLWriter.clear_temp": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.args_key:1::1" - ], - "matplotlib.animation.HTMLWriter.exec_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.args_key:1::1" + "doc/api/_as_gen/matplotlib.animation.HTMLWriter.rst:27::1" ], "matplotlib.animation.HTMLWriter.frame_format": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.args_key:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.finish:1::1" ], "matplotlib.animation.HTMLWriter.frame_size": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.args_key:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.HTMLWriter.finish:1::1" ], "matplotlib.animation.HTMLWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.HTMLWriter.rst:28::1" - ], - "matplotlib.animation.ImageMagickFileWriter.args_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" + "doc/api/_as_gen/matplotlib.animation.HTMLWriter.rst:27::1" ], "matplotlib.animation.ImageMagickFileWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" - ], - "matplotlib.animation.ImageMagickFileWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" - ], - "matplotlib.animation.ImageMagickFileWriter.clear_temp": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:27::1" ], "matplotlib.animation.ImageMagickFileWriter.delay": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" - ], - "matplotlib.animation.ImageMagickFileWriter.exec_key": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickFileWriter.finish": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:27::1" ], "matplotlib.animation.ImageMagickFileWriter.frame_format": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickFileWriter.frame_size": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickFileWriter.grab_frame": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:27::1" ], "matplotlib.animation.ImageMagickFileWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:27::1" ], "matplotlib.animation.ImageMagickFileWriter.output_args": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.supported_formats:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickFileWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickFileWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:27::1" ], "matplotlib.animation.ImageMagickFileWriter.setup": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:28::1" - ], - "matplotlib.animation.ImageMagickWriter.args_key": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:38::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickFileWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.bin_path": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" - ], - "matplotlib.animation.ImageMagickWriter.cleanup": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.delay": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:38::1" - ], - "matplotlib.animation.ImageMagickWriter.exec_key": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:38::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickWriter.finish": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.frame_size": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:38::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickWriter.grab_frame": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.isAvailable": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.output_args": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:38::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickWriter.input_names:1::1" ], "matplotlib.animation.ImageMagickWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.setup": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:27::1" ], "matplotlib.animation.ImageMagickWriter.supported_formats": [ - "doc/api/_as_gen/matplotlib.animation.ImageMagickWriter.rst:38::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.ImageMagickWriter.input_names:1::1" ], "matplotlib.animation.MovieWriter.frame_size": [ - "lib/matplotlib/animation.py:docstring of matplotlib.animation.MovieWriter.args_key:1::1" + "lib/matplotlib/animation.py:docstring of matplotlib.animation.MovieWriter.bin_path:1::1" ], "matplotlib.animation.MovieWriter.saving": [ - "doc/api/_as_gen/matplotlib.animation.MovieWriter.rst:28::1" + "doc/api/_as_gen/matplotlib.animation.MovieWriter.rst:27::1" ], "matplotlib.animation.PillowWriter.frame_size": [ "lib/matplotlib/animation.py:docstring of matplotlib.animation.PillowWriter.finish:1::1" @@ -1154,50 +605,25 @@ "doc/api/_as_gen/matplotlib.animation.PillowWriter.rst:26::1" ], "matplotlib.animation.TimedAnimation.new_frame_seq": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "matplotlib.animation.TimedAnimation.new_saved_frame_seq": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "matplotlib.animation.TimedAnimation.pause": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "matplotlib.animation.TimedAnimation.resume": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "matplotlib.animation.TimedAnimation.save": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "matplotlib.animation.TimedAnimation.to_html5_video": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "matplotlib.animation.TimedAnimation.to_jshtml": [ - "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:23::1" - ], - "matplotlib.colorbar.ColorbarBase.set_clim": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:290" - ], - "matplotlib.colorbar.ColorbarBase.set_cmap": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:290" - ], - "matplotlib.colorbar.ColorbarBase.set_norm": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:290" - ], - "matplotlib.dates.rrulewrapper": [ - "lib/matplotlib/dates.py:docstring of matplotlib.dates:143" - ], - "matplotlib.sphinxext.mathmpl": [ - "doc/users/prev_whats_new/whats_new_3.0.rst:224" - ], - "matplotlib.style.core.STYLE_BLACKLIST": [ - "doc/api/prev_api_changes/api_changes_3.0.0.rst:298", - "lib/matplotlib/__init__.py:docstring of matplotlib.rc_file:4", - "lib/matplotlib/__init__.py:docstring of matplotlib.rc_file_defaults:4", - "lib/matplotlib/__init__.py:docstring of matplotlib.rcdefaults:4", - "lib/matplotlib/pyplot.py:docstring of matplotlib.pyplot.rcdefaults:4" - ], - "matplotlib.testing.conftest.mpl_test_settings": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:947" + "doc/api/_as_gen/matplotlib.animation.TimedAnimation.rst:28::1" ], "mpl_toolkits.axislines.Axes": [ "lib/mpl_toolkits/axisartist/axis_artist.py:docstring of mpl_toolkits.axisartist.axis_artist:7" @@ -1205,69 +631,35 @@ "next_whats_new": [ "doc/users/next_whats_new/README.rst:6" ], - "plot": [ - "lib/matplotlib/sphinxext/plot_directive.py:docstring of matplotlib.sphinxext.plot_directive:4" - ], - "plot_include_source": [ - "lib/matplotlib/sphinxext/plot_directive.py:docstring of matplotlib.sphinxext.plot_directive:52" + "option_scale_image": [ + "lib/matplotlib/backends/backend_cairo.py:docstring of matplotlib.backends.backend_cairo.FigureCanvasCairo:1", + "lib/matplotlib/backends/backend_pdf.py:docstring of matplotlib.backends.backend_pdf.FigureCanvasPdf:1", + "lib/matplotlib/backends/backend_ps.py:docstring of matplotlib.backends.backend_ps.FigureCanvasPS:2", + "lib/matplotlib/backends/backend_template.py:docstring of matplotlib.backends.backend_template:18" ], "print_xyz": [ "lib/matplotlib/backends/backend_template.py:docstring of matplotlib.backends.backend_template:22" ], - "rrulewrapper": [ - "lib/matplotlib/dates.py:docstring of matplotlib.dates:143" - ], - "scipy.stats.norm.pdf": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:569", - "doc/api/prev_api_changes/api_changes_3.1.0.rst:655" - ], - "self.codes": [ - "lib/matplotlib/path.py:docstring of matplotlib.path.Path.codes:2" - ], - "self.vertices": [ - "lib/matplotlib/path.py:docstring of matplotlib.path.Path.codes:2" - ], "set_xbound": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.GeoAxes.set_xlim:49", - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim3d:22" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim:17" ], "set_ybound": [ - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim3d:22" - ], - "streamplot.Grid": [ - "doc/api/prev_api_changes/api_changes_3.1.0.rst:479" - ], - "style.available": [ - "lib/matplotlib/style/__init__.py:docstring of matplotlib.style.core.context:12", - "lib/matplotlib/style/__init__.py:docstring of matplotlib.style.core.use:19" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim:17" ], "toggled": [ - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.AxisScaleBase.disable:4", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.AxisScaleBase.enable:4", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.AxisScaleBase.trigger:2", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.ToolFullScreen.disable:4", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.ToolFullScreen.enable:4", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.ZoomPanBase.trigger:2" + "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools:1" ], "tool_removed_event": [ - "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases.ToolContainerBase.remove_toolitem:6" - ], - "trigger": [ - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.ToolFullScreen.disable:4", - "lib/matplotlib/backend_tools.py:docstring of matplotlib.backend_tools.ToolFullScreen.enable:4" - ], - "w_pad": [ - "lib/matplotlib/figure.py:docstring of matplotlib.figure.Figure.set_constrained_layout:5" + "lib/matplotlib/backend_bases.py:docstring of matplotlib.backend_bases:2" ], "whats_new.rst": [ "doc/users/next_whats_new/README.rst:6" ], "xaxis_inverted": [ - "lib/matplotlib/projections/geo.py:docstring of matplotlib.projections.geo.GeoAxes.set_xlim:51", - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim3d:24" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_xlim:19" ], "yaxis_inverted": [ - "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim3d:24" + "lib/mpl_toolkits/mplot3d/axes3d.py:docstring of mpl_toolkits.mplot3d.axes3d.Axes3D.get_ylim:19" ] } } diff --git a/doc/sphinxext/custom_roles.py b/doc/sphinxext/custom_roles.py index 0734f2b15ac3..3dcecc3df733 100644 --- a/doc/sphinxext/custom_roles.py +++ b/doc/sphinxext/custom_roles.py @@ -1,21 +1,57 @@ +from urllib.parse import urlsplit, urlunsplit + from docutils import nodes -from os.path import sep + from matplotlib import rcParamsDefault -def rcparam_role(name, rawtext, text, lineno, inliner, options={}, content=[]): - rendered = nodes.Text(f'rcParams["{text}"]') +class QueryReference(nodes.Inline, nodes.TextElement): + """ + Wraps a reference or pending reference to add a query string. + + The query string is generated from the attributes added to this node. + + Also equivalent to a `~docutils.nodes.literal` node. + """ + + def to_query_string(self): + """Generate query string from node attributes.""" + return '&'.join(f'{name}={value}' for name, value in self.attlist()) + + +def visit_query_reference_node(self, node): + """ + Resolve *node* into query strings on its ``reference`` children. - source = inliner.document.attributes['source'].replace(sep, '/') - rel_source = source.split('/doc/', 1)[1] + Then act as if this is a `~docutils.nodes.literal`. + """ + query = node.to_query_string() + for refnode in node.findall(nodes.reference): + uri = urlsplit(refnode['refuri'])._replace(query=query) + refnode['refuri'] = urlunsplit(uri) - levels = rel_source.count('/') - refuri = ('../' * levels + - 'tutorials/introductory/customizing.html' + - f"?highlight={text}#a-sample-matplotlibrc-file") + self.visit_literal(node) + + +def depart_query_reference_node(self, node): + """ + Act as if this is a `~docutils.nodes.literal`. + """ + self.depart_literal(node) + + +def rcparam_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + # Generate a pending cross-reference so that Sphinx will ensure this link + # isn't broken at some point in the future. + title = f'rcParams["{text}"]' + target = 'matplotlibrc-sample' + ref_nodes, messages = inliner.interpreted(title, f'{title} <{target}>', + 'ref', lineno) + + qr = QueryReference(rawtext, highlight=text) + qr += ref_nodes + node_list = [qr] - ref = nodes.reference(rawtext, rendered, refuri=refuri) - node_list = [nodes.literal('', '', ref)] # The default backend would be printed as "agg", but that's not correct (as # the default is actually determined by fallback). if text in rcParamsDefault and text != "backend": @@ -24,9 +60,16 @@ def rcparam_role(name, rawtext, text, lineno, inliner, options={}, content=[]): nodes.literal('', repr(rcParamsDefault[text])), nodes.Text(')'), ]) - return node_list, [] + + return node_list, messages def setup(app): app.add_role("rc", rcparam_role) + app.add_node( + QueryReference, + html=(visit_query_reference_node, depart_query_reference_node), + latex=(visit_query_reference_node, depart_query_reference_node), + text=(visit_query_reference_node, depart_query_reference_node), + ) return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/doc/sphinxext/gallery_order.py b/doc/sphinxext/gallery_order.py index 589fed453f06..82c721f3c220 100644 --- a/doc/sphinxext/gallery_order.py +++ b/doc/sphinxext/gallery_order.py @@ -6,7 +6,7 @@ from sphinx_gallery.sorting import ExplicitOrder # Gallery sections shall be displayed in the following order. -# Non-matching sections are appended. +# Non-matching sections are inserted at UNSORTED explicit_order_folders = [ '../examples/lines_bars_and_markers', '../examples/images_contours_and_fields', @@ -28,6 +28,10 @@ '../plot_types/arrays', '../plot_types/stats', '../plot_types/unstructured', + '../plot_types/3D', + 'UNSORTED', + '../examples/userdemo', + '../tutorials/provisional', ] @@ -36,11 +40,9 @@ class MplExplicitOrder(ExplicitOrder): def __call__(self, item): """Return a string determining the sort order.""" if item in self.ordered_list: - return "{:04d}".format(self.ordered_list.index(item)) + return f"{self.ordered_list.index(item):04d}" else: - # ensure not explicitly listed items come last. - return "zzz" + item - + return f"{self.ordered_list.index('UNSORTED'):04d}{item}" # Subsection order: # Subsections are ordered by filename, unless they appear in the following @@ -50,9 +52,9 @@ def __call__(self, item): list_all = [ # **Tutorials** # introductory - "usage", "pyplot", "sample_plots", "images", "lifecycle", "customizing", + "quick_start", "pyplot", "images", "lifecycle", "customizing", # intermediate - "artists", "legend_guide", "color_cycle", "gridspec", + "artists", "legend_guide", "color_cycle", "constrainedlayout_guide", "tight_layout_guide", # advanced # text @@ -77,6 +79,9 @@ def __call__(self, item): "eventplot", "hist2d", "hexbin", "pie", # Unstructured "tricontour", "tricontourf", "tripcolor", "triplot", + # Spines + "spines", "spine_placement_demo", "spines_dropped", + "multiple_yaxis_with_spines", "centered_spines_with_arrows", ] explicit_subsection_order = [item + ".py" for item in list_all] diff --git a/doc/sphinxext/math_symbol_table.py b/doc/sphinxext/math_symbol_table.py index 3041b15b36a8..6609f91bbd10 100644 --- a/doc/sphinxext/math_symbol_table.py +++ b/doc/sphinxext/math_symbol_table.py @@ -1,6 +1,6 @@ from docutils.parsers.rst import Directive -from matplotlib import mathtext +from matplotlib import _mathtext, _mathtext_data symbols = [ @@ -103,9 +103,9 @@ def run(state_machine): def render_symbol(sym): if sym.startswith("\\"): sym = sym[1:] - if sym not in {*mathtext.Parser._overunder_functions, - *mathtext.Parser._function_names}: - sym = chr(mathtext.tex2uni[sym]) + if sym not in (_mathtext.Parser._overunder_functions | + _mathtext.Parser._function_names): + sym = chr(_mathtext_data.tex2uni[sym]) return f'\\{sym}' if sym in ('\\', '|') else sym lines = [] @@ -149,7 +149,6 @@ def setup(app): if __name__ == "__main__": # Do some verification of the tables - from matplotlib import _mathtext_data print("SYMBOLS NOT IN STIX:") all_symbols = {} diff --git a/doc/sphinxext/missing_references.py b/doc/sphinxext/missing_references.py index 6aa82a4dd17d..12d836f296f1 100644 --- a/doc/sphinxext/missing_references.py +++ b/doc/sphinxext/missing_references.py @@ -90,7 +90,7 @@ def get_location(node, app): Usually, this will be of the form "path/to/file:linenumber". Two special values can be emitted, "" for paths which are not contained in this source tree (e.g. docstrings included from - other modules) or "", inidcating that the sphinx application + other modules) or "", indicating that the sphinx application cannot locate the original source file (usually because an extension has injected text into the sphinx parsing engine). """ diff --git a/doc/sphinxext/redirect_from.py b/doc/sphinxext/redirect_from.py index 34f00bf45cb9..f8aee0a3e52a 100644 --- a/doc/sphinxext/redirect_from.py +++ b/doc/sphinxext/redirect_from.py @@ -15,8 +15,10 @@ This creates in the build directory a file ``build/html/topic/old-page.html`` that contains a relative refresh:: + + @@ -38,8 +40,10 @@ logger = logging.getLogger(__name__) -HTML_TEMPLATE = """ +HTML_TEMPLATE = """ + + @@ -66,7 +70,7 @@ class RedirectFromDomain(Domain): @property def redirects(self): - """The mapping of the redirectes.""" + """The mapping of the redirects.""" return self.data.setdefault('redirects', {}) def clear_doc(self, docnames): @@ -79,7 +83,7 @@ def merge_domaindata(self, docnames, otherdata): elif self.redirects[src] != dst: raise ValueError( f"Inconsistent redirections from {src} to " - F"{self.redirects[src]} and {otherdata.redirects[src]}") + f"{self.redirects[src]} and {otherdata['redirects'][src]}") class RedirectFrom(Directive): @@ -88,7 +92,6 @@ class RedirectFrom(Directive): def run(self): redirected_doc, = self.arguments env = self.app.env - builder = self.app.builder domain = env.get_domain('redirect_from') current_doc = env.path2doc(self.state.document.current_source) redirected_reldoc, _ = env.relfn2path(redirected_doc, current_doc) @@ -115,4 +118,4 @@ def _generate_redirects(app, exception): else: logger.info(f'making refresh html file: {k} redirect to {v}') p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(html) + p.write_text(html, encoding='utf-8') diff --git a/doc/users/explain/api_interfaces.rst b/doc/users/explain/api_interfaces.rst new file mode 100644 index 000000000000..89354cc3a13b --- /dev/null +++ b/doc/users/explain/api_interfaces.rst @@ -0,0 +1,290 @@ +.. redirect-from:: /gallery/misc/pythonic_matplotlib + +.. _api_interfaces: + +======================================== +Matplotlib Application Interfaces (APIs) +======================================== + +Matplotlib has two major application interfaces, or styles of using the library: + +- An explicit "Axes" interface that uses methods on a Figure or Axes object to + create other Artists, and build a visualization step by step. This has also + been called an "object-oriented" interface. +- An implicit "pyplot" interface that keeps track of the last Figure and Axes + created, and adds Artists to the object it thinks the user wants. + +In addition, a number of downstream libraries (like `pandas` and xarray_) offer +a ``plot`` method implemented directly on their data classes so that users can +call ``data.plot()``. + +.. _xarray: https://xarray.pydata.org + +The difference between these interfaces can be a bit confusing, particularly +given snippets on the web that use one or the other, or sometimes multiple +interfaces in the same example. Here we attempt to point out how the "pyplot" +and downstream interfaces relate to the explicit "Axes" interface to help users +better navigate the library. + + +Native Matplotlib interfaces +---------------------------- + +The explicit "Axes" interface +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The "Axes" interface is how Matplotlib is implemented, and many customizations +and fine-tuning end up being done at this level. + +This interface works by instantiating an instance of a +`~.matplotlib.figure.Figure` class (``fig`` below), using a method +`~.Figure.subplots` method (or similar) on that object to create one or more +`~.matplotlib.axes.Axes` objects (``ax`` below), and then calling drawing +methods on the Axes (``plot`` in this example): + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + fig = plt.figure() + ax = fig.subplots() + ax.plot([1, 2, 3, 4], [0, 0.5, 1, 0.2]) + +We call this an "explicit" interface because each object is explicitly +referenced, and used to make the next object. Keeping references to the objects +is very flexible, and allows us to customize the objects after they are created, +but before they are displayed. + + +The implicit "pyplot" interface +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The `~.matplotlib.pyplot` module shadows most of the +`~.matplotlib.axes.Axes` plotting methods to give the equivalent of +the above, where the creation of the Figure and Axes is done for the user: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + plt.plot([1, 2, 3, 4], [0, 0.5, 1, 0.2]) + +This can be convenient, particularly when doing interactive work or simple +scripts. A reference to the current Figure can be retrieved using +`~.pyplot.gcf` and to the current Axes by `~.pyplot.gca`. The `~.pyplot` module +retains a list of Figures, and each Figure retains a list of Axes on the figure +for the user so that the following: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + plt.subplot(1, 2, 1) + plt.plot([1, 2, 3], [0, 0.5, 0.2]) + + plt.subplot(1, 2, 2) + plt.plot([3, 2, 1], [0, 0.5, 0.2]) + +is equivalent to: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + plt.subplot(1, 2, 1) + ax = plt.gca() + ax.plot([1, 2, 3], [0, 0.5, 0.2]) + + plt.subplot(1, 2, 2) + ax = plt.gca() + ax.plot([3, 2, 1], [0, 0.5, 0.2]) + +In the explicit interface, this would be: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + fig, axs = plt.subplots(1, 2) + axs[0].plot([1, 2, 3], [0, 0.5, 0.2]) + axs[1].plot([3, 2, 1], [0, 0.5, 0.2]) + +Why be explicit? +^^^^^^^^^^^^^^^^ + +What happens if you have to backtrack, and operate on an old axes that is not +referenced by ``plt.gca()``? One simple way is to call ``subplot`` again with +the same arguments. However, that quickly becomes inelegant. You can also +inspect the Figure object and get its list of Axes objects, however, that can be +misleading (colorbars are Axes too!). The best solution is probably to save a +handle to every Axes you create, but if you do that, why not simply create the +all the Axes objects at the start? + +The first approach is to call ``plt.subplot`` again: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + plt.subplot(1, 2, 1) + plt.plot([1, 2, 3], [0, 0.5, 0.2]) + + plt.subplot(1, 2, 2) + plt.plot([3, 2, 1], [0, 0.5, 0.2]) + + plt.suptitle('Implicit Interface: re-call subplot') + + for i in range(1, 3): + plt.subplot(1, 2, i) + plt.xlabel('Boo') + +The second is to save a handle: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + axs = [] + ax = plt.subplot(1, 2, 1) + axs += [ax] + plt.plot([1, 2, 3], [0, 0.5, 0.2]) + + ax = plt.subplot(1, 2, 2) + axs += [ax] + plt.plot([3, 2, 1], [0, 0.5, 0.2]) + + plt.suptitle('Implicit Interface: save handles') + + for i in range(2): + plt.sca(axs[i]) + plt.xlabel('Boo') + +However, the recommended way would be to be explicit from the outset: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + fig, axs = plt.subplots(1, 2) + axs[0].plot([1, 2, 3], [0, 0.5, 0.2]) + axs[1].plot([3, 2, 1], [0, 0.5, 0.2]) + fig.suptitle('Explicit Interface') + for i in range(2): + axs[i].set_xlabel('Boo') + + +Third-party library "Data-object" interfaces +-------------------------------------------- + +Some third party libraries have chosen to implement plotting for their data +objects, e.g. ``data.plot()``, is seen in `pandas`, xarray_, and other +third-party libraries. For illustrative purposes, a downstream library may +implement a simple data container that has ``x`` and ``y`` data stored together, +and then implements a ``plot`` method: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + # supplied by downstream library: + class DataContainer: + + def __init__(self, x, y): + """ + Proper docstring here! + """ + self._x = x + self._y = y + + def plot(self, ax=None, **kwargs): + if ax is None: + ax = plt.gca() + ax.plot(self._x, self._y, **kwargs) + ax.set_title('Plotted from DataClass!') + return ax + + + # what the user usually calls: + data = DataContainer([0, 1, 2, 3], [0, 0.2, 0.5, 0.3]) + data.plot() + +So the library can hide all the nitty-gritty from the user, and can make a +visualization appropriate to the data type, often with good labels, choices of +colormaps, and other convenient features. + +In the above, however, we may not have liked the title the library provided. +Thankfully, they pass us back the Axes from the ``plot()`` method, and +understanding the explicit Axes interface, we could call: +``ax.set_title('My preferred title')`` to customize the title. + +Many libraries also allow their ``plot`` methods to accept an optional *ax* +argument. This allows us to place the visualization in an Axes that we have +placed and perhaps customized. + +Summary +------- + +Overall, it is useful to understand the explicit "Axes" interface since it is +the most flexible and underlies the other interfaces. A user can usually +figure out how to drop down to the explicit interface and operate on the +underlying objects. While the explicit interface can be a bit more verbose +to setup, complicated plots will often end up simpler than trying to use +the implicit "pyplot" interface. + +.. note:: + + It is sometimes confusing to people that we import ``pyplot`` for both + interfaces. Currently, the ``pyplot`` module implements the "pyplot" + interface, but it also provides top-level Figure and Axes creation + methods, and ultimately spins up the graphical user interface, if one + is being used. So ``pyplot`` is still needed regardless of the + interface chosen. + +Similarly, the declarative interfaces provided by partner libraries use the +objects accessible by the "Axes" interface, and often accept these as arguments +or pass them back from methods. It is usually essential to use the explicit +"Axes" interface to perform any customization of the default visualization, or +to unpack the data into NumPy arrays and pass directly to Matplotlib. + +Appendix: "Axes" interface with data structures +----------------------------------------------- + +Most `~.axes.Axes` methods allow yet another API addressing by passing a +*data* object to the method and specifying the arguments as strings: + +.. plot:: + :include-source: + :align: center + + import matplotlib.pyplot as plt + + data = {'xdat': [0, 1, 2, 3], 'ydat': [0, 0.2, 0.4, 0.1]} + fig, ax = plt.subplots(figsize=(2, 2)) + ax.plot('xdat', 'ydat', data=data) + + +Appendix: "pylab" interface +--------------------------- + +There is one further interface that is highly discouraged, and that is to +basically do ``from matplotlib.pyplot import *``. This allows users to simply +call ``plot(x, y)``. While convenient, this can lead to obvious problems if the +user unwittingly names a variable the same name as a pyplot method. diff --git a/doc/users/explain/backends.rst b/doc/users/explain/backends.rst index 871c4e9d41fb..9b188048de50 100644 --- a/doc/users/explain/backends.rst +++ b/doc/users/explain/backends.rst @@ -107,7 +107,7 @@ allows both interactive work and plotting from scripts, with output to the screen and/or to a file, so at least initially, you will not need to worry about the backend. The most common exception is if your Python distribution comes without :mod:`tkinter` and you have no other GUI toolkit installed. -This happens on certain Linux distributions, where you need to install a +This happens with certain Linux distributions, where you need to install a Linux package named ``python-tk`` (or similar). If, however, you want to write graphical user interfaces, or a web @@ -139,13 +139,13 @@ writing to a file): Renderer Filetypes Description ======== ========= ======================================================= AGG png raster_ graphics -- high quality images using the - `Anti-Grain Geometry`_ engine -PDF pdf vector_ graphics -- `Portable Document Format`_ -PS ps, eps vector_ graphics -- Postscript_ output -SVG svg vector_ graphics -- `Scalable Vector Graphics`_ -PGF pgf, pdf vector_ graphics -- using the pgf_ package + `Anti-Grain Geometry`_ engine. +PDF pdf vector_ graphics -- `Portable Document Format`_ output. +PS ps, eps vector_ graphics -- PostScript_ output. +SVG svg vector_ graphics -- `Scalable Vector Graphics`_ output. +PGF pgf, pdf vector_ graphics -- using the pgf_ package. Cairo png, ps, raster_ or vector_ graphics -- using the Cairo_ library - pdf, svg + pdf, svg (requires pycairo_ or cairocffi_). ======== ========= ======================================================= To save plots using the non-interactive backends, use the @@ -161,16 +161,18 @@ Backend Description ========= ================================================================ QtAgg Agg rendering in a Qt_ canvas (requires PyQt_ or `Qt for Python`_, a.k.a. PySide). This backend can be activated in IPython with - ``%matplotlib qt``. -ipympl Agg rendering embedded in a Jupyter widget. (requires ipympl_). + ``%matplotlib qt``. The Qt binding can be selected via the + :envvar:`QT_API` environment variable; see :ref:`QT_bindings` for + more details. +ipympl Agg rendering embedded in a Jupyter widget (requires ipympl_). This backend can be enabled in a Jupyter notebook with ``%matplotlib ipympl``. -GTK3Agg Agg rendering to a GTK_ 3.x canvas (requires PyGObject_, - and pycairo_ or cairocffi_). This backend can be activated in - IPython with ``%matplotlib gtk3``. -GTK4Agg Agg rendering to a GTK_ 4.x canvas (requires PyGObject_, - and pycairo_ or cairocffi_). This backend can be activated in - IPython with ``%matplotlib gtk4``. +GTK3Agg Agg rendering to a GTK_ 3.x canvas (requires PyGObject_ and + pycairo_). This backend can be activated in IPython with + ``%matplotlib gtk3``. +GTK4Agg Agg rendering to a GTK_ 4.x canvas (requires PyGObject_ and + pycairo_). This backend can be activated in IPython with + ``%matplotlib gtk4``. macosx Agg rendering into a Cocoa canvas in OSX. This backend can be activated in IPython with ``%matplotlib osx``. TkAgg Agg rendering to a Tk_ canvas (requires TkInter_). This @@ -180,10 +182,10 @@ nbAgg Embed an interactive figure in a Jupyter classic notebook. This ``%matplotlib notebook``. WebAgg On ``show()`` will start a tornado server with an interactive figure. -GTK3Cairo Cairo rendering to a GTK_ 3.x canvas (requires PyGObject_, - and pycairo_ or cairocffi_). -GTK4Cairo Cairo rendering to a GTK_ 4.x canvas (requires PyGObject_, - and pycairo_ or cairocffi_). +GTK3Cairo Cairo rendering to a GTK_ 3.x canvas (requires PyGObject_ and + pycairo_). +GTK4Cairo Cairo rendering to a GTK_ 4.x canvas (requires PyGObject_ and + pycairo_). wxAgg Agg rendering to a wxWidgets_ canvas (requires wxPython_ 4). This backend can be activated in IPython with ``%matplotlib wx``. ========= ================================================================ @@ -229,26 +231,12 @@ or See `installing ipympl `__ for more details. -.. _QT_API-usage: - -How do I select the Qt implementation? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The QtAgg and QtCairo backends support both Qt 5 and 6, as well as both Python -bindings (`PyQt`_ or `Qt for Python`_, a.k.a. PySide). If any binding has -already been loaded, then it will be used for the Qt backend. Otherwise, the -first available binding is used, in the order: PyQt6, PySide6, PyQt5, PySide2. - -The :envvar:`QT_API` environment variable can be set to override the search -when nothing has already been loaded. It may be set to (case-insensitively) -PyQt6, PySide6, PyQt5, or PySide2 to pick the version and binding to use. If -the chosen implementation is unavailable, the Qt backend will fail to load -without attempting any other Qt implementations. See :ref:`QT_bindings` for -more details. - Using non-builtin backends -------------------------- More generally, any importable backend can be selected by using any of the methods above. If ``name.of.the.backend`` is the module containing the backend, use ``module://name.of.the.backend`` as the backend name, e.g. ``matplotlib.use('module://name.of.the.backend')``. + +Information for backend implementers is available at +:doc:`/users/explain/writing_a_backend_pyplot_interface`. diff --git a/doc/users/explain/event_handling.rst b/doc/users/explain/event_handling.rst index 239c32a194bb..c7fe7f5ea38d 100644 --- a/doc/users/explain/event_handling.rst +++ b/doc/users/explain/event_handling.rst @@ -82,29 +82,127 @@ Event name Class Description you may encounter inconsistencies between the different user interface toolkits that Matplotlib works with. This is due to inconsistencies/limitations of the user interface toolkit. The following table shows some basic examples of - what you may expect to receive as key(s) from the different user interface toolkits, - where a comma separates different keys: - - ============== ============================= ============================== ============================== ============================== ============================== - Key(s) Pressed WxPython Qt WebAgg Gtk Tkinter - ============== ============================= ============================== ============================== ============================== ============================== - Shift+2 shift, shift+2 shift, " shift, " shift, " shift, " - Shift+F1 shift, shift+f1 shift, shift+f1 shift, shift+f1 shift, shift+f1 shift, shift+f1 - Shift shift shift shift shift shift - Control control control control control control - Alt alt alt alt alt alt - AltGr Nothing Nothing alt iso_level3_shift iso_level3_shift - CapsLock caps_lock caps_lock caps_lock caps_lock caps_lock - A a a A A A - a a a a a a - Shift+a shift, A shift, A shift, A shift, A shift, A - Shift+A shift, A shift, A shift, a shift, a shift, a - Ctrl+Shift+Alt control, ctrl+shift, ctrl+alt control, ctrl+shift, ctrl+meta control, ctrl+shift, ctrl+meta control, ctrl+shift, ctrl+meta control, ctrl+shift, ctrl+meta - Ctrl+Shift+a control, ctrl+shift, ctrl+A control, ctrl+shift, ctrl+A control, ctrl+shift, ctrl+A control, ctrl+shift, ctrl+A control, ctrl+shift, ctrl+a - Ctrl+Shift+A control, ctrl+shift, ctrl+A control, ctrl+shift, ctrl+A control, ctrl+shift, ctrl+a control, ctrl+shift, ctrl+a control, ctrl+shift, ctrl+a - F1 f1 f1 f1 f1 f1 - Ctrl+F1 control, ctrl+f1 control, ctrl+f1 control, ctrl+f1 control, ctrl+f1 control, ctrl+f1 - ============== ============================= ============================== ============================== ============================== ============================== + what you may expect to receive as key(s) (using a QWERTY keyboard layout) + from the different user interface toolkits, where a comma separates different keys: + + .. container:: wide-table + + .. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - Key(s) Pressed + - Tkinter + - Qt + - macosx + - WebAgg + - GTK + - WxPython + * - :kbd:`Shift+2` + - shift, @ + - shift, @ + - shift, @ + - shift, @ + - shift, @ + - shift, shift+2 + * - :kbd:`Shift+F1` + - shift, shift+f1 + - shift, shift+f1 + - shift, shift+f1 + - shift, shift+f1 + - shift, shift+f1 + - shift, shift+f1 + * - :kbd:`Shift` + - shift + - shift + - shift + - shift + - shift + - shift + * - :kbd:`Control` + - control + - control + - control + - control + - control + - control + * - :kbd:`Alt` + - alt + - alt + - alt + - alt + - alt + - alt + * - :kbd:`AltGr` + - iso_level3_shift + - *nothing* + - + - alt + - iso_level3_shift + - *nothing* + * - :kbd:`CapsLock` + - caps_lock + - caps_lock + - caps_lock + - caps_lock + - caps_lock + - caps_lock + * - :kbd:`CapsLock+a` + - caps_lock, A + - caps_lock, a + - caps_lock, a + - caps_lock, A + - caps_lock, A + - caps_lock, a + * - :kbd:`a` + - a + - a + - a + - a + - a + - a + * - :kbd:`Shift+a` + - shift, A + - shift, A + - shift, A + - shift, A + - shift, A + - shift, A + * - :kbd:`CapsLock+Shift+a` + - caps_lock, shift, a + - caps_lock, shift, A + - caps_lock, shift, A + - caps_lock, shift, a + - caps_lock, shift, a + - caps_lock, shift, A + * - :kbd:`Ctrl+Shift+Alt` + - control, ctrl+shift, ctrl+meta + - control, ctrl+shift, ctrl+meta + - control, ctrl+shift, ctrl+alt+shift + - control, ctrl+shift, ctrl+meta + - control, ctrl+shift, ctrl+meta + - control, ctrl+shift, ctrl+alt + * - :kbd:`Ctrl+Shift+a` + - control, ctrl+shift, ctrl+a + - control, ctrl+shift, ctrl+A + - control, ctrl+shift, ctrl+A + - control, ctrl+shift, ctrl+A + - control, ctrl+shift, ctrl+A + - control, ctrl+shift, ctrl+A + * - :kbd:`F1` + - f1 + - f1 + - f1 + - f1 + - f1 + - f1 + * - :kbd:`Ctrl+F1` + - control, ctrl+f1 + - control, ctrl+f1 + - control, *nothing* + - control, ctrl+f1 + - control, ctrl+f1 + - control, ctrl+f1 Matplotlib attaches some keypress callbacks by default for interactivity; they are documented in the :ref:`key-event-handling` section. diff --git a/doc/users/explain/figures.rst b/doc/users/explain/figures.rst new file mode 100644 index 000000000000..f5fb3142d297 --- /dev/null +++ b/doc/users/explain/figures.rst @@ -0,0 +1,215 @@ + +.. _figure_explanation: + +================================================ +Creating, viewing, and saving Matplotlib Figures +================================================ + +.. plot:: + :include-source: + + fig = plt.figure(figsize=(2, 2), facecolor='lightskyblue', + layout='constrained') + fig.suptitle('Figure') + ax = fig.add_subplot() + ax.set_title('Axes', loc='left', fontstyle='oblique', fontsize='medium') + +When looking at Matplotlib visualization, you are almost always looking at +Artists placed on a `~.Figure`. In the example above, the figure is the +blue region and `~.Figure.add_subplot` has added an `~.axes.Axes` artist to the +`~.Figure` (see :ref:`figure_parts`). A more complicated visualization can add +multiple Axes to the Figure, colorbars, legends, annotations, and the Axes +themselves can have multiple Artists added to them +(e.g. ``ax.plot`` or ``ax.imshow``). + +.. _viewing_figures: + +Viewing Figures +================ + +We will discuss how to create Figures in more detail below, but first it is +helpful to understand how to view a Figure. This varies based on how you are +using Matplotlib, and what :ref:`Backend ` you are using. + +Notebooks and IDEs +------------------ + +If you are using a Notebook (e.g. `Jupyter `_) or an IDE +that renders Notebooks (PyCharm, VSCode, etc), then they have a backend that +will render the Matplotlib Figure when a code cell is executed. One thing to +be aware of is that the default Jupyter backend (``%matplotlib inline``) will +by default trim or expand the figure size to have a tight box around Artists +added to the Figure (see :ref:`saving_figures`, below). + +Standalone scripts and interactive use +-------------------------------------- + +If the user is on a client with a windowing system, there are a number of +:ref:`Backends ` that can be used to render the Figure to +the screen, usually using a Python Qt, Tk, or Wx toolkit, though there is a native +MacOS backend as well. These are typically chosen either in the user's +:ref:`matplotlibrc `, or by calling +``matplotlib.use('QtAgg')`` at the beginning of a session or script. + +When run from a script, or interactively (e.g. from an +`iPython shell `_) the Figure +will not be shown until we call ``plt.show()``. The Figure will appear in +a new GUI window, and usually will have a toolbar with Zoom, Pan, and other tools +for interacting with the Figure. By default, ``plt.show()`` blocks +further interaction from the script or shell until the Figure window is closed, +though that can be toggled off for some purposes. For more details, please see +:ref:`controlling-interactive`. + +Note that if you are on a client that does not have access to a windowing +system, the Figure will fallback to being drawn using the "Agg" backend, and +cannot be viewed, though it can be :ref:`saved `. + +.. _creating_figures: + +Creating Figures +================ + +By far the most common way to create a figure is using the +:doc:`pyplot ` interface. As noted in +:ref:`api_interfaces`, the pyplot interface serves two purposes. One is to spin +up the Backend and keep track of GUI windows. The other is a global state for +Axes and Artists that allow a short-form API to plotting methods. In the +example above, we use pyplot for the first purpose, and create the Figure object, +``fig``. As a side effect ``fig`` is also added to pyplot's global state, and +can be accessed via `~.pyplot.gcf`. + +Users typically want an Axes or a grid of Axes when they create a Figure, so in +addition to `~.pyplot.figure`, there are convenience methods that return both +a Figure and some Axes. A simple grid of Axes can be achieved with +`.pyplot.subplots` (which +simply wraps `.Figure.subplots`): + +.. plot:: + :include-source: + + fig, axs = plt.subplots(2, 2, figsize=(4, 3), layout='constrained') + +More complex grids can be achieved with `.pyplot.subplot_mosaic` (which wraps +`.Figure.subplot_mosaic`): + +.. plot:: + :include-source: + + fig, axs = plt.subplot_mosaic([['A', 'right'], ['B', 'right']], + figsize=(4, 3), layout='constrained') + for ax_name in axs: + axs[ax_name].text(0.5, 0.5, ax_name, ha='center', va='center') + +Sometimes we want to have a nested layout in a Figure, with two or more sets of +Axes that do not share the same subplot grid. +We can use `~.Figure.add_subfigure` or `~.Figure.subfigures` to create virtual +figures inside a parent Figure; see +:doc:`/gallery/subplots_axes_and_figures/subfigures` for more details. + +.. plot:: + :include-source: + + fig = plt.figure(layout='constrained', facecolor='lightskyblue') + fig.suptitle('Figure') + figL, figR = fig.subfigures(1, 2) + figL.set_facecolor('thistle') + axL = figL.subplots(2, 1, sharex=True) + axL[1].set_xlabel('x [m]') + figL.suptitle('Left subfigure') + figR.set_facecolor('paleturquoise') + axR = figR.subplots(1, 2, sharey=True) + axR[0].set_title('Axes 1') + figR.suptitle('Right subfigure') + +It is possible to directly instantiate a `.Figure` instance without using the +pyplot interface. This is usually only necessary if you want to create your +own GUI application or service that you do not want carrying the pyplot global +state. See the embedding examples in :ref:`user_interfaces` for examples of +how to do this. + +Figure options +-------------- + +There are a few options available when creating figures. The Figure size on +the screen is set by *figsize* and *dpi*. *figsize* is the ``(width, height)`` +of the Figure in inches (or, if preferred, units of 72 typographic points). *dpi* +are how many pixels per inch the figure will be rendered at. To make your Figures +appear on the screen at the physical size you requested, you should set *dpi* +to the same *dpi* as your graphics system. Note that many graphics systems now use +a "dpi ratio" to specify how many screen pixels are used to represent a graphics +pixel. Matplotlib applies the dpi ratio to the *dpi* passed to the figure to make +it have higher resolution, so you should pass the lower number to the figure. + +The *facecolor*, *edgecolor*, *linewidth*, and *frameon* options all change the appearance of the +figure in expected ways, with *frameon* making the figure transparent if set to *False*. + +Finally, the user can specify a layout engine for the figure with the *layout* +parameter. Currently Matplotlib supplies +:doc:`"constrained" `, +:ref:`"compressed" ` and +:doc:`"tight" ` layout engines. These +rescale axes inside the Figure to prevent overlap of ticklabels, and try and align +axes, and can save significant manual adjustment of artists on a Figure for many +common cases. + +Adding Artists +-------------- + +The `~.FigureBase` class has a number of methods to add artists to a `~.Figure` or +a `~.SubFigure`. By far the most common are to add Axes of various configurations +(`~.FigureBase.add_axes`, `~.FigureBase.add_subplot`, `~.FigureBase.subplots`, +`~.FigureBase.subplot_mosaic`) and subfigures (`~.FigureBase.subfigures`). Colorbars +are added to Axes or group of Axes at the Figure level (`~.FigureBase.colorbar`). +It is also possible to have a Figure-level legend (`~.FigureBase.legend`). +Other Artists include figure-wide labels (`~.FigureBase.suptitle`, +`~.FigureBase.supxlabel`, `~.FigureBase.supylabel`) and text (`~.FigureBase.text`). +Finally, low-level Artists can be added directly using `~.FigureBase.add_artist` +usually with care being taken to use the appropriate transform. Usually these +include ``Figure.transFigure`` which ranges from 0 to 1 in each direction, and +represents the fraction of the current Figure size, or ``Figure.dpi_scale_trans`` +which will be in physical units of inches from the bottom left corner of the Figure +(see :doc:`/tutorials/advanced/transforms_tutorial` for more details). + + +.. _saving_figures: + +Saving Figures +============== + +Finally, Figures can be saved to disk using the `~.Figure.savefig` method. +``fig.savefig('MyFigure.png', dpi=200)`` will save a PNG formatted figure to +the file ``MyFigure.png`` in the current directory on disk with 200 dots-per-inch +resolution. Note that the filename can include a relative or absolute path to +any place on the file system. + +Many types of output are supported, including raster formats like PNG, GIF, JPEG, +TIFF and vector formats like PDF, EPS, and SVG. + +By default, the size of the saved Figure is set by the Figure size (in inches) and, for the raster +formats, the *dpi*. If *dpi* is not set, then the *dpi* of the Figure is used. +Note that *dpi* still has meaning for vector formats like PDF if the Figure includes +Artists that have been :doc:`rasterized `; the +*dpi* specified will be the resolution of the rasterized objects. + +It is possible to change the size of the Figure using the *bbox_inches* argument +to savefig. This can be specified manually, again in inches. However, by far +the most common use is ``bbox_inches='tight'``. This option "shrink-wraps", trimming +or expanding as needed, the size of the figure so that it is tight around all the artists +in a figure, with a small pad that can be specified by *pad_inches*, which defaults to +0.1 inches. The dashed box in the plot below shows the portion of the figure that +would be saved if ``bbox_inches='tight'`` were used in savefig. + +.. plot:: + + import matplotlib.pyplot as plt + from matplotlib.patches import FancyBboxPatch + + fig, ax = plt.subplots(figsize=(4, 2), facecolor='lightskyblue') + ax.set_position([0.1, 0.2, 0.8, 0.7]) + ax.set_aspect(1) + bb = ax.get_tightbbox() + bb = bb.padded(10) + fancy = FancyBboxPatch(bb.p0, bb.width, bb.height, fc='none', + ec=(0, 0.0, 0, 0.5), lw=2, linestyle='--', + transform=None, clip_on=False) + ax.add_patch(fancy) diff --git a/doc/users/explain/fonts.rst b/doc/users/explain/fonts.rst index 9bf2bbc26a2d..1f12c5760d25 100644 --- a/doc/users/explain/fonts.rst +++ b/doc/users/explain/fonts.rst @@ -1,23 +1,27 @@ .. redirect-from:: /users/fonts - -Fonts in Matplotlib text engine -=============================== + +Fonts in Matplotlib +=================== Matplotlib needs fonts to work with its text engine, some of which are shipped -alongside the installation. However, users can configure the default fonts, or -even provide their own custom fonts! For more details, see :doc:`Customizing -text properties `. +alongside the installation. The default font is `DejaVu Sans +`_ which covers most European writing systems. +However, users can configure the default fonts, and provide their own custom +fonts. See :doc:`Customizing text properties ` for +details and :ref:`font-nonlatin` in particular for glyphs not supported by +DejaVu Sans. + +Matplotlib also provides an option to offload text rendering to a TeX engine +(``usetex=True``), see :doc:`Text rendering with LaTeX +`. -However, Matplotlib also provides an option to offload text rendering to a TeX -engine (``usetex=True``), -see :doc:`Text rendering with LaTeX `. +Fonts in PDF and PostScript +--------------------------- -Font specifications -------------------- -Fonts have a long and sometimes incompatible history in computing, leading to -different platforms supporting different types of fonts. In practice, there are -3 types of font specifications Matplotlib supports (in addition to 'core -fonts', more about which is explained later in the guide): +Fonts have a long (and sometimes incompatible) history in computing, leading to +different platforms supporting different types of fonts. In practice, +Matplotlib supports three font specifications (in addition to pdf 'core fonts', +which are explained later in the guide): .. list-table:: Type of Fonts :header-rows: 1 @@ -36,21 +40,22 @@ fonts', more about which is explained later in the guide): - Do not support font hinting - Hinting supported (virtual machine processes the "hints") * - Non-subsetted through Matplotlib - - Subsetted via external module `ttconv `_ - - Subsetted via external module `fonttools `_ + - Subsetted via external module ttconv + - Subsetted via external module `fontTools `__ + +.. note:: -NOTE: Adobe will disable support for authoring with Type 1 fonts in -January 2023. `Read more here. `_ + Adobe disabled__ support for authoring with Type 1 fonts in January 2023. + + __ https://helpx.adobe.com/fonts/kb/postscript-type-1-fonts-end-of-support.html -Special mentions -^^^^^^^^^^^^^^^^ Other font specifications which Matplotlib supports: - Type 42 fonts (PS): - PostScript wrapper around TrueType fonts - 42 is the `Answer to Life, the Universe, and Everything! `_ - - Matplotlib uses an external library called `fonttools `_ + - Matplotlib uses the external library `fontTools `__ to subset these types of fonts - OpenType fonts: @@ -58,54 +63,40 @@ Other font specifications which Matplotlib supports: - OpenType is a new standard for digital type fonts, developed jointly by Adobe and Microsoft - Generally contain a much larger character set! - - Limited Support with Matplotlib - -Subsetting ----------- -Matplotlib is able to generate documents in multiple different formats. Some of -those formats (for example, PDF, PS/EPS, SVG) allow embedding font data in such -a way that when these documents are visually scaled, the text does not appear -pixelated. + - Limited support with Matplotlib -This can be achieved by embedding the *whole* font file within the -output document. However, this can lead to very large documents, as some -fonts (for instance, CJK - Chinese/Japanese/Korean fonts) can contain a large -number of glyphs, and thus their embedded size can be quite huge. +Font subsetting +~~~~~~~~~~~~~~~ -Font Subsetting can be used before generating documents, to embed only the -*required* glyphs within the documents. Fonts can be considered as a collection -of glyphs, so ultimately the goal is to find out *which* glyphs are required -for a certain array of characters, and embed only those within the output. +The PDF and PostScript formats support embedding fonts in files, allowing the +display program to correctly render the text, independent of what fonts are +installed on the viewer's computer and without the need to pre-rasterize the text. +This ensures that if the output is zoomed or resized the text does not become +pixelated. However, embedding full fonts in the file can lead to large output +files, particularly with fonts with many glyphs such as those that support CJK +(Chinese/Japanese/Korean). -.. note:: - The role of subsetter really shines when we encounter characters like **ä** - (composed by calling subprograms for **a** and **¨**); since the subsetter - has to find out *all* such subprograms being called by every glyph included - in the subset, this is a generally difficult problem! +The solution to this problem is to subset the fonts used in the document and +only embed the glyphs actually used. This gets both vector text and small +files sizes. Computing the subset of the font required and writing the new +(reduced) font are both complex problem and thus Matplotlib relies on +`fontTools `__ and a vendored fork +of ttconv. -Luckily, Matplotlib uses a fork of an external dependency called -`ttconv `_, which helps in embedding and -subsetting font data. (however, recent versions have moved away from ttconv to -pure Python for certain types: for more details visit -`these `_, `links `_) +Currently Type 3, Type 42, and TrueType fonts are subsetted. Type 1 fonts are not. -| *Type 1 fonts are still non-subsetted* through Matplotlib. (though one will encounter these mostly via *usetex*/*dviread* in PDF backend) -| **Type 3 and Type 42 fonts are subsetted**, with a fair amount of exceptions and bugs for the latter. +Core Fonts +~~~~~~~~~~ -What to use? ------------- -Practically, most fonts that are readily available on most operating systems or -are readily available on the internet to download include *TrueType fonts* and -its "extensions" such as MacOS-resource fork fonts and the newer OpenType -fonts. +In addition to the ability to embed fonts, as part of the `PostScript +`_ and `PDF +specification +`_ +there are 14 Core Fonts that compliant viewers must ensure are available. If +you restrict your document to only these fonts you do not have to embed any +font information in the document but still get vector text. -PS and PDF backends provide support for yet another type of fonts, which remove -the need of subsetting altogether! These are called **Core Fonts**, and -Matplotlib calls them via the keyword **AFM**; all that is supplied from -Matplotlib to such documents are font metrics (specified in AFM format), and it -is the job of the viewer applications to supply the glyph definitions. - -This is especially helpful to generate *really lightweight* documents.:: +This is especially helpful to generate *really lightweight* documents:: # trigger core fonts for PDF backend plt.rcParams["pdf.use14corefonts"] = True @@ -119,14 +110,84 @@ This is especially helpful to generate *really lightweight* documents.:: fig.savefig("AFM_PDF.pdf", format="pdf") fig.savefig("AFM_PS.ps", format="ps) -.. note:: - These core fonts are limited to PDF and PS backends only; they can not be - rendered in other backends. +Fonts in SVG +------------ + +Text can output to SVG in two ways controlled by :rc:`svg.fonttype`: - Another downside to this is that while the font metrics are standardized, - different PDF viewer applications will have different fonts to render these - metrics. In other words, the **output might look different on different - viewers**, as well as (let's say) Windows and Linux, if Linux tools included - free versions of the proprietary fonts. +- as a path (``'path'``) in the SVG +- as string in the SVG with font styling on the element (``'none'``) + +When saving via ``'path'`` Matplotlib will compute the path of the glyphs used +as vector paths and write those to the output. The advantage of doing so is +that the SVG will look the same on all computers independent of what fonts are +installed. However the text will not be editable after the fact. +In contrast, saving with ``'none'`` will result in smaller files and the +text will appear directly in the markup. However, the appearance may vary +based on the SVG viewer and what fonts are available. + +Fonts in Agg +------------ - This also violates the *what-you-see-is-what-you-get* feature of Matplotlib. +To output text to raster formats via Agg, Matplotlib relies on `FreeType +`_. Because the exact rendering of the glyphs +changes between FreeType versions we pin to a specific version for our image +comparison tests. + +How Matplotlib selects fonts +---------------------------- + +Internally, using a font in Matplotlib is a three step process: + +1. a `.FontProperties` object is created (explicitly or implicitly) +2. based on the `.FontProperties` object the methods on `.FontManager` are used + to select the closest "best" font Matplotlib is aware of (except for + ``'none'`` mode of SVG). +3. the Python proxy for the font object is used by the backend code to render + the text -- the exact details depend on the backend via `.font_manager.get_font`. + +The algorithm to select the "best" font is a modified version of the algorithm +specified by the `CSS1 Specifications +`_ which is used by web browsers. +This algorithm takes into account the font family name (e.g. "Arial", "Noto +Sans CJK", "Hack", ...), the size, style, and weight. In addition to family +names that map directly to fonts there are five "generic font family names" +(serif, monospace, fantasy, cursive, and sans-serif) that will internally be +mapped to any one of a set of fonts. + +Currently the public API for doing step 2 is `.FontManager.findfont` (and that +method on the global `.FontManager` instance is aliased at the module level as +`.font_manager.findfont`), which will only find a single font and return the absolute +path to the font on the filesystem. + +Font fallback +------------- + +There is no font that covers the entire Unicode space thus it is possible for the +users to require a mix of glyphs that can not be satisfied from a single font. +While it has been possible to use multiple fonts within a Figure, on distinct +`.Text` instances, it was not previous possible to use multiple fonts in the +same `.Text` instance (as a web browser does). As of Matplotlib 3.6 the Agg, +SVG, PDF, and PS backends will "fallback" through multiple fonts in a single +`.Text` instance: + +.. plot:: + :include-source: + :caption: The string "There are 几个汉字 in between!" rendered with 2 fonts. + + fig, ax = plt.subplots() + ax.text( + .5, .5, "There are 几个汉字 in between!", + family=['DejaVu Sans', 'WenQuanYi Zen Hei'], + ha='center' + ) + +Internally this is implemented by setting The "font family" on +`.FontProperties` objects to a list of font families. A (currently) +private API extracts a list of paths to all of the fonts found and then +constructs a single `.ft2font.FT2Font` object that is aware of all of the fonts. +Each glyph of the string is rendered using the first font in the list that +contains that glyph. + +A majority of this work was done by Aitik Gupta supported by Google Summer of +Code 2021. diff --git a/doc/users/explain/index.rst b/doc/users/explain/index.rst index f18676613dfc..d70886dc6878 100644 --- a/doc/users/explain/index.rst +++ b/doc/users/explain/index.rst @@ -8,7 +8,10 @@ Explanations .. toctree:: :maxdepth: 2 + api_interfaces.rst + figures.rst backends.rst + writing_a_backend_pyplot_interface.rst interactive.rst fonts.rst event_handling.rst diff --git a/doc/users/explain/interactive.rst b/doc/users/explain/interactive.rst index 8e69fbda3408..43b6327815f9 100644 --- a/doc/users/explain/interactive.rst +++ b/doc/users/explain/interactive.rst @@ -8,9 +8,6 @@ Interactive figures =================== -.. toctree:: - - When working with data, interactivity can be invaluable. The pan/zoom and mouse-location tools built into the Matplotlib GUI windows are often sufficient, but you can also use the event system to build customized data exploration tools. @@ -28,15 +25,15 @@ that include interactive tools, a toolbar, a tool-tip, and :ref:`key bindings `: `.pyplot.figure` - Creates a new empty `.figure.Figure` or selects an existing figure + Creates a new empty `.Figure` or selects an existing figure `.pyplot.subplots` - Creates a new `.figure.Figure` and fills it with a grid of `.axes.Axes` + Creates a new `.Figure` and fills it with a grid of `~.axes.Axes` `.pyplot` has a notion of "The Current Figure" which can be accessed through `.pyplot.gcf` and a notion of "The Current Axes" accessed through `.pyplot.gca`. Almost all of the functions in `.pyplot` pass -through the current `.Figure` / `.axes.Axes` (or create one) as +through the current `.Figure` / `~.axes.Axes` (or create one) as appropriate. Matplotlib keeps a reference to all of the open figures @@ -169,7 +166,7 @@ and run the GUI event loop for the specified period of time. The GUI event loop being integrated with your command prompt and the figures being in interactive mode are independent of each other. -If you use `pyplot.ion` but have not arranged for the event loop integration, +If you try to use `pyplot.ion` without arranging for the event-loop integration, your figures will appear but will not be interactive while the prompt is waiting for input. You will not be able to pan/zoom and the figure may not even render (the window might appear black, transparent, or as a snapshot of the @@ -184,7 +181,7 @@ the GUI main loop in some other way. .. warning:: - Using `.figure.Figure.show` it is possible to display a figure on + Using `.Figure.show` it is possible to display a figure on the screen without starting the event loop and without being in interactive mode. This may work (depending on the GUI toolkit) but will likely result in a non-responsive figure. diff --git a/doc/users/explain/interactive_guide.rst b/doc/users/explain/interactive_guide.rst index 76f014d7b129..9c110967d94a 100644 --- a/doc/users/explain/interactive_guide.rst +++ b/doc/users/explain/interactive_guide.rst @@ -166,8 +166,8 @@ takes over again. This time-share technique only allows the event loop to run while python is otherwise idle and waiting for user input. If you want the GUI to be responsive during long running code it is necessary to -periodically flush the GUI event queue as described :ref:`above -`. In this case it is your code, not the REPL, which +periodically flush the GUI event queue as described in :ref:`spin_event_loop`. +In this case it is your code, not the REPL, which is blocking the process so you need to handle the "time-share" manually. Conversely, a very slow figure draw will block the prompt until it finishes drawing. @@ -213,9 +213,7 @@ Blocking functions ------------------ If you only need to collect points in an Axes you can use -`.figure.Figure.ginput` or more generally the tools from -`.blocking_input` the tools will take care of starting and stopping -the event loop for you. However if you have written some custom event +`.Figure.ginput`. However if you have written some custom event handling or are using `.widgets` you will need to manually run the GUI event loop using the methods described :ref:`above `. @@ -287,7 +285,7 @@ to call `~.FigureCanvasBase.draw_idle` to request that the canvas be re-drawn. This method can be thought of *draw_soon* in analogy to `asyncio.loop.call_soon`. -We can add this our example above as :: +We can add this to our example above as :: def slow_loop(N, ln): for j in range(N): @@ -315,7 +313,7 @@ Artists (as of Matplotlib 1.5) have a **stale** attribute which is `True` if the internal state of the artist has changed since the last time it was rendered. By default the stale state is propagated up to the Artists parents in the draw tree, e.g., if the color of a `.Line2D` -instance is changed, the `.axes.Axes` and `.figure.Figure` that +instance is changed, the `~.axes.Axes` and `.Figure` that contain it will also be marked as "stale". Thus, ``fig.stale`` will report if any artist in the figure has been modified and is out of sync with what is displayed on the screen. This is intended to be used to @@ -332,11 +330,11 @@ which by default is set to a function that forwards the stale state to the artist's parent. If you wish to suppress a given artist from propagating set this attribute to None. -`.figure.Figure` instances do not have a containing artist and their +`.Figure` instances do not have a containing artist and their default callback is `None`. If you call `.pyplot.ion` and are not in ``IPython`` we will install a callback to invoke `~.backend_bases.FigureCanvasBase.draw_idle` whenever the -`.figure.Figure` becomes stale. In ``IPython`` we use the +`.Figure` becomes stale. In ``IPython`` we use the ``'post_execute'`` hook to invoke `~.backend_bases.FigureCanvasBase.draw_idle` on any stale figures after having executed the user's input, but before returning the prompt @@ -424,17 +422,17 @@ based prompt to a ``prompt_toolkit`` based prompt. ``prompt_toolkit`` has the same conceptual input hook, which is fed into ``prompt_toolkit`` via the :meth:`IPython.terminal.interactiveshell.TerminalInteractiveShell.inputhook` method. The source for the ``prompt_toolkit`` input hooks lives at -:mod:`IPython.terminal.pt_inputhooks`. +``IPython.terminal.pt_inputhooks``. .. rubric:: Footnotes .. [#f1] A limitation of this design is that you can only wait for one - input, if there is a need to multiplex between multiple sources - then the loop would look something like :: + input, if there is a need to multiplex between multiple sources + then the loop would look something like :: - fds = [...] + fds = [...] while True: # Loop inp = select(fds).read() # Read eval(inp) # Evaluate / Print @@ -443,6 +441,6 @@ method. The source for the ``prompt_toolkit`` input hooks lives at `__ if you must. .. [#f3] These examples are aggressively dropping many of the - complexities that must be dealt with in the real world such as - keyboard interrupts, timeouts, bad input, resource - allocation and cleanup, etc. + complexities that must be dealt with in the real world such as + keyboard interrupts, timeouts, bad input, resource + allocation and cleanup, etc. diff --git a/doc/users/explain/performance.rst b/doc/users/explain/performance.rst index aa291f407c1e..03bff404b7ab 100644 --- a/doc/users/explain/performance.rst +++ b/doc/users/explain/performance.rst @@ -55,7 +55,6 @@ simplification) and another style for publication quality plotting :doc:`/tutorials/introductory/customizing` for instructions on how to perform these actions. - The simplification works by iteratively merging line segments into a single vector until the next line segment's perpendicular distance to the vector (measured in display-coordinate space) @@ -67,18 +66,14 @@ is greater than the ``path.simplify_threshold`` parameter. parameters prior to 2.1, but rendering time for some kinds of data will be vastly improved in versions 2.1 and greater. -Marker simplification ---------------------- - -Markers can also be simplified, albeit less robustly than -line segments. Marker simplification is only available -to :class:`~matplotlib.lines.Line2D` objects (through the -``markevery`` property). Wherever -:class:`~matplotlib.lines.Line2D` construction parameters -are passed through, such as -:func:`matplotlib.pyplot.plot` and -:meth:`matplotlib.axes.Axes.plot`, the ``markevery`` -parameter can be used:: +Marker subsampling +------------------ + +Markers can also be simplified, albeit less robustly than line +segments. Marker subsampling is only available to `.Line2D` objects +(through the ``markevery`` property). Wherever `.Line2D` construction +parameters are passed through, such as `.pyplot.plot` and `.Axes.plot`, +the ``markevery`` parameter can be used:: plt.plot(x, y, markevery=10) diff --git a/doc/users/explain/writing_a_backend_pyplot_interface.rst b/doc/users/explain/writing_a_backend_pyplot_interface.rst new file mode 100644 index 000000000000..e193135f970d --- /dev/null +++ b/doc/users/explain/writing_a_backend_pyplot_interface.rst @@ -0,0 +1,82 @@ +========================================= +Writing a backend -- the pyplot interface +========================================= + +This page assumes general understanding of the information in the +:doc:`/users/explain/backends` page, and is instead intended as reference for +third-party backend implementers. It also only deals with the interaction +between backends and `.pyplot`, not with the rendering side, which is described +in `.backend_template`. + +There are two APIs for defining backends: a new canvas-based API (introduced in +Matplotlib 3.6), and an older function-based API. The new API is simpler to +implement because many methods can be inherited from "parent backends". It is +recommended if back-compatibility for Matplotlib < 3.6 is not a concern. +However, the old API remains supported. + +Fundamentally, a backend module needs to provide information to `.pyplot`, so +that + +1. `.pyplot.figure()` can create a new `.Figure` instance and associate it with + an instance of a backend-provided canvas class, itself hosted in an instance + of a backend-provided manager class. +2. `.pyplot.show()` can show all figures and start the GUI event loop (if any). + +To do so, the backend module must define a ``backend_module.FigureCanvas`` +subclass of `.FigureCanvasBase`. In the canvas-based API, this is the only +strict requirement for backend modules. The function-based API additionally +requires many module-level functions to be defined. + +Canvas-based API (Matplotlib >= 3.6) +------------------------------------ + +1. **Creating a figure**: `.pyplot.figure()` calls + ``figure = Figure(); FigureCanvas.new_manager(figure, num)`` + (``new_manager`` is a classmethod) to instantiate a canvas and a manager and + set up the ``figure.canvas`` and ``figure.canvas.manager`` attributes. + Figure unpickling uses the same approach, but replaces the newly + instantiated ``Figure()`` by the unpickled figure. + + Interactive backends should customize the effect of ``new_manager`` by + setting the ``FigureCanvas.manager_class`` attribute to the desired manager + class, and additionally (if the canvas cannot be created before the manager, + as in the case of the wx backends) by overriding the + ``FigureManager.create_with_canvas`` classmethod. (Non-interactive backends + can normally use a trivial ``FigureManagerBase`` and can therefore skip this + step.) + + After a new figure is registered with `.pyplot` (either via + `.pyplot.figure()` or via unpickling), if in interactive mode, `.pyplot` + will call its canvas' ``draw_idle()`` method, which can be overridden as + desired. + +2. **Showing figures**: `.pyplot.show()` calls + ``FigureCanvas.manager_class.pyplot_show()`` (a classmethod), forwarding any + arguments, to start the main event loop. + + By default, ``pyplot_show()`` checks whether there are any ``managers`` + registered with `.pyplot` (exiting early if not), calls ``manager.show()`` + on all such managers, and then, if called with ``block=True`` (or with + the default ``block=None`` and out of IPython's pylab mode and not in + interactive mode), calls ``FigureCanvas.manager_class.start_main_loop()`` + (a classmethod) to start the main event loop. Interactive backends should + therefore override the ``FigureCanvas.manager_class.start_main_loop`` + classmethod accordingly (or alternatively, they may also directly override + ``FigureCanvas.manager_class.pyplot_show`` directly). + +Function-based API +------------------ + +1. **Creating a figure**: `.pyplot.figure()` calls + ``new_figure_manager(num, *args, **kwargs)`` (which also takes care of + creating the new figure as ``Figure(*args, **kwargs)``); unpickling calls + ``new_figure_manager_given_figure(num, figure)``. + + Furthermore, in interactive mode, the first draw of the newly registered + figure can be customized by providing a module-level + ``draw_if_interactive()`` function. (In the new canvas-based API, this + function is not taken into account anymore.) + +2. **Showing figures**: `.pyplot.show()` calls a module-level ``show()`` + function, which is typically generated via the ``ShowBase`` class and its + ``mainloop`` method. diff --git a/doc/users/faq/environment_variables_faq.rst b/doc/users/faq/environment_variables_faq.rst index 36460ae1b0cd..fb9341db1147 100644 --- a/doc/users/faq/environment_variables_faq.rst +++ b/doc/users/faq/environment_variables_faq.rst @@ -48,7 +48,7 @@ Environment variables .. envvar:: QT_API The Python Qt wrapper to prefer when using Qt-based backends. See :ref:`the - entry in the usage guide ` for more information. + entry in the usage guide ` for more information. .. _setting-linux-osx-environment-variables: diff --git a/doc/users/faq/howto_faq.rst b/doc/users/faq/howto_faq.rst index 409b9e04e713..fd4e81f32d00 100644 --- a/doc/users/faq/howto_faq.rst +++ b/doc/users/faq/howto_faq.rst @@ -89,8 +89,8 @@ way of defining empty, and checking the above is only rarely necessary. Usually the user or program handling the figure know if they have added something to the figure. -Checking whether a figure would render empty cannot be reliably checked except -by actually rendering the figure and investigating the rendered result. +The only reliable way to check whether a figure would render empty is to +actually perform such a rendering and inspect the result. .. _howto-findobj: @@ -117,7 +117,7 @@ You can also filter on class instances:: for o in fig.findobj(text.Text): o.set_fontstyle('italic') -.. _howto-supress_offset: +.. _howto-suppress_offset: Prevent ticklabels from having an offset ---------------------------------------- @@ -187,7 +187,7 @@ multiple ways to fix this: - tight layout (:doc:`/tutorials/intermediate/tight_layout_guide`) - Calculate good values from the size of the plot elements yourself - (:doc:`/gallery/pyplots/auto_subplots_adjust`) + (:doc:`/gallery/subplots_axes_and_figures/auto_subplots_adjust`) .. _howto-align-label: @@ -203,8 +203,8 @@ behavior by specifying the coordinates of the label. The example below shows the default behavior in the left subplots, and the manual setting in the right subplots. -.. figure:: ../../gallery/pyplots/images/sphx_glr_align_ylabels_001.png - :target: ../../gallery/pyplots/align_ylabels.html +.. figure:: ../../gallery/text_labels_and_annotations/images/sphx_glr_align_ylabels_001.png + :target: ../../gallery/text_labels_and_annotations/align_ylabels.html :align: center :scale: 50 diff --git a/doc/users/faq/index.rst b/doc/users/faq/index.rst index 124b1a7f561a..636c90904aba 100644 --- a/doc/users/faq/index.rst +++ b/doc/users/faq/index.rst @@ -3,7 +3,7 @@ .. redirect-from:: /faq/index ########################## -How-to and troubleshooting +How-to and troubleshooting ########################## .. only:: html diff --git a/doc/users/faq/troubleshooting_faq.rst b/doc/users/faq/troubleshooting_faq.rst index aedd108f5430..f88cb19d1395 100644 --- a/doc/users/faq/troubleshooting_faq.rst +++ b/doc/users/faq/troubleshooting_faq.rst @@ -84,7 +84,8 @@ Getting help There are a number of good resources for getting help with Matplotlib. There is a good chance your question has already been asked: -- The `mailing list archive `_. +- The `mailing list archive + `_. - `GitHub issues `_. @@ -156,7 +157,7 @@ the tracker so the issue doesn't get lost. Problems with recent git versions ================================= -First make sure you have a clean build and install (see :ref:`clean-install`), +First, make sure you have a clean build and install (see :ref:`clean-install`), get the latest git update, install it and run a simple test script in debug mode:: diff --git a/doc/users/getting_started/index.rst b/doc/users/getting_started/index.rst index 8ce5491ce19e..ec1f769e5e78 100644 --- a/doc/users/getting_started/index.rst +++ b/doc/users/getting_started/index.rst @@ -4,9 +4,9 @@ Getting started Installation quick-start ------------------------ -.. container:: twocol +.. grid:: 1 1 2 2 - .. container:: + .. grid-item:: Install using `pip `__: @@ -14,13 +14,13 @@ Installation quick-start pip install matplotlib - .. container:: + .. grid-item:: Install using `conda `__: .. code-block:: bash - conda install matplotlib + conda install -c conda-forge matplotlib Further details are available in the :doc:`Installation Guide `. @@ -44,7 +44,7 @@ Here is a minimal example plot: ax.plot(x, y) plt.show() -If a plot does not show up please check :ref:`troubleshooting-faq`. +If a plot does not show up please check :ref:`troubleshooting-faq`. Where to go next ---------------- @@ -52,4 +52,4 @@ Where to go next - Check out :doc:`Plot types ` to get an overview of the types of plots you can create with Matplotlib. - Learn Matplotlib from the ground up in the - :doc:`Quick-start guide `. + :doc:`Quick-start guide `. diff --git a/doc/users/github_stats.rst b/doc/users/github_stats.rst index 7e9651da8294..cbeb72270f2e 100644 --- a/doc/users/github_stats.rst +++ b/doc/users/github_stats.rst @@ -1,338 +1,684 @@ .. _github-stats: -GitHub statistics for 3.5.2 (May 02, 2022) -========================================== +GitHub statistics (Feb 13, 2023) +================================ -GitHub statistics for 2021/12/11 (tag: v3.5.1) - 2022/05/02 +GitHub statistics for 2022/09/16 (tag: v3.6.0) - 2023/02/13 These lists are automatically generated, and may be incomplete or contain duplicates. -We closed 61 issues and merged 222 pull requests. -The full list can be seen `on GitHub `__ +We closed 120 issues and merged 427 pull requests. +The full list can be seen `on GitHub `__ -The following 30 authors contributed 319 commits. +The following 112 authors contributed 1962 commits. -* Adeel Hassan -* Aitik Gupta -* Andrew Fennell -* andrzejnovak +* Abhijnan Bajpai +* Adrien F. Vincent +* Ahoy Ahoy +* Akshit Tyagi +* Ali Meshkat +* Almar Klein +* Andrés Martínez +* Ante Sikic * Antony Lee -* Clément Phan -* daniilS -* David Poznik +* Augustin LAVILLE +* baharev +* cargobuild +* Carsten Schnober +* Chahak Mehta +* Charisma Kausar * David Stansby * dependabot[bot] -* Edouard Berthe +* DerWeh +* Eero Vaher * Elliott Sales de Andrade +* Eric Larson +* Eric Prestat +* erykoff +* EunHo Lee +* Felix Goudreault * Greg Lucas -* Hassan Kibirige -* Jake VanderPlas +* hannah +* Ian Hunt-Isaak +* Ian Thomas +* intellizEHL +* iofall +* j1642 +* jacoverster +* Jae-Joon Lee +* Jakub Klus +* James Braza * Jay Stanley +* Jef Myers +* jeffreypaul15 +* Jefro * Jody Klymak -* MAKOMO -* Matthias Bussonnier -* Niyas Sait +* John Paul Jepko +* Joseph Fox-Rabinovitz +* Joshua Barrass +* Julian Chen +* Junaid Khan +* Justin Tracey +* Kaidong Hu +* Kanza +* Karan +* Kian Eliasi +* kolibril13 +* Kostya Farber +* Krutarth Patel +* Kyle Sunden +* Leo Singer +* Lucas Ricci +* luke +* Marc Van den Bossche +* Martok +* Marvvxi +* Matthew Feickert +* Mauricio Collares +* MeeseeksMachine +* melissawm +* Mikhail Ryazanov +* Muhammad Abdur Rakib +* noatamir +* NRaudseps +* Olivier Castany * Oscar Gustafsson -* Pieter P -* Qijia Liu -* Quentin Peter -* Raphael Quast +* parthpankajtiwary +* Paul Seyfert +* Pavel Grunt +* Pieter Eendebak +* PIotr Strzelczyk +* Pratim Ugale +* pre-commit-ci[bot] +* ramvikrams * richardsheridan -* root +* Ruth Comer +* Ryan May +* saranti +* Scott Shambaugh +* Shabnam Sadegh +* Shawn Zhong +* Simon Waldherr +* Skhaki18 +* slackline +* Snipeur060 +* Sourajita Dewasi +* SourajitaDewasi +* Stefanie Molin * Steffen Rehberg +* Sven Eschlbeck +* sveneschlbeck +* takimata +* tfpf * Thomas A Caswell +* Tiger Nie * Tim Hoffmann +* Tom +* Tortar +* tsumli +* tybeller +* vdbma +* Vishal Pankaj Chandratreya +* vivekvedant +* whyvra +* yuanx749 +* zhizheng1 +* مهدي شينون (Mehdi Chinoune) GitHub issues and pull requests: -Pull Requests (222): +Pull Requests (427): -* :ghpull:`22963`: Backport PR #22957 on branch v3.5.x (fix "is" comparison for np.array) -* :ghpull:`22951`: Backport PR #22946: FIX: Handle no-offsets in collection datalim -* :ghpull:`22957`: fix "is" comparison for np.array -* :ghpull:`22962`: Backport PR #22961 on branch v3.5.x (Raised macosx memory leak threshold) -* :ghpull:`22961`: Raised macosx memory leak threshold -* :ghpull:`22945`: FIX: Handle no-offsets in collection datalim -* :ghpull:`22946`: FIX: Handle no-offsets in collection datalim (alternative) -* :ghpull:`22944`: Backport PR #22907 on branch v3.5.x (Fix quad mesh cursor data) -* :ghpull:`22943`: Backport PR #22923 on branch v3.5.x (Fixed _upcast_err docstring and comments in _axes.py) -* :ghpull:`22907`: Fix quad mesh cursor data -* :ghpull:`22923`: Fixed _upcast_err docstring and comments in _axes.py -* :ghpull:`22876`: Backport PR #22560 on branch v3.5.x (Improve pandas/xarray/... conversion) -* :ghpull:`22942`: Backport PR #22933 on branch v3.5.x (Adjusted wording in pull request guidelines) -* :ghpull:`22941`: Backport PR #22898 on branch v3.5.x (Only set Tk scaling-on-map for Windows systems) -* :ghpull:`22935`: Backport PR #22002: Fix TkAgg memory leaks and test for memory growth regressions -* :ghpull:`22898`: Only set Tk scaling-on-map for Windows systems -* :ghpull:`22933`: Adjusted wording in pull request guidelines -* :ghpull:`22002`: Fix TkAgg memory leaks and test for memory growth regressions -* :ghpull:`22924`: Fix gtk4 incorrect import. -* :ghpull:`22922`: Backport PR #22904 on branch v3.5.x (Fixed typo in triage acknowledgment) -* :ghpull:`22904`: Fixed typo in triage acknowledgment -* :ghpull:`22890`: DOC: add ipykernel to list of optional dependencies -* :ghpull:`22878`: Backport PR #22871 on branch v3.5.x (Fix year offset not always being added) -* :ghpull:`22871`: Fix year offset not always being added -* :ghpull:`22844`: Backport PR #22313 on branch v3.5.x (Fix colorbar exponents) -* :ghpull:`22560`: Improve pandas/xarray/... conversion -* :ghpull:`22846`: Backport PR #22284 on branch v3.5.x (Specify font number for TTC font subsetting) -* :ghpull:`22284`: Specify font number for TTC font subsetting -* :ghpull:`22845`: Backport PR #22199 on branch v3.5.x (DOC: git:// is deprecated.) -* :ghpull:`22837`: Backport PR #22807 on branch v3.5.x (Replace quiver dpi callback with reinit-on-dpi-changed.) -* :ghpull:`22838`: Backport PR #22806 on branch v3.5.x (FIX: callback for subfigure uses parent) -* :ghpull:`22832`: Backport PR #22767 on branch v3.5.x (Fixed bug in find_nearest_contour) -* :ghpull:`22767`: Fixed bug in find_nearest_contour -* :ghpull:`22807`: Replace quiver dpi callback with reinit-on-dpi-changed. -* :ghpull:`22806`: FIX: callback for subfigure uses parent -* :ghpull:`22737`: Backport PR #22138: Fix clearing subfigures -* :ghpull:`22735`: MNT: prefer Figure.clear() as canonical over Figure.clf() -* :ghpull:`22783`: Backport PR #22732: FIX: maybe improve renderer dance -* :ghpull:`22748`: Backport PR #22628 on branch v3.5.x (Add RuntimeWarning guard around division-by-zero) -* :ghpull:`22732`: FIX: maybe improve renderer dance -* :ghpull:`22764`: Backport PR #22756 on branch v3.5.x (Use system distutils instead of the setuptools copy) -* :ghpull:`22780`: Backport PR #22766 on branch v3.5.x (FIX: account for constant deprecations in Pillow 9.1) -* :ghpull:`22781`: Backport PR #22776 on branch v3.5.x (Fix colorbar stealing from a single axes and with panchor=False.) -* :ghpull:`22782`: Backport PR #22774 on branch v3.5.x (Remove outdated doc for pie chart) -* :ghpull:`22774`: Remove outdated doc for pie chart -* :ghpull:`22776`: Fix colorbar stealing from a single axes and with panchor=False. -* :ghpull:`22766`: FIX: account for deprecations of constant in Pillow 9.1 -* :ghpull:`22756`: Use system distutils instead of the setuptools copy -* :ghpull:`22750`: Backport PR #22743: Fix configure_subplots with tool manager -* :ghpull:`22743`: Fix configure_subplots with tool manager -* :ghpull:`22628`: Add RuntimeWarning guard around division-by-zero -* :ghpull:`22736`: Backport PR #22719 on branch v3.5.x (Fix incorrect deprecation warning) -* :ghpull:`22719`: Fix incorrect deprecation warning -* :ghpull:`22138`: Fix clearing subfigures -* :ghpull:`22729`: Backport PR #22711 on branch v3.5.x (RangeSlider handle set_val bugfix) -* :ghpull:`22711`: RangeSlider handle set_val bugfix -* :ghpull:`22701`: Backport PR #22691 on branch v3.5.x (FIX: remove toggle on QuadMesh cursor data) -* :ghpull:`22723`: Backport PR #22716 on branch v3.5.x (DOC: set canonical) -* :ghpull:`22703`: Backport PR #22689 on branch v3.5.x (Fix path_effects to work on text with spaces only) -* :ghpull:`22689`: Fix path_effects to work on text with spaces only -* :ghpull:`22691`: FIX: remove toggle on QuadMesh cursor data -* :ghpull:`22696`: Backport PR #22693 on branch v3.5.x (Remove QuadMesh from mouseover set.) -* :ghpull:`22693`: Remove QuadMesh from mouseover set. -* :ghpull:`22647`: Backport PR #22429 on branch v3.5.x (Enable windows/arm64 platform) -* :ghpull:`22653`: Simplify FreeType version check to avoid packaging -* :ghpull:`22646`: Manual backport of pr 22635 on v3.5.x -* :ghpull:`22429`: Enable windows/arm64 platform -* :ghpull:`22635`: FIX: Handle inverted colorbar axes with extensions -* :ghpull:`22313`: Fix colorbar exponents -* :ghpull:`22619`: Backport PR #22611 on branch v3.5.x (FIX: Colorbars check for subplotspec attribute before using) -* :ghpull:`22618`: Backport PR #22617 on branch v3.5.x (Bump actions/checkout from 2 to 3) -* :ghpull:`22611`: FIX: Colorbars check for subplotspec attribute before using -* :ghpull:`22617`: Bump actions/checkout from 2 to 3 -* :ghpull:`22595`: Backport PR #22005: Further defer backend selection -* :ghpull:`22602`: Backport PR #22596 on branch v3.5.x (Fix backend in matplotlibrc if unset in mplsetup.cfg) -* :ghpull:`22596`: Fix backend in matplotlibrc if unset in mplsetup.cfg -* :ghpull:`22597`: Backport PR #22594 on branch v3.5.x (FIX: do not pass dashes to collections in errorbar) -* :ghpull:`22594`: FIX: do not pass dashes to collections in errorbar -* :ghpull:`22593`: Backport PR #22559 on branch v3.5.x (fix: fill stairs should have lw=0 instead of edgecolor="none") -* :ghpull:`22005`: Further defer backend selection -* :ghpull:`22559`: fix: fill stairs should have lw=0 instead of edgecolor="none" -* :ghpull:`22592`: Backport PR #22141 on branch v3.5.x (Fix check 1d) -* :ghpull:`22141`: Fix check 1d -* :ghpull:`22588`: Backport PR #22445 on branch v3.5.x (Fix loading tk on windows when current process has >1024 modules.) -* :ghpull:`22445`: Fix loading tk on windows when current process has >1024 modules. -* :ghpull:`22575`: Backport PR #22572 on branch v3.5.x (Fix issue with unhandled Done exception) -* :ghpull:`22578`: Backport PR #22038 on branch v3.5.x (DOC: Include alternatives to deprecations in the documentation) -* :ghpull:`22572`: Fix issue with unhandled Done exception -* :ghpull:`22557`: Backport PR #22549 on branch v3.5.x (Really fix wheel building on CI) -* :ghpull:`22549`: Really fix wheel building on CI -* :ghpull:`22548`: Backport PR #22540 on branch v3.5.x (Reorder text api docs.) -* :ghpull:`22540`: Reorder text api docs. -* :ghpull:`22542`: Backport PR #22534 on branch v3.5.x (Fix issue with manual clabel) -* :ghpull:`22534`: Fix issue with manual clabel -* :ghpull:`22501`: Backport PR #22499 on branch v3.5.x (FIX: make the show API on webagg consistent with others) -* :ghpull:`22499`: FIX: make the show API on webagg consistent with others -* :ghpull:`22500`: Backport PR #22496 on branch v3.5.x (Fix units in quick start example) -* :ghpull:`22496`: Fix units in quick start example -* :ghpull:`22493`: Backport PR #22483 on branch v3.5.x (Tweak arrow demo size.) -* :ghpull:`22492`: Backport PR #22476: FIX: Include (0, 0) offsets in scatter autoscaling -* :ghpull:`22483`: Tweak arrow demo size. -* :ghpull:`22476`: FIX: Include (0, 0) offsets in scatter autoscaling -* :ghpull:`22481`: Backport PR #22479 on branch v3.5.x (adds _enum qualifier for QColorDialog.ShowAlphaChannel. Closes #22471.) -* :ghpull:`22479`: adds _enum qualifier for QColorDialog.ShowAlphaChannel. Closes #22471. -* :ghpull:`22475`: Backport PR #22474 on branch v3.5.x (Clarify secondary_axis documentation) -* :ghpull:`22474`: Clarify secondary_axis documentation -* :ghpull:`22462`: Backport PR #22458 on branch v3.5.x (Fix Radar Chart Gridlines for Non-Circular Charts) -* :ghpull:`22456`: Backport PR #22375 on branch v3.5.x (Re-enable cibuildwheel on push) -* :ghpull:`22375`: Re-enable cibuildwheel on push -* :ghpull:`22443`: Backport PR #22442 on branch v3.5.x (CI: skip test to work around gs bug) -* :ghpull:`22442`: CI: skip test to work around gs bug -* :ghpull:`22441`: Backport PR #22434 on branch v3.5.x (DOC: imbalanced backticks.) -* :ghpull:`22436`: Backport PR #22431 on branch v3.5.x (Update Scipy intersphinx inventory link) -* :ghpull:`22438`: Backport PR #22430 on branch v3.5.x (fix method name in doc) -* :ghpull:`22434`: DOC: imbalanced backticks. -* :ghpull:`22426`: Backport PR #22398 on branch v3.5.x (Pin coverage to fix CI) -* :ghpull:`22428`: Backport PR #22368 on branch v3.5.x (Pin dependencies to fix CI) -* :ghpull:`22427`: Backport PR #22396 on branch v3.5.x (Clarify note in get_cmap()) -* :ghpull:`22396`: Clarify note in get_cmap() -* :ghpull:`22398`: Pin coverage to fix CI -* :ghpull:`22368`: Pin dependencies to fix CI -* :ghpull:`22358`: Backport PR #22349 on branch v3.5.x (Use latex as the program name for kpsewhich) -* :ghpull:`22349`: Use latex as the program name for kpsewhich -* :ghpull:`22348`: Backport PR #22346 on branch v3.5.x (Remove invalid ```` tag in ``animation.HTMLWriter``) -* :ghpull:`22346`: Remove invalid ```` tag in ``animation.HTMLWriter`` -* :ghpull:`22328`: Backport PR #22288 on branch v3.5.x (update documentation after #18966) -* :ghpull:`22288`: update documentation after #18966 -* :ghpull:`22325`: Backport PR #22283: Fixed ``repr`` for ``SecondaryAxis`` -* :ghpull:`22322`: Backport PR #22077 on branch v3.5.x (Fix keyboard event routing in Tk backend (fixes #13484, #14081, and #22028)) -* :ghpull:`22321`: Backport PR #22290 on branch v3.5.x (Respect ``position`` and ``group`` argument in Tk toolmanager add_toolitem) -* :ghpull:`22318`: Backport PR #22293 on branch v3.5.x (Modify example for x-axis tick labels at the top) -* :ghpull:`22319`: Backport PR #22279 on branch v3.5.x (Remove Axes sublists from docs) -* :ghpull:`22327`: Backport PR #22326 on branch v3.5.x (CI: ban coverage 6.3 that may be causing random hangs in fork test) -* :ghpull:`22326`: CI: ban coverage 6.3 that may be causing random hangs in fork test -* :ghpull:`22077`: Fix keyboard event routing in Tk backend (fixes #13484, #14081, and #22028) -* :ghpull:`22290`: Respect ``position`` and ``group`` argument in Tk toolmanager add_toolitem -* :ghpull:`22293`: Modify example for x-axis tick labels at the top -* :ghpull:`22311`: Backport PR #22285 on branch v3.5.x (Don't warn on grid removal deprecation if grid is hidden) -* :ghpull:`22310`: Backport PR #22294 on branch v3.5.x (Add set_cursor method to FigureCanvasTk) -* :ghpull:`22285`: Don't warn on grid removal deprecation if grid is hidden -* :ghpull:`22294`: Add set_cursor method to FigureCanvasTk -* :ghpull:`22309`: Backport PR #22301 on branch v3.5.x (FIX: repositioning axes labels: use get_window_extent instead for spines.) -* :ghpull:`22301`: FIX: repositioning axes labels: use get_window_extent instead for spines. -* :ghpull:`22307`: Backport PR #22306 on branch v3.5.x (FIX: ensure that used sub-packages are actually imported) -* :ghpull:`22306`: FIX: ensure that used sub-packages are actually imported -* :ghpull:`22283`: Fixed ``repr`` for ``SecondaryAxis`` -* :ghpull:`22275`: Backport PR #22254 on branch v3.5.x (Disable QuadMesh cursor data by default) -* :ghpull:`22254`: Disable QuadMesh cursor data by default -* :ghpull:`22269`: Backport PR #22265 on branch v3.5.x (Fix Qt enum access.) -* :ghpull:`22265`: Fix Qt enum access. -* :ghpull:`22259`: Backport PR #22256 on branch v3.5.x (Skip tests on the -doc branches) -* :ghpull:`22238`: Backport PR #22235 on branch v3.5.x (Run wheel builds on PRs when requested by a label) -* :ghpull:`22241`: Revert "Backport PR #22179 on branch v3.5.x (FIX: macosx check case-insensitive app name)" -* :ghpull:`22248`: Backport PR #22206 on branch v3.5.x (Improve formatting of "Anatomy of a figure") -* :ghpull:`22235`: Run wheel builds on PRs when requested by a label -* :ghpull:`22206`: Improve formatting of "Anatomy of a figure" -* :ghpull:`22220`: Backport PR #21833: Enforce backport conditions on v*-doc branches -* :ghpull:`22219`: Backport PR #22218 on branch v3.5.x (Fix typo in ``tutorials/intermediate/arranging_axes.py``) -* :ghpull:`22218`: Fix typo in ``tutorials/intermediate/arranging_axes.py`` -* :ghpull:`22217`: Backport PR #22209 on branch v3.5.x (DOC: Document default join style) -* :ghpull:`22209`: DOC: Document default join style -* :ghpull:`22214`: Backport PR #22208 on branch v3.5.x (Stop sorting artists in Figure Options dialog) -* :ghpull:`22215`: Backport PR #22177 on branch v3.5.x (Document ArtistList) -* :ghpull:`22177`: Document ArtistList -* :ghpull:`22208`: Stop sorting artists in Figure Options dialog -* :ghpull:`22199`: DOC: git:// is deprecated. -* :ghpull:`22210`: Backport PR #22202 on branch v3.5.x (PR: Fix merge of 18966) -* :ghpull:`22202`: PR: Fix merge of 18966 -* :ghpull:`22201`: Backport PR #22053 on branch v3.5.x (DOC: Document default cap styles) -* :ghpull:`22053`: DOC: Document default cap styles -* :ghpull:`22195`: Backport PR #22179 on branch v3.5.x (FIX: macosx check case-insensitive app name) -* :ghpull:`22192`: Backport PR #22190 on branch v3.5.x (DOC: Fix upstream URL for merge in CircleCI) -* :ghpull:`22188`: Backport PR #22187 on branch v3.5.x (Fix typo in ``axhline`` docstring) -* :ghpull:`22187`: Fix typo in ``axhline`` docstring -* :ghpull:`22185`: Backport PR #22184 on branch v3.5.x (Removed dev from 3.10-version) -* :ghpull:`22186`: Backport PR #21943 on branch v3.5.x (DOC: explain too many ticks) -* :ghpull:`21943`: DOC: explain too many ticks -* :ghpull:`22184`: Removed dev from 3.10-version -* :ghpull:`22168`: Backport PR #22144 on branch v3.5.x (Fix cl subgridspec) -* :ghpull:`22144`: Fix cl subgridspec -* :ghpull:`22155`: Backport PR #22082 on branch v3.5.x (Update both zoom/pan states on wx when triggering from keyboard.) -* :ghpull:`22082`: Update both zoom/pan states on wx when triggering from keyboard. -* :ghpull:`22153`: Backport PR #22147 on branch v3.5.x (Fix loading user-defined icons for Tk toolbar) -* :ghpull:`22152`: Backport PR #22135 on branch v3.5.x (Fix loading user-defined icons for Qt plot window) -* :ghpull:`22151`: Backport PR #22078 on branch v3.5.x (Prevent tooltips from overlapping buttons in NavigationToolbar2Tk (fixes issue mentioned in #22028)) -* :ghpull:`22135`: Fix loading user-defined icons for Qt plot window -* :ghpull:`22078`: Prevent tooltips from overlapping buttons in NavigationToolbar2Tk (fixes issue mentioned in #22028) -* :ghpull:`22147`: Fix loading user-defined icons for Tk toolbar -* :ghpull:`22136`: Backport PR #22132 on branch v3.5.x (TST: Increase fp tolerances for some images) -* :ghpull:`22132`: TST: Increase fp tolerances for some images -* :ghpull:`22121`: Backport PR #22116 on branch v3.5.x (FIX: there is no add_text method, fallback to add_artist) -* :ghpull:`22117`: Backport PR #21860 on branch v3.5.x (DOC: Update style sheet reference) -* :ghpull:`22116`: FIX: there is no add_text method, fallback to add_artist -* :ghpull:`22038`: DOC: Include alternatives to deprecations in the documentation -* :ghpull:`22074`: Backport PR #22066 on branch v3.5.x (FIX: Remove trailing zeros from offset significand) -* :ghpull:`22106`: Backport PR #22089: FIX: squash memory leak in colorbar -* :ghpull:`22089`: FIX: squash memory leak in colorbar -* :ghpull:`22101`: Backport PR #22099 on branch v3.5.x (CI: Disable numpy avx512 instructions) -* :ghpull:`22099`: CI: Disable numpy avx512 instructions -* :ghpull:`22095`: Backport PR #22083 on branch v3.5.x (Fix reference to Matplotlib FAQ in doc/index.rst) -* :ghpull:`22066`: FIX: Remove trailing zeros from offset significand -* :ghpull:`22072`: Backport PR #22071 on branch v3.5.x (Fix a small typo in docstring ("loation" --> "location")) -* :ghpull:`22071`: Fix a small typo in docstring ("loation" --> "location") -* :ghpull:`22070`: Backport PR #22069 on branch v3.5.x ([Doc] Fix typo in ``units.py`` documentation example) -* :ghpull:`22069`: [Doc] Fix typo in ``units.py`` documentation example -* :ghpull:`22067`: Backport PR #22064 on branch v3.5.x (DOC: Clarify y parameter in Axes.set_title) -* :ghpull:`22064`: DOC: Clarify y parameter in Axes.set_title -* :ghpull:`22049`: Backport PR #22048 on branch v3.5.x (Document how to prevent TeX from treating ``&``, ``#`` as special.) -* :ghpull:`22048`: Document how to prevent TeX from treating ``&``, ``#`` as special. -* :ghpull:`22047`: Backport PR #22044 on branch v3.5.x (Get correct source code link for decorated functions) -* :ghpull:`22044`: Get correct source code link for decorated functions -* :ghpull:`22024`: Backport PR #22009 on branch v3.5.x (FIX: Prevent set_alpha from changing color of legend patch) -* :ghpull:`22009`: FIX: Prevent set_alpha from changing color of legend patch -* :ghpull:`22019`: Backport PR #22018 on branch v3.5.x (BUG: fix handling of zero-dimensional arrays in cbook._reshape_2D) -* :ghpull:`22018`: BUG: fix handling of zero-dimensional arrays in cbook._reshape_2D -* :ghpull:`21996`: Backport PR #21990 on branch v3.5.x (Fix rubberbanding on wx+py3.10.) -* :ghpull:`21990`: Fix rubberbanding on wx+py3.10. -* :ghpull:`21987`: Backport PR #21862 on branch v3.5.x (DOC: Simplify markevery demo) -* :ghpull:`21969`: Backport PR #21948 on branch v3.5.x (Distinguish AbstractMovieWriter and MovieWriter in docs.) -* :ghpull:`21948`: Distinguish AbstractMovieWriter and MovieWriter in docs. -* :ghpull:`21953`: Backport PR #21946 on branch v3.5.x (DOC: fix interactive to not put Event Handling and Interactive Guide …) -* :ghpull:`21946`: DOC: fix interactive to not put Event Handling and Interactive Guide … +* :ghpull:`25201`: Backport PR #25196 on branch v3.7.x (Add deprecation for setting data with non sequence type in ``Line2D``) +* :ghpull:`25196`: Add deprecation for setting data with non sequence type in ``Line2D`` +* :ghpull:`25197`: Backport PR #25193 on branch v3.7.x (Fix displacement of colorbar for eps with bbox_inches='tight') +* :ghpull:`25193`: Fix displacement of colorbar for eps with bbox_inches='tight' +* :ghpull:`24781`: DOC: restore SHA to footer +* :ghpull:`25188`: Backport PR #25085 on branch v3.7.x (FIX: only try to update blit caches if the canvas we expect) +* :ghpull:`25170`: Backport PR #25097 on branch v3.7.x (fix FigureCanvasTkAgg memory leak via weakrefs) +* :ghpull:`25186`: Backport PR #24893 on branch v3.7.x (STY: make allowed line length 9 longer to 88 from 79) +* :ghpull:`25185`: Backport PR #25183 on branch v3.7.x (FIX: do not use deprecated API internally) +* :ghpull:`25184`: Backport PR #25174 on branch v3.7.x (Accept LA icons for the toolbar) +* :ghpull:`25085`: FIX: only try to update blit caches if the canvas we expect +* :ghpull:`25183`: FIX: do not use deprecated API internally +* :ghpull:`25182`: Backport PR #25052 on branch v3.7.x (Support both Bbox and list for bbox to table/Table) +* :ghpull:`25174`: Accept LA icons for the toolbar +* :ghpull:`25052`: Support both Bbox and list for bbox to table/Table +* :ghpull:`25095`: Backport PR #23442 on branch v3.7.x (Remove need to detect math mode in pgf strings) +* :ghpull:`25097`: fix FigureCanvasTkAgg memory leak via weakrefs +* :ghpull:`25167`: Backport PR #25122 on branch v3.7.x (FIX: scaling factor for window with negative value) +* :ghpull:`25122`: FIX: scaling factor for window with negative value +* :ghpull:`25161`: Backport PR #25158 on branch v3.7.x (Disconnect SubplotTool destroyer callback on tool_fig close) +* :ghpull:`25160`: Backport PR #25129 on branch v3.7.x (Undeprecate Cursor event handlers) +* :ghpull:`25158`: Disconnect SubplotTool destroyer callback on tool_fig close +* :ghpull:`25129`: Undeprecate Cursor event handlers +* :ghpull:`25154`: Backport PR #25151 on branch v3.7.x (Increase timeout to GitHub API) +* :ghpull:`25151`: Increase timeout to GitHub API +* :ghpull:`25136`: Backport PR #25126 on branch v3.7.x (FIX: fully invalidate TransformWrapper parents before swapping) +* :ghpull:`25132`: Backport PR #24993 on branch v3.7.x ([DOC] GitHub spelling and links) +* :ghpull:`25126`: FIX: fully invalidate TransformWrapper parents before swapping +* :ghpull:`24993`: [DOC] GitHub spelling and links +* :ghpull:`25118`: Backport PR #25113 on branch v3.7.x (Fix outdated comment re: _update_label_position.) +* :ghpull:`25113`: Fix outdated comment re: _update_label_position. +* :ghpull:`25111`: Backport PR #25110 on branch v3.7.x (Stop recommending ``ncol`` in legend examples) +* :ghpull:`25110`: Stop recommending ``ncol`` in legend examples +* :ghpull:`25106`: Fix cursor_demo wrt. Line2D.set_x/ydata not accepting scalars anymore. +* :ghpull:`25103`: Backport PR #25098 on branch v3.7.x (Correctly pass valinit as keyword in SliderTool.) +* :ghpull:`25098`: Correctly pass valinit as keyword in SliderTool. +* :ghpull:`23442`: Remove need to detect math mode in pgf strings +* :ghpull:`25093`: Backport PR #25092 on branch v3.7.x (Fix distribution of test data) +* :ghpull:`24893`: STY: make allowed line length 9 longer to 88 from 79 +* :ghpull:`25092`: Fix distribution of test data +* :ghpull:`25089`: Backport PR #25088 on branch v3.7.x (DOC: Fix broken cross-reference when building PDF) +* :ghpull:`25088`: DOC: Fix broken cross-reference when building PDF +* :ghpull:`25083`: Backport PR #25074 on branch v3.7.x (Revert "Use system distutils instead of the setuptools copy") +* :ghpull:`25082`: Backport PR #25079 on branch v3.7.x (FIX: Only send one update signal when autoscaling norms) +* :ghpull:`25084`: DOC: Fix typos in GitHub stats +* :ghpull:`25074`: Revert "Use system distutils instead of the setuptools copy" +* :ghpull:`25079`: FIX: Only send one update signal when autoscaling norms +* :ghpull:`25072`: Merge v3.6.x into v3.7.x +* :ghpull:`25071`: Backport PR #25039 on branch v3.7.x (Updated WebAgg JS to check and send request over wss if using HTTPS) +* :ghpull:`25039`: Updated WebAgg JS to check and send request over wss if using HTTPS +* :ghpull:`25070`: Backport PR #25058 on branch v3.7.x (fix for pcolormesh doesn't allow shading = 'flat' in the option) +* :ghpull:`25058`: fix for pcolormesh doesn't allow shading = 'flat' in the option +* :ghpull:`25067`: Backport PR #25054 on branch v3.7.x (Remove note that mathtext.fontset = "custom" is unsupported.) +* :ghpull:`25066`: Backport PR #24999 on branch v3.7.x (DOC: figure explanation) +* :ghpull:`25054`: Remove note that mathtext.fontset = "custom" is unsupported. +* :ghpull:`25065`: Backport PR #24838 on branch v3.7.x (Add styling support to Check and Radio buttons ) +* :ghpull:`24999`: DOC: figure explanation +* :ghpull:`24838`: Add styling support to Check and Radio buttons +* :ghpull:`25056`: Backport PR #25055 on branch v3.7.x (Reword awkward sentence in FAQ.) +* :ghpull:`25055`: Reword awkward sentence in FAQ. +* :ghpull:`25049`: Backport PR #25047 on branch v3.7.x (Remove dead code from deprecated-and-removed block) +* :ghpull:`25047`: Remove dead code from deprecated-and-removed block +* :ghpull:`25037`: Backport PR #25018 on branch v3.7.x (Simplify "artist reference" example.) +* :ghpull:`25018`: Simplify "artist reference" example. +* :ghpull:`25034`: Backport PR #24812 on branch v3.7.x ([Doc] expanded basic pie example) +* :ghpull:`24812`: [Doc] expanded basic pie example +* :ghpull:`25029`: Backport PR #25019 on branch v3.7.x (Tweak titles pyplot examples.) +* :ghpull:`25019`: Tweak titles pyplot examples. +* :ghpull:`25026`: Backport PR #25017 on branch v3.7.x (Capitalize headings in example Gallery) +* :ghpull:`25017`: Capitalize headings in example Gallery +* :ghpull:`25010`: Backport PR #24989 on branch v3.7.x (Suppress pyparsing warning) +* :ghpull:`25008`: Backport PR #25004 on branch v3.7.x (Bump pypa/cibuildwheel from 2.11.4 to 2.12.0) +* :ghpull:`24989`: Suppress pyparsing warning +* :ghpull:`25004`: Bump pypa/cibuildwheel from 2.11.4 to 2.12.0 +* :ghpull:`25001`: Backport PR #25000 on branch v3.7.x (Update matplotlibrc urls) +* :ghpull:`25000`: Update matplotlibrc urls +* :ghpull:`24977`: Backport PR #24970 on branch v3.7.x (FIX: Handle uint8 indices properly for colormap lookups) +* :ghpull:`24970`: FIX: Handle uint8 indices properly for colormap lookups +* :ghpull:`24975`: Backport PR #24971 on branch v3.7.x (FIX: adjust_bbox should not modify layout engine) +* :ghpull:`24974`: Backport PR #24973 on branch v3.7.x (MNT: Fix double % signs in matplotlibrc) +* :ghpull:`24966`: Backport PR #24965 on branch v3.7.x (Remove additional deprecations from 3.5) +* :ghpull:`24971`: FIX: adjust_bbox should not modify layout engine +* :ghpull:`24973`: MNT: Fix double % signs in matplotlibrc +* :ghpull:`24965`: Remove additional deprecations from 3.5 +* :ghpull:`24963`: Backport PR #24912 on branch v3.7.x (Remove contour warning for "no-valid-levels".) +* :ghpull:`24962`: Backport PR #24957 on branch v3.7.x (DOC: Enable Opensearch) +* :ghpull:`24961`: Backport PR #24948 on branch v3.7.x (Remove remaining deprecations from 3.5) +* :ghpull:`24959`: Backport PR #24254 on branch v3.7.x (Expire deprecations in widgets and keyword only arguments for Selectors) +* :ghpull:`24912`: Remove contour warning for "no-valid-levels". +* :ghpull:`24960`: Backport PR #24825 on branch v3.7.x (Allow non-default scales on polar axes) +* :ghpull:`24957`: DOC: Enable Opensearch +* :ghpull:`24948`: Remove remaining deprecations from 3.5 +* :ghpull:`24825`: Allow non-default scales on polar axes +* :ghpull:`24254`: Expire deprecations in widgets and keyword only arguments for Selectors +* :ghpull:`24956`: Backport PR #24955 on branch v3.7.x (Cleanup bullseye plot example.) +* :ghpull:`24955`: Cleanup bullseye plot example. +* :ghpull:`24949`: Backport PR #24918 on branch v3.7.x (DOC: animation faster) +* :ghpull:`24947`: Auto backport of pr 24897 on v3.7.x +* :ghpull:`24945`: Backport PR #24940 on branch v3.7.x ([MNT] specify which gallery sections come last) +* :ghpull:`24918`: DOC: animation faster +* :ghpull:`24917`: Backport PR #24897: DOC: Add ref for every under examples/animation +* :ghpull:`24940`: [MNT] specify which gallery sections come last +* :ghpull:`24941`: Backport PR #24655 on branch v3.7.x (Update font_manager to only use registry on Win) +* :ghpull:`24655`: Update font_manager to only use registry on Win +* :ghpull:`24937`: Backport PR #24470 on branch v3.7.x ([ENH] hatch keyword for pie + some pie documentation) +* :ghpull:`24938`: Backport PR #23390 on branch v3.7.x (FIX: colorbar contour with log norm should default to log locator and formatter...) +* :ghpull:`24935`: Backport PR #24934 on branch v3.7.x (Swap ipython directives for code-block directives) +* :ghpull:`24470`: [ENH] hatch keyword for pie + some pie documentation +* :ghpull:`24933`: Backport PR #24924 on branch v3.7.x (Fix toggling layout engines) +* :ghpull:`24934`: Swap ipython directives for code-block directives +* :ghpull:`24931`: Backport PR #24783 on branch v3.7.x (inset locator fix with tests added) +* :ghpull:`24924`: Fix toggling layout engines +* :ghpull:`24928`: Backport PR #24927 on branch v3.7.x (DOC: Remove space after directive name, before double-colon) +* :ghpull:`24926`: Backport PR #24925 on branch v3.7.x (DOC: Improve documentation for set_loglevel) +* :ghpull:`24925`: DOC: Improve documentation for set_loglevel +* :ghpull:`24922`: Backport PR #24921 on branch v3.7.x (Pin sphinx != 6.1.2) +* :ghpull:`24921`: Pin sphinx != 6.1.2 +* :ghpull:`24911`: Backport PR #24904 on branch v3.7.x (Deprecate AxisArtistHelpers with inconsistent loc/nth_coord.) +* :ghpull:`24897`: DOC: Add ref for every under examples/animation +* :ghpull:`24904`: Deprecate AxisArtistHelpers with inconsistent loc/nth_coord. +* :ghpull:`22314`: Add a helper to generate xy coordinates for AxisArtistHelper. +* :ghpull:`24841`: changed method in animation tutorial table of methods +* :ghpull:`24902`: Remove provisional note from pyplot.subplot_mosaic +* :ghpull:`24891`: DOC: mark mosaic as no longer provisional +* :ghpull:`24889`: Harmonize exceptions for unknown keyword arguments. +* :ghpull:`24085`: Set facecolor of FilledArrow axisline style and fix tight layout +* :ghpull:`19743`: ENH: allow fig.legend outside axes... +* :ghpull:`24887`: [MNT] Bump NumPy to 1.20 +* :ghpull:`24896`: changed contribute docs link to writing docs +* :ghpull:`24894`: DOC: explain clipbox a bit better +* :ghpull:`24864`: Deprecate BrokenBarHCollection. +* :ghpull:`24869`: Skip displaying pan/zoom navigate mode in toolbar. +* :ghpull:`24892`: FIX: error in formatting in error string in redirect extension +* :ghpull:`24895`: add new & improved doc notices to what's new +* :ghpull:`24888`: update install instructions for conda +* :ghpull:`24886`: CI: rotate the circleci deploy key +* :ghpull:`24879`: Document "." as a filled marker. +* :ghpull:`24870`: Better default bool contour levels. +* :ghpull:`24786`: Increase a few test tolerances on some arches +* :ghpull:`24863`: Add parameter doc to PolarTransform +* :ghpull:`24845`: Fix toggling of MultiCursor.{horizOn,vertOn} +* :ghpull:`24862`: Fix argument checking in ``Axes3D.quiver`` +* :ghpull:`24868`: [pre-commit.ci] pre-commit autoupdate +* :ghpull:`24840`: Simplify/robustify segment-point distance calculation. +* :ghpull:`24850`: Improve PolarAffine docstring +* :ghpull:`24851`: Variable rename t > theta +* :ghpull:`24763`: Allow polar scales where zero is not in valid interval +* :ghpull:`24846`: Promote pending cm deprecations to full deprecations +* :ghpull:`24848`: ``Collection.set_linestyle``: remove redundant string handling +* :ghpull:`24839`: Move geo/polar projections to their own pages +* :ghpull:`24727`: Handle argument "facecolors=None" correctly in plot_surface() +* :ghpull:`24847`: Avoid extra copy initializing empty Affine2D +* :ghpull:`24837`: DOC: Replace .format by f-strings in examples +* :ghpull:`24604`: Enh/extend mosaic kwargs +* :ghpull:`24131`: Deprecate attributes and expire deprecation in animation +* :ghpull:`23457`: Add blitting support to button widgets +* :ghpull:`24832`: [MNT] Improve variable naming in bar +* :ghpull:`24829`: Simplify shape-checking in QuadMesh.set_array. +* :ghpull:`24835`: Delay nightly wheel builds by 2 hours +* :ghpull:`24831`: [Doc] Fix ndarray-links for arguments +* :ghpull:`24824`: Fix incorrect method in doc +* :ghpull:`24826`: space in version added for reverse in legend +* :ghpull:`24819`: Bump pypa/cibuildwheel from 2.11.3 to 2.11.4 +* :ghpull:`24811`: removed casting handles to list in legend +* :ghpull:`24759`: Reverse legend +* :ghpull:`24465`: Reparametrize offsetbox calculations in terms of bboxes. +* :ghpull:`22316`: Arbitrary figure customization hooks. +* :ghpull:`22329`: Enforce that Line data modifications are sequences +* :ghpull:`24730`: Data access API for rcParams +* :ghpull:`24699`: Implement nested four-level TeX cache +* :ghpull:`24752`: DOC: Make event handling table scrollable +* :ghpull:`24637`: Fixes #20044 pass AnnotationBbox to renderer +* :ghpull:`24810`: Don't modify dictionary input to widgets +* :ghpull:`24769`: Improve matplotlib.axes documentation +* :ghpull:`24806`: Deprecate 'x' argument for widgets.TextBox.begin_typing +* :ghpull:`24293`: Handle rasterization start & stop only from Artist +* :ghpull:`24768`: Fix/zorder rasterization +* :ghpull:`24474`: Use scatter for check boxes and set facecolors correctly in check boxes and radio buttons +* :ghpull:`24262`: Fix issue with space allocated for single tick that should not be there +* :ghpull:`24780`: Update environment.yml +* :ghpull:`23576`: Soft deprecate the textpath module (import from text instead) +* :ghpull:`24750`: Fix deprecations of \*Cursor widget event handlers +* :ghpull:`24757`: Allow using masked in ``set_offsets`` +* :ghpull:`21661`: Fix plot directive with func calls +* :ghpull:`24803`: Correct type in docstring of zorder for streamplot and LineCollection +* :ghpull:`24801`: Correct docstring of RangeSlider.on_changed +* :ghpull:`24802`: Correct docstring of CheckButtons.get_status +* :ghpull:`24758`: MNT: Simplify code related to masked arrays +* :ghpull:`24756`: DOC: Simplify some table markup +* :ghpull:`24795`: DOC: Fix duplicate redirect +* :ghpull:`24782`: DOC: update typos and grammar errors +* :ghpull:`24794`: Update README.md +* :ghpull:`24071`: Deprecate undefined label_mode to Grid +* :ghpull:`24724`: Run delvewheel on Windows for wheels +* :ghpull:`24538`: [Doc] Document legend_handles and legend_handlers +* :ghpull:`24751`: DOC: Update Artist inheritance diagram +* :ghpull:`24761`: Don't set the never-used Line2D._contains in set_picker. +* :ghpull:`24760`: Remove unused dicts from backend_cairo. +* :ghpull:`24736`: DOC: simplify CheckButton example +* :ghpull:`22700`: MAINT: Move docstring of ``LogLocator`` to class +* :ghpull:`19763`: Remove visibility changes in draw for \*Cursor widgets +* :ghpull:`23473`: Separately track modifier keys for mouse events. +* :ghpull:`24748`: DOC: remove research notice +* :ghpull:`24734`: Support masked dates +* :ghpull:`24737`: MNT: make fig.colorbar(..., ax=INPUT) even more forgiving +* :ghpull:`24120`: don't try to start a new event loop in WebAgg when in an ipykernel +* :ghpull:`24362`: Allow bool-like values for sharex/sharey +* :ghpull:`24740`: Minor redundancy cleanup of code which sets 3D aspect 3D +* :ghpull:`22273`: Improve inheritance diagrams +* :ghpull:`24668`: Add test for remaining axis options +* :ghpull:`9598`: ENH: rely on non-rectangular patch paths rather than bboxes for legend auto-placing (fix #9580) +* :ghpull:`22920`: Mnt deprecate mlab +* :ghpull:`24408`: Fix: restore make_axes to accept a tuple of axes +* :ghpull:`24731`: DOC: Post warnings as reviews on PRs +* :ghpull:`24652`: Offsetbox default arguments +* :ghpull:`24720`: FIX: be more forgiving in default draw wrapper +* :ghpull:`24719`: Remove quotes from EngFormatter.format_eng example +* :ghpull:`24718`: Remove refresh function from polar ThetaLocator +* :ghpull:`24710`: Drop support for Qt<5.10. +* :ghpull:`24509`: Factor out & improve accuracy of derivatives calculations in axisartist. +* :ghpull:`19591`: reverse order in which stackplot elements are added to axes +* :ghpull:`24367`: STY: Update macosx zoom rect styling +* :ghpull:`24706`: Bump pypa/cibuildwheel from 2.11.2 to 2.11.3 +* :ghpull:`24705`: Cleanup a few examples. +* :ghpull:`21096`: FIX: improve symlog ticker +* :ghpull:`24498`: DOC: Update multiple category bar chart examples +* :ghpull:`24688`: Deprecate quiver_doc and barbs_doc class members +* :ghpull:`24526`: [Doc] Fix spelling and grammar in tutorials +* :ghpull:`24675`: TST: set style in mpl_toolkits to ease later transition +* :ghpull:`24484`: Artist's draw method prevents rasterization by default +* :ghpull:`24667`: Test scroll zoom bbox update +* :ghpull:`24662`: Doc/git force +* :ghpull:`24664`: Deprecate offsetbox.bbox_artist +* :ghpull:`24670`: Tiny capitalization fix. +* :ghpull:`24596`: ENH: Add ellipse class for annotation box styles +* :ghpull:`24249`: Add legend tests for 3D plots +* :ghpull:`24627`: MNT: when clearing an Axes via clear/cla fully detach children +* :ghpull:`24653`: Directly call _long_axis()._set_axes_scale in Colorbar. +* :ghpull:`24640`: Small TransformWrapper cleanups. +* :ghpull:`24528`: BUG: Warn when an existing layout manager changes to tight layout +* :ghpull:`24635`: Remove unneeded _update_transScale calls in _init_axis. +* :ghpull:`24641`: Fix that font files never pass the test on Win +* :ghpull:`24522`: Use pybind11 for tri module +* :ghpull:`24603`: Shorten the definition of sawtooth boxstyle. +* :ghpull:`24630`: Improve error message for gridspec when the index is not an integer. +* :ghpull:`24634`: Init axes._children early enough to avoid need for some getattr calls. +* :ghpull:`24629`: Doc/gitwash redirects +* :ghpull:`24624`: Expire FancyBboxPatch deprecations. +* :ghpull:`24619`: ENH: Allow RGB(A) arrays for pcolormesh +* :ghpull:`23588`: Refactoring gitwash +* :ghpull:`21549`: Unifying the Figure getter/setter interface to match its constructor +* :ghpull:`24582`: Shorten demo_axes_grid example. +* :ghpull:`24577`: Fold _set_ticklabels into set_ticklabels. +* :ghpull:`24581`: Simplify implementation of _is_sorted. +* :ghpull:`24575`: Use std::isnan and fix compiler warning +* :ghpull:`24570`: FIX: VPacker and HPacker bottom/top alignment +* :ghpull:`23812`: Ci add codeql +* :ghpull:`24556`: Fix incorrect window_extent of AxesImage +* :ghpull:`24566`: Improve argument checking for set_xticks(). +* :ghpull:`24544`: DOC: Add links to supported file formats in animations tutorial +* :ghpull:`24511`: Add test for mutating input arrays #8990 +* :ghpull:`24558`: In mplot3d, fix a doc typo and autogen zaxis_inverted. +* :ghpull:`24555`: ENH: Add warning for SymLogScale when values in linear scale range +* :ghpull:`23417`: Consistently set label on axis with units +* :ghpull:`24542`: DOC: Clarify supported animation formats in animation tutorial +* :ghpull:`23685`: Add mathtext support for ``\middle`` and correct rendering of ``\|`` +* :ghpull:`24539`: Fix misnamed api changes entry. +* :ghpull:`23692`: Add ``Axes.get_tick_params()`` method. +* :ghpull:`24132`: CenteredNorm changes +* :ghpull:`24529`: Transform ParasiteAxesBase._update_viewlim into standard callback. +* :ghpull:`24304`: Simplify some patches path definitions. +* :ghpull:`24431`: FIX: Support passing one alpha per event sequence to eventplot() +* :ghpull:`24527`: Fix testing of whether backends use the new pyplot_show API. +* :ghpull:`24537`: Fix triage tool due to test reorganization +* :ghpull:`21831`: FIX: pre-composite animation frames to white background +* :ghpull:`24205`: Plot directive: delegate file handling to Sphinx +* :ghpull:`24274`: Animation Tutorial +* :ghpull:`24519`: MNT: remove unused arguments to private methods and minor doc fixes +* :ghpull:`24525`: [Doc] Fix spelling and grammar in examples +* :ghpull:`24523`: [Doc] fix more spelling and grammar +* :ghpull:`24218`: Document what pyplot expects from a backend. +* :ghpull:`24513`: Modernize a bit floating_axes tests. +* :ghpull:`24491`: Make Path3DCollection store indexed offset, and only apply z-ordered offset during draw +* :ghpull:`24500`: DOC: Removed matplotlib from mission statement title +* :ghpull:`24490`: DOC: Remove text rotation example +* :ghpull:`24487`: Update tests to run with 3.11 (not rc) +* :ghpull:`24439`: Remove custom polar behaviour in LogLocator +* :ghpull:`24461`: Shorten and explain more calculations in axes_divider. +* :ghpull:`24472`: [DOC] removed flake8 from PR template +* :ghpull:`24467`: [DOC] swapped params in fig_compare_error msg +* :ghpull:`24455`: Draw RadioButtons using scatter to ensure circular buttons. +* :ghpull:`24462`: Don't pass unused xdescent to _get_packed_offsets. +* :ghpull:`24446`: Remove axis() manual argument parsing. +* :ghpull:`24334`: ENH: Check labels arg when kwargs passed in Axis.set_ticks() +* :ghpull:`24430`: MNT: Issue a warning instead of logging if RGB(A) passed to scatter(..., c) +* :ghpull:`24397`: Simplify appveyor to only use conda +* :ghpull:`24447`: Factor out error generation for function calls with wrong nargs. +* :ghpull:`24441`: DOC: Fix example for what's new imshow so it isn't cut off or crowded. +* :ghpull:`24443`: Add valid values to ``get_*axis_transform`` docstring +* :ghpull:`24440`: DOC: Fix colorbar what's new entry so it isn't cut off. +* :ghpull:`23787`: Use pybind11 for C/C++ extensions +* :ghpull:`24247`: Split toolkit tests into their toolkits +* :ghpull:`24432`: DOC: Fix What's New entry for bar_label() formatting. +* :ghpull:`23101`: Move show() to somewhere naturally inheritable / document what pyplot expects from a backend. +* :ghpull:`24215`: Add :shows-source-link: option to Sphinx plot directive +* :ghpull:`24423`: Tighten the Qt binding selection docs. +* :ghpull:`24403`: Use ``repr`` in error message Addresses #21959 +* :ghpull:`24415`: made f2tfont error message explicit that it needs path to file +* :ghpull:`24329`: Kill FontconfigPatternParser. +* :ghpull:`23267`: Add location keyword argument to Colorbar +* :ghpull:`24375`: DOC: Group pyplot plotting commands +* :ghpull:`24307`: DOC: Organize Axes3D methods into sections +* :ghpull:`22230`: FIX: add support for imshow extent to have units +* :ghpull:`24252`: Change default rotation mode for 3D labels to 'anchor' +* :ghpull:`24356`: Expire QuadMesh old signature deprecation +* :ghpull:`24355`: Expire unused positional parameters in canvas subclasses +* :ghpull:`24257`: Load style files from third-party packages. +* :ghpull:`24279`: Cleanup BboxImage example. +* :ghpull:`24342`: Use HTML5 for webagg files +* :ghpull:`24339`: DOC: Minor cleanup in "Writing documentation" +* :ghpull:`24338`: DOC: Group pyplot commands by category +* :ghpull:`24314`: Minor improvements to Annotations Tutorial +* :ghpull:`23914`: Add shading of Poly3DCollection +* :ghpull:`24322`: GOV: change security reporting to use tidelift +* :ghpull:`24305`: Unify logic of ConnectionStyle._Base.{_clip,_shrink}. +* :ghpull:`24303`: Simplify generate_fontconfig_pattern. +* :ghpull:`24319`: Bump mamba-org/provision-with-micromamba from 13 to 14 +* :ghpull:`24239`: Fix mathtext rendering of ``\|`` and sizing of ``|`` and ``\|`` +* :ghpull:`23606`: added offset section & restructured annotations tutorial +* :ghpull:`24125`: Expire miscellaneous deprecations from 3.5 +* :ghpull:`24306`: Remove unnecessary/replaceable explicit str calls. +* :ghpull:`24295`: Remove unnecessary np.{,as}array / astype calls. +* :ghpull:`24302`: MNT: Remove redundant int after round +* :ghpull:`24290`: Cleanup Barbs._find_tails. +* :ghpull:`24298`: List all the places to update when adding a dependency. +* :ghpull:`24289`: Cleanup image_zcoord example. +* :ghpull:`23865`: Add test and example for VBoxDivider +* :ghpull:`24287`: Simplifying glyph stream logic in ps backend +* :ghpull:`24291`: Rely on builtin round() instead of manual rounding. +* :ghpull:`24062`: Replaced std::random_shuffle with std::shuffle in tri +* :ghpull:`24278`: Use oldest-supported-numpy for build +* :ghpull:`24161`: Versioning directives policy +* :ghpull:`24013`: Deprecate matplotlib.tri.* submodules +* :ghpull:`24031`: Add rcParams for 3D pane color +* :ghpull:`24220`: Simplify and tighten parse_fontconfig_pattern. +* :ghpull:`24251`: Expire deprecation for ``auto_add_to_figure=True`` in ``Axes3D`` +* :ghpull:`24160`: sample versioning directives, empty + description +* :ghpull:`24253`: Expire deprecation of grid argument name +* :ghpull:`14471`: FIX: don't close figures if switch_backend is a no-op +* :ghpull:`24240`: Deprecate unit_cube-related methods in Axes3D +* :ghpull:`24244`: Clarify that z must be finite for tricountour(f) +* :ghpull:`23536`: Improve mpl_toolkit documentation +* :ghpull:`24243`: Improve documentation for ticker +* :ghpull:`24189`: Do not pass gridspec_kw to inner layouts in subplot_mosaic +* :ghpull:`24242`: Add information about environment variables in matplotlib.__doc__ +* :ghpull:`24241`: Small animation docs/style fixes. +* :ghpull:`24236`: DOC: Mark SubplotBase removals in code style +* :ghpull:`24141`: Set figure options dynamically +* :ghpull:`23796`: Remove useless semicolons in "Introductory / Basic Usage" tutorial +* :ghpull:`23573`: Merge SubplotBase into AxesBase. +* :ghpull:`23931`: Raise ValueError on negative number inputs for set_aspect +* :ghpull:`24065`: Fixed the positioning of cursor in Textbox: no approximation +* :ghpull:`24122`: Add textcolor to legend based on labelcolor string +* :ghpull:`24182`: MNT: Remove redundant method, fix signature and add doc-string to ``draw_tex`` +* :ghpull:`24224`: Deprecate Julian date-related functions and constant +* :ghpull:`24196`: MNT: Update pre-commit hooks +* :ghpull:`24221`: Deprecate BufferRegion.to_string{,_argb}. +* :ghpull:`23683`: Simplify/add pyparsing error messages on mathtext/fontconfig errors. +* :ghpull:`24210`: Small cleanups to axislines docs. +* :ghpull:`24213`: Cleanup make_compound_path_from_poly doc, example. +* :ghpull:`24208`: Deprecate backend_webagg.ServerThread. +* :ghpull:`24207`: Recommend multiple_yaxis_with_spines over parasite axes. +* :ghpull:`24156`: Automatically update rebase label +* :ghpull:`24198`: Deprecate unused backend_ps.{PsBackendHelper,ps_backend_helper}. +* :ghpull:`24129`: Expire cursor-related deprecations +* :ghpull:`24179`: MNT: Refactor ``Renderer.get_text_width_height_descent`` +* :ghpull:`24191`: BLD: be more cautious about checking editable mode +* :ghpull:`24000`: Generalize validation that pyplot commands are documented +* :ghpull:`24144`: Deprecate some label-related attributes on ContourLabeler. +* :ghpull:`24162`: windows doc build parity +* :ghpull:`24102`: Simplest pyproject.toml containing build-system only +* :ghpull:`24091`: MNT: Clean up code in SecondaryAxis +* :ghpull:`24140`: Replace ClabelText by set_transform_rotates_text. +* :ghpull:`24143`: Add QuadContourSet.remove. +* :ghpull:`24138`: [DOC] Fix some documentation typos +* :ghpull:`24128`: Expire deprecations in dates and ticker +* :ghpull:`23907`: Inherit OffsetBox.get_window_extent. +* :ghpull:`23449`: Add pan and zoom toolbar handling to 3D Axes (Replaces PR#22614) +* :ghpull:`24126`: Bump version when invalid hatches error +* :ghpull:`23874`: Expire parameter renaming and deletion and attribute privatization from 3.5 +* :ghpull:`23592`: Polar errcaps +* :ghpull:`24083`: Enable interactive figure resizing for webagg and nbagg backends +* :ghpull:`24110`: test readme rendering +* :ghpull:`24067`: README.rst to README.md +* :ghpull:`23702`: Get Mathtext ``\times`` symbol from ``cmsy10`` when using ``cmr10``. +* :ghpull:`24066`: Simplify svg font expansion logic. +* :ghpull:`23730`: [DOC]: Add grid to style sheets +* :ghpull:`24020`: [DOC]: adding a grid to the style sheet reference. +* :ghpull:`23579`: Remove direct manipulation of HostAxes.parasites by end users. +* :ghpull:`23553`: Add tests for ImageGrid +* :ghpull:`23918`: Merge v3.6.x branch to main +* :ghpull:`23902`: Add test and improve examples for mpl_toolkits +* :ghpull:`23950`: DOC: Don't import doctest because we're not using it +* :ghpull:`21006`: Rotate errorbar caps in polar plots +* :ghpull:`23870`: Implement Sphinx-Gallery's ``make html-noplot`` +* :ghpull:`23905`: made explicit that install link is install docs in readme +* :ghpull:`23824`: Deprecate draw_gouraud_triangle +* :ghpull:`23913`: Add draggable as param to Legend init +* :ghpull:`23896`: Inline AnchoredOffsetBox._update_offset_func. +* :ghpull:`23889`: Update image tutorial. +* :ghpull:`23861`: Move axes_grid tests to axes_grid1 +* :ghpull:`23254`: Add PathCollection test for ps backend +* :ghpull:`23542`: Add even more mplot3d tests +* :ghpull:`23698`: Fix bug in ``Axes.bar_label(label_type='center')`` for non-linear scales. +* :ghpull:`23767`: DEV: add flake8-force plugin +* :ghpull:`23835`: Fix version switcher links +* :ghpull:`23832`: Improve skip message for image comparison tests +* :ghpull:`23690`: Add new-style string formatting option and callable option to ``fmt`` in ``Axes.bar_label()``. +* :ghpull:`23804`: Fix TexManager's support for ``openin_any = p`` +* :ghpull:`23737`: Update grammar +* :ghpull:`23552`: Provide ``adjustable='box'`` to 3D axes aspect ratio setting +* :ghpull:`23769`: Bump mamba-org/provision-with-micromamba from 12 to 13 +* :ghpull:`23590`: Changing bar examples to tea and coffee +* :ghpull:`21253`: Fix: axis, ticks are set to defaults fontsize after ax.clear() +* :ghpull:`21968`: Changed fstring to make error clearer +* :ghpull:`22614`: ENH: Add pan and zoom toolbar handling to 3D Axes +* :ghpull:`21562`: Add a test for Hexbin Linear -Issues (61): +Issues (120): -* :ghissue:`22954`: [Doc]: v3.5.1 github stats are missing -* :ghissue:`22959`: [MNT]: macos-latest memory leak over threshold -* :ghissue:`22921`: [Bug]: Regression in animation from #22175 -* :ghissue:`22908`: [Bug]: QuadMesh get_cursor_data errors if no array is set -* :ghissue:`21901`: Suggested clarification of comments in errorbar helpers -* :ghissue:`22932`: [Doc]: small edits to the Pull request guidelines -* :ghissue:`22858`: [Bug]: FigureCanvasTkAgg call creates memory leak -* :ghissue:`20490`: Memory leaks on matplotlib 3.4.2 (and 3.4.0) -* :ghissue:`22900`: [Doc]: Typo in triage acknowledgment -* :ghissue:`22341`: [Bug]: GridSpec or related change between 3.4.3 and 3.5.1 -* :ghissue:`22472`: [Bug]: ConciseDateFormatter not showing year anywhere when plotting <12 months -* :ghissue:`22874`: [Bug]: Textbox doesn't accept input -* :ghissue:`21893`: [Bug]: ``backend_pdf`` gives ``TTLibError`` with ``pdf.fonttype : 42`` -* :ghissue:`22840`: [Bug]: Blank output EPS file when using latex and figure.autolayout = True -* :ghissue:`22762`: [Bug]: Issue with find_nearest_contour in contour.py -* :ghissue:`22823`: [Bug]: Changing Linestyle in plot window swaps some plotted lines -* :ghissue:`22804`: [Bug]: Quiver not working with subfigure? -* :ghissue:`22673`: [Bug]: tight_layout (version 3.5+) -* :ghissue:`21930`: [Bug]: EPS savefig messed up by 'figure.autolayout' rcParam on 3.5.0 -* :ghissue:`22753`: windows CI broken on azure -* :ghissue:`22088`: [Bug]: Tool Manager example broken -* :ghissue:`22624`: [Bug]: invalid value encountered with 'ortho' projection mode -* :ghissue:`22640`: [Bug]: Confusing deprecation warning when empty data passed to axis with category units -* :ghissue:`22137`: [Bug]: Cannot clear figure of subfigures -* :ghissue:`22706`: [Bug]: RangeSlider.set_val does not move the slider (only poly and value) -* :ghissue:`22727`: MAtplolib pan and zoom dead slow on new PC -* :ghissue:`22687`: [Bug]: Empty text or text with a newline at either end + path_effects crashes -* :ghissue:`22694`: Revert set_show_cursor_data -* :ghissue:`22520`: [Bug]: Slow lasso selector over QuadMesh collection -* :ghissue:`22648`: Add packaging to setup_requires? -* :ghissue:`22052`: [Bug]: invert_yaxis function cannot invert the "over value" in colorbar axes -* :ghissue:`22576`: [Bug]: ``inset_axes`` colorbar + ``tight_layout`` raises ``AttributeError`` -* :ghissue:`22590`: [Bug]: ValueError: Do not know how to convert "list" to dashes; when using axes errorbar. -* :ghissue:`21998`: [Bug]: Working with PyQt5, the different import order will make different result. -* :ghissue:`22330`: [Bug]: possible regression with pandas 1.4 with plt.plot when using a single column dataframe as the x argument -* :ghissue:`22125`: [Bug]: ``plt.plot`` thinks ``pandas.Series`` is 2-dimensional when nullable data type is used -* :ghissue:`22378`: [Bug]: TkAgg fails to find Tcl/Tk libraries in Windows for processes with a large number of modules loaded -* :ghissue:`22577`: [Bug]: Erroneous deprecation warning help message -* :ghissue:`21798`: [Bug]: Unhandled _get_renderer.Done exception in wxagg backend -* :ghissue:`22532`: [Issue]: Manually placing contour labels using ``clabel`` not working -* :ghissue:`22470`: [Bug]: Subsequent scatter plots work incorrectly -* :ghissue:`22471`: [Bug]: formlayout fails on PyQt6 due to the unqualified enum ShowAlphaChannel in class ColorButton -* :ghissue:`22473`: [Bug]: Secondary axis does not accept python builtins for transform -* :ghissue:`22384`: [Bug]: Curve styles gets mixed up when edited in the Curves Tab of Figure Options (Edit Axis) -* :ghissue:`22028`: [Bug]: mpl with py3.10.1 - Interactive figures - Constrain pan/zoom to x/y axis not work -* :ghissue:`13484`: Matplotlib keymap stop working after pressing tab -* :ghissue:`20130`: tk toolmanager add_toolitem fails to add tool to group other than the last one -* :ghissue:`21723`: [Bug]: Some styles trigger pcolormesh grid deprecation -* :ghissue:`22300`: [Bug]: Saving a fig with a colorbar using a ``TwoSlopeNorm`` sometimes results in 'posx and posy should be finite values' -* :ghissue:`22305`: [Bug]: Import Error in Matplotlib 3.5.1 -* :ghissue:`21917`: [Bug]: pcolormesh is not responsive in Matplotlib 3.5 -* :ghissue:`22094`: [Doc]: No documentation on ArtistList -* :ghissue:`21979`: [Doc]: Clarify default capstyle -* :ghissue:`22143`: [Bug]: ``constrained_layout`` merging similar subgrids -* :ghissue:`22131`: [Bug]: png icon image fails to load for manually defined tool buttons -* :ghissue:`22093`: [Bug]: AttributeError: 'AxesSubplot' object has no attribute 'add_text' -* :ghissue:`22085`: [Bug]: Memory leak with colorbar.make_axes -* :ghissue:`22065`: [Bug]: Additive offset with trailing zeros -* :ghissue:`15493`: common_texification misses & (ampersand) -* :ghissue:`22039`: [Doc]: [source] link for deprecated functions leads to _api/deprecation.py -* :ghissue:`22016`: [Bug]: matplotlib 3.3 changed how plt.hist handles iterables of zero-dimensional arrays. +* :ghissue:`25176`: [Bug]: Colorbar is displaced when saving as .eps with bbox_inches='tight' +* :ghissue:`25075`: [Bug]: Widget blitting broken when saving as PDF +* :ghissue:`25181`: unavoidable warnings in nbagg on ``plt.close`` +* :ghissue:`25134`: [Doc]: pyplot.boxplot whisker length wrong docs +* :ghissue:`24395`: Any resizing of the plot after plt.show results in an error when closing the window +* :ghissue:`25107`: [Doc]: annotated_cursor example seems broken +* :ghissue:`25124`: [Bug]: ax.plot(x,y) disappears after changing y_scale +* :ghissue:`8278`: FuncAnimation with generator defaults to arbitrary save_count=100 +* :ghissue:`22765`: Document distutil vs setuptools issues or fix usage +* :ghissue:`25077`: [Bug]: Setting norm with existing colorbar fails with 3.6.3 +* :ghissue:`23999`: [Bug]: Annotation misplaced when rasterizing and saving as PDF +* :ghissue:`25040`: [Bug]: Request to insecure websocket endpoint is blocked by browser +* :ghissue:`24678`: [Bug]: pcolormesh doesn't allow shading = 'flat' in the option +* :ghissue:`15388`: matplotlib.collections.QuadMesh.set_array() input arg format is weird and undocumented +* :ghissue:`23779`: [ENH]: control the size of the tex cache +* :ghissue:`24583`: [ENH]: provide public API for styling radio buttons +* :ghissue:`21895`: [Bug]: slow rendering of multiple axes (time scales as 2nd power of label count) +* :ghissue:`4781`: Add API to register styles +* :ghissue:`24972`: [MNT]: UserWarning from pyparsing upon immediate import +* :ghissue:`24865`: [Bug]: NumPy 1.24 deprecation warnings +* :ghissue:`24954`: [Bug]: compressed layout setting can be forgotten on second save +* :ghissue:`23778`: [ENH]: Allow override of contour level autoscaling +* :ghissue:`20203`: contour edge case with all data below levels and a surrounding field of zeros +* :ghissue:`12803`: pcolormesh in log polar coordinates +* :ghissue:`24383`: log scale and polar broken +* :ghissue:`22847`: [Bug]: Cannot toggle set_tight_layout +* :ghissue:`23646`: [Bug]: matplotlib.set_loglevel() adds a console handler +* :ghissue:`24673`: [Doc]: animation examples show controls; source does not reproduce them +* :ghissue:`7617`: set_ylabel does not work as expected with SubplotZero +* :ghissue:`13023`: constrained_layout support for figure.legend +* :ghissue:`15973`: span_where fails with timeseries on the x-axis +* :ghissue:`24867`: [Bug]: controlling text on toolbar in wx +* :ghissue:`24421`: [Doc]: change to install from conda forge +* :ghissue:`24890`: [Bug]: Clipping mask can shift in PDF and SVG file outputs when Bbox is adjusted +* :ghissue:`23849`: [Bug]: The point marker is not actually unfilled +* :ghissue:`24321`: [ENH]: Auto-detect bool arrays passed to contour()? +* :ghissue:`24842`: axes3d.quiver() fails when providing args to Line3DCollection +* :ghissue:`24093`: [Bug]: CenteredNorm gets stuck in infinite recursion when given all zeros +* :ghissue:`24571`: [ENH]: gridspec_mosaic +* :ghissue:`24815`: [TST] Upcoming dependency test failures +* :ghissue:`24712`: [ENH]: Reverse legend +* :ghissue:`22308`: [Bug] set_3d_properties type error in Matplotlib 3.5.1 +* :ghissue:`24741`: [Doc]: tables in "notes" cut off content +* :ghissue:`20044`: AnnotationBbox gid not passed to renderer +* :ghissue:`24762`: [Doc]: Development workflow doc has lots of typos and clunky sentences +* :ghissue:`24235`: [Bug]: pcolormesh(rasterized=True) conflicts with set_rasterization_zorder() +* :ghissue:`24471`: [Bug]: CheckBoxes should be square, not rectangular +* :ghissue:`18804`: bugged pads on savefig +* :ghissue:`20656`: Sphinx extension plot_directive not able to detect function +* :ghissue:`24704`: [Bug]: ImportError: DLL load failed on Windows +* :ghissue:`20639`: document Legend.legendHandles +* :ghissue:`19633`: Multicursor disappears when not moving on nbagg with useblit=False + burns CPU +* :ghissue:`24717`: Update Research Notice on README.md +* :ghissue:`22754`: [Bug]: It is recommended for you to run autogen before configuring freetype +* :ghissue:`24349`: [Bug]: sharex and sharey don't accept 0 and 1 as bool values +* :ghissue:`20577`: Using ``legend(labelcolor="markerfacecolor")`` with a scatter plot throws an error +* :ghissue:`24424`: [Doc]: Inheritance diagrams +* :ghissue:`9580`: Broken legend auto-position with step*-type histograms +* :ghissue:`22176`: [MNT]: Write a bot to post doc build issues +* :ghissue:`24623`: [Bug]: ``offsetbox`` classes have optional arguments that are really not optional +* :ghissue:`24693`: [MNT]: Update minver policy re: GUI toolkits +* :ghissue:`23566`: [ENH]: Z-axis/3D support for Figure options +* :ghissue:`23777`: [ENH] Interactive Zoom Rectangle Color Review for MACOSX backend +* :ghissue:`24676`: [Doc]: quiver_doc etc leads to documentation of the documentation string +* :ghissue:`24568`: [ENH]: Ellipse annotation +* :ghissue:`6982`: cla(), clf() should unset the ``.axes`` and ``.figure`` attributes of deparented artists +* :ghissue:`11227`: fig.set_dpi() does not set the dpi correctly +* :ghissue:`24418`: [ENH]: rgp or rgba option for pyplot pcolormesh and/or pcolor +* :ghissue:`22236`: [Bug]: integer colours for pcolorfast / quadmesh +* :ghissue:`4277`: RGB not supported in pcolormesh +* :ghissue:`23155`: [ENH]: do_3d_projection could restore original verts order after draw() finishes +* :ghissue:`24386`: [Bug]: ``align`` in ``HPacker`` is reversed +* :ghissue:`23803`: Static code analysis +* :ghissue:`8990`: Surprising behaviour of mutating input arrays to Axes.plot vs Axes3D.plot +* :ghissue:`24550`: [ENH]: Warn when a SymLogScale receives values that are all in the linear regime +* :ghissue:`23416`: [Bug]: Inconsistent y-axis unit label with plot/scatter +* :ghissue:`23603`: [MNT]: Only a subset of attributes set via ``Axes.tick_params()`` are accessible via public methods and attributes +* :ghissue:`13858`: matplotlib.sphinxext.plot_directive generates incorrect links when using dirhtml builder +* :ghissue:`19376`: eventplot: allow a list of alpha channels as in the case with colors +* :ghissue:`24508`: [Bug]: Re-organization of mpl_toolkits tests broke tools/triage_tests.py +* :ghissue:`19040`: v3.3.0 Regression, Animation draws artists multiple times. +* :ghissue:`12324`: DOC: Write a unified backend doc +* :ghissue:`24464`: Issue with legend labelcolor='linecolor' for errorbar plots +* :ghissue:`24273`: [ENH]: Axes.set_xticks/Axis.set_ticks only validates kwargs if ticklabels are set, but they should +* :ghissue:`24454`: [Bug]: "import matplotlib.pyplot" gives ModuleNotFoundError +* :ghissue:`24394`: [TST]: Appveyor Qt tests failing +* :ghissue:`21959`: [ENH]: Use ``repr`` instead of ``str`` in the error message +* :ghissue:`22676`: [ENH]: Colorbar should support location kwarg that sets both orientation and ticklocation +* :ghissue:`23901`: [Doc]: add summary table to Axes3D similar to Axes +* :ghissue:`22105`: [Bug]: imshow extents can't have units? +* :ghissue:`21878`: [MNT]: make axis labels of 3d axis anchor-rotate +* :ghissue:`17978`: Document how to distribute style files in python packages +* :ghissue:`23965`: Simplify glyph stream logic in ps backend +* :ghissue:`19509`: Adding lightsource when plotting Poly3DCollection +* :ghissue:`17523`: Unclear if no gallery argument for doc builds works +* :ghissue:`23250`: [Bug]: Incorrect mathtext rendering of ``r"$\|$"`` with default (dejavu) math fontfamily +* :ghissue:`24010`: c++17 removed random_shuffle +* :ghissue:`20424`: function shadowing their own definition modules +* :ghissue:`20781`: Make the pane color in 3d plots configurable +* :ghissue:`14426`: Existing FigureCanvasQT objects destroyed by call to plt.figure +* :ghissue:`5908`: Unclear Documentation ticker class +* :ghissue:`24099`: [Bug]: Error using width_ratios with nested mosaic in subplot_mosaic() +* :ghissue:`6893`: List environment variables in matplotlib.__doc__ +* :ghissue:`11445`: The axes module structure +* :ghissue:`23847`: [Bug]: set_aspect with negative argument leads to infinite loop +* :ghissue:`24136`: [Doc]: document ``julian2num`` and ``num2julian``? +* :ghissue:`5332`: QuadContourSet lacks remove method +* :ghissue:`110`: pan and zoom are broken for mplot3d +* :ghissue:`441`: Polar plot error bars don't rotate with angle +* :ghissue:`24064`: Convert readme.rst to readme.md +* :ghissue:`10029`: \times in minor ticklabels not recognized due to \mathdefault +* :ghissue:`24080`: verify quoting method in svg backend for font names +* :ghissue:`23601`: [Doc]: add gridlines to style sheet reference +* :ghissue:`24075`: [ENH]: Resizing the figure with webagg backend by dragging the corner +* :ghissue:`23352`: [Doc]: bar examples should probably not have "score by ... gender" in them... +* :ghissue:`23819`: [MNT]: Make draw_gouraud_triangle optional +* :ghissue:`9181`: legend draggable as keyword +* :ghissue:`23688`: [Bug]: ``Axes.bar_label()`` on log scale does not center the label. +* :ghissue:`23689`: [ENH]: Add f-string formatting to labels in ``Axes.bar_label()`` +* :ghissue:`23718`: [Bug]: Installing from source fails during Freetype compilation with spaces in working directory filepath Previous GitHub statistics diff --git a/doc/users/index.rst b/doc/users/index.rst index 9f913737af52..0ecff14c9a23 100644 --- a/doc/users/index.rst +++ b/doc/users/index.rst @@ -29,7 +29,7 @@ Tutorials and examples ../tutorials/index.rst ../gallery/index.rst -Reference +Reference ######### .. toctree:: diff --git a/doc/users/installing/index.rst b/doc/users/installing/index.rst index 10431d0cd465..1b6fdf65055f 100644 --- a/doc/users/installing/index.rst +++ b/doc/users/installing/index.rst @@ -80,6 +80,25 @@ you can install Matplotlib via your package manager, e.g.: .. _install_from_source: +========================== +Installing a nightly build +========================== + +Matplotlib makes nightly development build wheels available on the +`scipy-wheels-nightly Anaconda Cloud organization +`_. +These wheels can be installed with ``pip`` by specifying scipy-wheels-nightly +as the package index to query: + +.. code-block:: sh + + python -m pip install \ + --upgrade \ + --pre \ + --index-url https://pypi.anaconda.org/scipy-wheels-nightly/simple \ + --extra-index-url https://pypi.org/simple \ + matplotlib + ====================== Installing from source ====================== @@ -92,10 +111,18 @@ First you need to install the :ref:`dependencies`. A C compiler is required. Typically, on Linux, you will need ``gcc``, which should be installed using your distribution's package manager; on macOS, you -will need xcode_; on Windows, you will need Visual Studio 2015 or later. +will need xcode_; on Windows, you will need `Visual Studio`_ 2015 or later. + +For those using Visual Studio, make sure "Desktop development with C++" is +selected, and that the latest MSVC, "C++ CMake tools for Windows," and a +Windows SDK compatible with your version of Windows are selected and installed. +They should be selected by default under the "Optional" subheading, but are +required to build matplotlib from source. .. _xcode: https://guide.macports.org/chunked/installing.html#installing.xcode +.. _Visual Studio: https://visualstudio.microsoft.com/downloads/ + The easiest way to get the latest development version to start contributing is to go to the git `repository `_ and run:: @@ -230,7 +257,7 @@ install Matplotlib with other useful Python software is to use the Anaconda_ Python scientific software collection, which includes Python itself and a wide range of libraries; if you need a library that is not available from the collection, you can install it yourself using standard methods such as *pip*. -See the Ananconda web page for installation support. +See the Anaconda web page for installation support. .. _system python packages: https://github.com/MacPython/wiki/wiki/Which-Python#system-python-and-extra-python-packages @@ -262,11 +289,9 @@ be manually installed on Macports with :: Once pip is installed, you can install Matplotlib and all its dependencies with from the Terminal.app command line:: - python3 -mpip install matplotlib - -(``sudo python3.6 ...`` on Macports). + python3 -m pip install matplotlib -You might also want to install IPython or the Jupyter notebook (``python3 -mpip +You might also want to install IPython or the Jupyter notebook (``python3 -m pip install ipython notebook``). Checking your installation @@ -279,9 +304,9 @@ at the Terminal.app command line:: You should see something like :: - 3.0.0 /Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages/matplotlib/__init__.py + 3.6.0 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/matplotlib/__init__.py -where ``3.0.0`` is the Matplotlib version you just installed, and the path +where ``3.6.0`` is the Matplotlib version you just installed, and the path following depends on whether you are using Python.org Python, Homebrew or Macports. If you see another version, or you get an error like :: diff --git a/doc/users/next_whats_new/README.rst b/doc/users/next_whats_new/README.rst index 6555d229a1b5..e1b27ef97f1e 100644 --- a/doc/users/next_whats_new/README.rst +++ b/doc/users/next_whats_new/README.rst @@ -15,7 +15,7 @@ Please avoid using references in section titles, as it causes links to be confusing in the table of contents. Instead, ensure that a reference is included in the descriptive text. Include contents of the form: :: - Section Title for Feature + Section title for feature ------------------------- A bunch of text about how awesome the new feature is and examples of how diff --git a/doc/users/prev_whats_new/changelog.rst b/doc/users/prev_whats_new/changelog.rst index 9f054a28b3ab..f1f3cca3225e 100644 --- a/doc/users/prev_whats_new/changelog.rst +++ b/doc/users/prev_whats_new/changelog.rst @@ -4,8 +4,7 @@ List of changes to Matplotlib prior to 2015 =========================================== This is a list of the changes made to Matplotlib from 2003 to 2015. For more -recent changes, please refer to the `what's new <../whats_new.html>`_ or -the `API changes <../../api/api_changes.html>`_. +recent changes, please refer to the :doc:`/users/release_notes`. 2015-11-16 Levels passed to contour(f) and tricontour(f) must be in increasing order. @@ -49,7 +48,7 @@ the `API changes <../../api/api_changes.html>`_. Fixed bug so radial plots can be saved as ps in py3k. 2014-06-01 - Changed the fmt kwarg of errorbar to support the the mpl convention that + Changed the fmt kwarg of errorbar to support the mpl convention that "none" means "don't draw it", and to default to the empty string, so that plotting of data points is done with the plot() function defaults. Deprecated use of the None object in place "none". @@ -125,7 +124,7 @@ the `API changes <../../api/api_changes.html>`_. 2014-03-24 Added bool kwarg (manage_xticks) to boxplot to enable/disable the - managemnet of the xlimits and ticks when making a boxplot. Default in True + management of the xlimits and ticks when making a boxplot. Default in True which maintains current behavior by default. 2014-03-23 @@ -194,7 +193,7 @@ the `API changes <../../api/api_changes.html>`_. memory. 2013-10-06 - Improve window and detrend functions in mlab, particulart support for 2D + Improve window and detrend functions in mlab, particular support for 2D arrays. 2013-10-06 @@ -262,7 +261,7 @@ the `API changes <../../api/api_changes.html>`_. 2013-04-15 Added 'axes.xmargin' and 'axes.ymargin' to rpParams to set default margins - on auto-scaleing. - TAC + on auto-scaling. - TAC 2013-04-16 Added patheffect support for Line2D objects. -JJL @@ -331,7 +330,7 @@ the `API changes <../../api/api_changes.html>`_. from that point forward. - PI 2012-11-16 - Added the funcction _get_rbga_face, which is identical to _get_rbg_face + Added the function _get_rbga_face, which is identical to _get_rbg_face except it return a (r,g,b,a) tuble, to line2D. Modified Line2D.draw to use _get_rbga_face to get the markerface color so that any alpha set by markerfacecolor will respected. - Thomas Caswell @@ -1488,7 +1487,7 @@ the `API changes <../../api/api_changes.html>`_. interface - GR 2009-02-04 - Some reorgnization of the legend code. anchored_text.py added as an + Some reorganization of the legend code. anchored_text.py added as an example. - JJL 2009-02-04 @@ -1675,7 +1674,7 @@ the `API changes <../../api/api_changes.html>`_. 2008-12-12 Preparations to eliminate maskedarray rcParams key: its use will now - generate a warning. Similarly, importing the obsolote numerix.npyma will + generate a warning. Similarly, importing the obsolete numerix.npyma will generate a warning. - EF 2008-12-12 @@ -1806,7 +1805,7 @@ the `API changes <../../api/api_changes.html>`_. 2008-11-11 Add 'pad_to' and 'sides' parameters to mlab.psd() to allow controlling of - zero padding and returning of negative frequency components, respecitively. + zero padding and returning of negative frequency components, respectively. These are added in a way that does not change the API. - RM 2008-11-10 @@ -2258,7 +2257,7 @@ the `API changes <../../api/api_changes.html>`_. align='edge' changed to center of bin - MM 2008-05-22 - Added support for ReST-based doumentation using Sphinx. Documents are + Added support for ReST-based documentation using Sphinx. Documents are located in doc/, and are broken up into a users guide and an API reference. To build, run the make.py files. Sphinx-0.4 is needed to build generate xml, which will be useful for rendering equations with mathml, use sphinx @@ -2737,7 +2736,7 @@ the `API changes <../../api/api_changes.html>`_. Fixed a bug in patches.Ellipse that was broken for aspect='auto'. Scale free ellipses now work properly for equal and auto on Agg and PS, and they fall back on a polygonal approximation for nonlinear transformations until - we convince oursleves that the spline approximation holds for nonlinear + we convince ourselves that the spline approximation holds for nonlinear transformations. Added unit/ellipse_compare.py to compare spline with vertex approx for both aspects. JDH @@ -3218,7 +3217,7 @@ the `API changes <../../api/api_changes.html>`_. The backend has been updated to use new wxPython functionality to provide fast blit() animation without the C++ accelerator. This requires wxPython - 2.8 or later. Previous versions of wxPython can use the C++ acclerator or + 2.8 or later. Previous versions of wxPython can use the C++ accelerator or the old pure Python routines. setup.py no longer builds the C++ accelerator when wxPython >= 2.8 is @@ -3293,7 +3292,7 @@ the `API changes <../../api/api_changes.html>`_. PickEvent - Details and examples in examples/pick_event_demo.py - JDH 2007-01-16 - Begun work on a new pick API using the mpl event handling frameowrk. + Begun work on a new pick API using the mpl event handling framework. Artists will define their own pick method with a configurable epsilon tolerance and return pick attrs. All artists that meet the tolerance threshold will fire a PickEvent with artist dependent attrs; e.g., a Line2D @@ -3450,7 +3449,7 @@ the `API changes <../../api/api_changes.html>`_. specified as a kwarg. - EF 2006-11-05 - Added broken_barh function for makring a sequence of horizontal bars broken + Added broken_barh function for making a sequence of horizontal bars broken by gaps -- see examples/broken_barh.py 2006-11-05 @@ -4386,7 +4385,7 @@ the `API changes <../../api/api_changes.html>`_. Released 0.85 2005-11-16 - Changed the default default linewidth in rc to 1.0 + Changed the default linewidth in rc to 1.0 2005-11-16 Replaced agg_to_gtk_drawable with pure pygtk pixbuf code in backend_gtkagg. @@ -4824,7 +4823,7 @@ the `API changes <../../api/api_changes.html>`_. 2005-05-27 Finally found the pesky agg bug (which Maxim was kind enough to fix within hours) that was causing a segfault in the win32 cached marker drawing. Now - windows users can get the enormouse performance benefits of caced markers + windows users can get the enormous performance benefits of cached markers w/o those occasional pesy screenshots. - JDH 2005-05-27 @@ -4851,7 +4850,7 @@ the `API changes <../../api/api_changes.html>`_. 2005-05-21 Fixed raster problem for small rasters with dvipng -- looks like it was a - premultipled alpha problem - JDH + premultiplied alpha problem - JDH 2005-05-20 Added linewidth and faceted kwarg to scatter to control edgewidth and @@ -4879,7 +4878,7 @@ the `API changes <../../api/api_changes.html>`_. 2005-05-12 Started work on TeX text for antigrain using pngdvi -- see examples/tex_demo.py and the new module matplotlib.texmanager. Rotated - text not supported and rendering small glyps is not working right yet. BUt + text not supported and rendering small glyphs is not working right yet. But large fontsizes and/or high dpi saved figs work great. 2005-05-10 @@ -4917,7 +4916,7 @@ the `API changes <../../api/api_changes.html>`_. 2005-05-04 Added NewScalarFormatter. Improved formatting of ticklabels, scientific - notation, and the ability to plot large large numbers with small ranges, by + notation, and the ability to plot large numbers with small ranges, by determining a numerical offset. See ticker.NewScalarFormatter for more details. -DSD @@ -5009,7 +5008,7 @@ the `API changes <../../api/api_changes.html>`_. Applied boxplot and OSX font search patches 2005-03-27 - Added ft2font NULL check to fix Japanase font bug - JDH + Added ft2font NULL check to fix Japanese font bug - JDH 2005-03-27 Added sprint legend patch plus John Gill's tests and fix -- see @@ -5164,7 +5163,7 @@ the `API changes <../../api/api_changes.html>`_. 2005-02-09 backend renderer draw_lines now has transform in backend, as in - draw_markers; use numerix in _backend_agg, aded small line optimization to + draw_markers; use numerix in _backend_agg, added small line optimization to agg 2005-02-09 @@ -5588,7 +5587,7 @@ the `API changes <../../api/api_changes.html>`_. 2004-10-31 backend_ps.py: clean up the generated PostScript code, use the PostScript - stack to hold itermediate values instead of storing them in the dictionary. + stack to hold intermediate values instead of storing them in the dictionary. - JV 2004-10-30 @@ -5892,7 +5891,7 @@ the `API changes <../../api/api_changes.html>`_. Use imshow(blah, blah, extent=(xmin, xmax, ymin, ymax) instead - JDH 2004-07-12 - Added prototype for new nav bar with codifed event handling. Use + Added prototype for new nav bar with codified event handling. Use mpl_connect rather than connect for matplotlib event handling. toolbar style determined by rc toolbar param. backend status: gtk: prototype, wx: in progress, tk: not started - JDH @@ -6200,7 +6199,7 @@ the `API changes <../../api/api_changes.html>`_. WX backends. - PEB 2004-04-16 - Added get- and set_fontstyle msethods. - PEB + Added get- and set_fontstyle methods. - PEB 2004-04-10 Mathtext fixes: scaling with dpi, - JDH @@ -6285,7 +6284,7 @@ This is the Old, stale, never used changelog Added a new line type '|' which is a vline. syntax is plot(x, Y, '|') where y.shape = len(x),2 and each row gives the ymin,ymax for the respective values of x. Previously I had implemented vlines as a list of - lines, but I needed the efficientcy of the numeric clipping for large + lines, but I needed the efficiency of the numeric clipping for large numbers of vlines outside the viewport, so I wrote a dedicated class Vline2D which derives from Line2D @@ -6298,7 +6297,7 @@ This is the Old, stale, never used changelog 2003-05-28 Changed figure rendering to draw form a pixmap to reduce flicker. See - examples/system_monitor.py for an example where the plot is continusouly + examples/system_monitor.py for an example where the plot is continuously updated w/o flicker. This example is meant to simulate a system monitor that shows free CPU, RAM, etc... @@ -6313,7 +6312,7 @@ This is the Old, stale, never used changelog Added figure text with new example examples/figtext.py 2003-08-27 - Fixed bugs i figure text with font override dictionairies and fig text that + Fixed bugs in figure text with font override dictionaries and fig text that was placed outside the window bounding box 2003-09-01 through 2003-09-15 diff --git a/doc/users/prev_whats_new/github_stats_3.5.1.rst b/doc/users/prev_whats_new/github_stats_3.5.1.rst index 18ee258d351f..7eb37b769d6c 100644 --- a/doc/users/prev_whats_new/github_stats_3.5.1.rst +++ b/doc/users/prev_whats_new/github_stats_3.5.1.rst @@ -45,7 +45,7 @@ Pull Requests (84): * :ghpull:`21908`: Backport PR #21834 on branch v3.5.x (MAINT Fix signature qhull version function ) * :ghpull:`21907`: Backport PR #21905 on branch v3.5.x (Fix image testing decorator in pytest importlib mode) * :ghpull:`21906`: Backport PR #21773 on branch v3.5.x (FIX: Reset label of axis to center) -* :ghpull:`21834`: MAINT Fix signature qhull version function +* :ghpull:`21834`: MAINT Fix signature qhull version function * :ghpull:`21905`: Fix image testing decorator in pytest importlib mode * :ghpull:`21773`: FIX: Reset label of axis to center * :ghpull:`21902`: Backport PR #21884 on branch v3.5.x (FIX: be more careful about coercing unit-full containers to ndarray) diff --git a/doc/users/prev_whats_new/github_stats_3.5.2.rst b/doc/users/prev_whats_new/github_stats_3.5.2.rst new file mode 100644 index 000000000000..66f53d8e3672 --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.5.2.rst @@ -0,0 +1,335 @@ +.. _github-stats-3-5-2: + +GitHub statistics for 3.5.2 (May 02, 2022) +========================================== + +GitHub statistics for 2021/12/11 (tag: v3.5.1) - 2022/05/02 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 61 issues and merged 222 pull requests. +The full list can be seen `on GitHub `__ + +The following 30 authors contributed 319 commits. + +* Adeel Hassan +* Aitik Gupta +* Andrew Fennell +* andrzejnovak +* Antony Lee +* Clément Phan +* daniilS +* David Poznik +* David Stansby +* dependabot[bot] +* Edouard Berthe +* Elliott Sales de Andrade +* Greg Lucas +* Hassan Kibirige +* Jake VanderPlas +* Jay Stanley +* Jody Klymak +* MAKOMO +* Matthias Bussonnier +* Niyas Sait +* Oscar Gustafsson +* Pieter P +* Qijia Liu +* Quentin Peter +* Raphael Quast +* richardsheridan +* root +* Steffen Rehberg +* Thomas A Caswell +* Tim Hoffmann + +GitHub issues and pull requests: + +Pull Requests (222): + +* :ghpull:`22963`: Backport PR #22957 on branch v3.5.x (fix "is" comparison for np.array) +* :ghpull:`22951`: Backport PR #22946: FIX: Handle no-offsets in collection datalim +* :ghpull:`22957`: fix "is" comparison for np.array +* :ghpull:`22962`: Backport PR #22961 on branch v3.5.x (Raised macosx memory leak threshold) +* :ghpull:`22961`: Raised macosx memory leak threshold +* :ghpull:`22945`: FIX: Handle no-offsets in collection datalim +* :ghpull:`22946`: FIX: Handle no-offsets in collection datalim (alternative) +* :ghpull:`22944`: Backport PR #22907 on branch v3.5.x (Fix quad mesh cursor data) +* :ghpull:`22943`: Backport PR #22923 on branch v3.5.x (Fixed _upcast_err docstring and comments in _axes.py) +* :ghpull:`22907`: Fix quad mesh cursor data +* :ghpull:`22923`: Fixed _upcast_err docstring and comments in _axes.py +* :ghpull:`22876`: Backport PR #22560 on branch v3.5.x (Improve pandas/xarray/... conversion) +* :ghpull:`22942`: Backport PR #22933 on branch v3.5.x (Adjusted wording in pull request guidelines) +* :ghpull:`22941`: Backport PR #22898 on branch v3.5.x (Only set Tk scaling-on-map for Windows systems) +* :ghpull:`22935`: Backport PR #22002: Fix TkAgg memory leaks and test for memory growth regressions +* :ghpull:`22898`: Only set Tk scaling-on-map for Windows systems +* :ghpull:`22933`: Adjusted wording in pull request guidelines +* :ghpull:`22002`: Fix TkAgg memory leaks and test for memory growth regressions +* :ghpull:`22924`: Fix gtk4 incorrect import. +* :ghpull:`22922`: Backport PR #22904 on branch v3.5.x (Fixed typo in triage acknowledgment) +* :ghpull:`22904`: Fixed typo in triage acknowledgment +* :ghpull:`22890`: DOC: add ipykernel to list of optional dependencies +* :ghpull:`22878`: Backport PR #22871 on branch v3.5.x (Fix year offset not always being added) +* :ghpull:`22871`: Fix year offset not always being added +* :ghpull:`22844`: Backport PR #22313 on branch v3.5.x (Fix colorbar exponents) +* :ghpull:`22560`: Improve pandas/xarray/... conversion +* :ghpull:`22846`: Backport PR #22284 on branch v3.5.x (Specify font number for TTC font subsetting) +* :ghpull:`22284`: Specify font number for TTC font subsetting +* :ghpull:`22845`: Backport PR #22199 on branch v3.5.x (DOC: git:// is deprecated.) +* :ghpull:`22837`: Backport PR #22807 on branch v3.5.x (Replace quiver dpi callback with reinit-on-dpi-changed.) +* :ghpull:`22838`: Backport PR #22806 on branch v3.5.x (FIX: callback for subfigure uses parent) +* :ghpull:`22832`: Backport PR #22767 on branch v3.5.x (Fixed bug in find_nearest_contour) +* :ghpull:`22767`: Fixed bug in find_nearest_contour +* :ghpull:`22807`: Replace quiver dpi callback with reinit-on-dpi-changed. +* :ghpull:`22806`: FIX: callback for subfigure uses parent +* :ghpull:`22737`: Backport PR #22138: Fix clearing subfigures +* :ghpull:`22735`: MNT: prefer Figure.clear() as canonical over Figure.clf() +* :ghpull:`22783`: Backport PR #22732: FIX: maybe improve renderer dance +* :ghpull:`22748`: Backport PR #22628 on branch v3.5.x (Add RuntimeWarning guard around division-by-zero) +* :ghpull:`22732`: FIX: maybe improve renderer dance +* :ghpull:`22764`: Backport PR #22756 on branch v3.5.x (Use system distutils instead of the setuptools copy) +* :ghpull:`22780`: Backport PR #22766 on branch v3.5.x (FIX: account for constant deprecations in Pillow 9.1) +* :ghpull:`22781`: Backport PR #22776 on branch v3.5.x (Fix colorbar stealing from a single axes and with panchor=False.) +* :ghpull:`22782`: Backport PR #22774 on branch v3.5.x (Remove outdated doc for pie chart) +* :ghpull:`22774`: Remove outdated doc for pie chart +* :ghpull:`22776`: Fix colorbar stealing from a single axes and with panchor=False. +* :ghpull:`22766`: FIX: account for deprecations of constant in Pillow 9.1 +* :ghpull:`22756`: Use system distutils instead of the setuptools copy +* :ghpull:`22750`: Backport PR #22743: Fix configure_subplots with tool manager +* :ghpull:`22743`: Fix configure_subplots with tool manager +* :ghpull:`22628`: Add RuntimeWarning guard around division-by-zero +* :ghpull:`22736`: Backport PR #22719 on branch v3.5.x (Fix incorrect deprecation warning) +* :ghpull:`22719`: Fix incorrect deprecation warning +* :ghpull:`22138`: Fix clearing subfigures +* :ghpull:`22729`: Backport PR #22711 on branch v3.5.x (RangeSlider handle set_val bugfix) +* :ghpull:`22711`: RangeSlider handle set_val bugfix +* :ghpull:`22701`: Backport PR #22691 on branch v3.5.x (FIX: remove toggle on QuadMesh cursor data) +* :ghpull:`22723`: Backport PR #22716 on branch v3.5.x (DOC: set canonical) +* :ghpull:`22703`: Backport PR #22689 on branch v3.5.x (Fix path_effects to work on text with spaces only) +* :ghpull:`22689`: Fix path_effects to work on text with spaces only +* :ghpull:`22691`: FIX: remove toggle on QuadMesh cursor data +* :ghpull:`22696`: Backport PR #22693 on branch v3.5.x (Remove QuadMesh from mouseover set.) +* :ghpull:`22693`: Remove QuadMesh from mouseover set. +* :ghpull:`22647`: Backport PR #22429 on branch v3.5.x (Enable windows/arm64 platform) +* :ghpull:`22653`: Simplify FreeType version check to avoid packaging +* :ghpull:`22646`: Manual backport of pr 22635 on v3.5.x +* :ghpull:`22429`: Enable windows/arm64 platform +* :ghpull:`22635`: FIX: Handle inverted colorbar axes with extensions +* :ghpull:`22313`: Fix colorbar exponents +* :ghpull:`22619`: Backport PR #22611 on branch v3.5.x (FIX: Colorbars check for subplotspec attribute before using) +* :ghpull:`22618`: Backport PR #22617 on branch v3.5.x (Bump actions/checkout from 2 to 3) +* :ghpull:`22611`: FIX: Colorbars check for subplotspec attribute before using +* :ghpull:`22617`: Bump actions/checkout from 2 to 3 +* :ghpull:`22595`: Backport PR #22005: Further defer backend selection +* :ghpull:`22602`: Backport PR #22596 on branch v3.5.x (Fix backend in matplotlibrc if unset in mplsetup.cfg) +* :ghpull:`22596`: Fix backend in matplotlibrc if unset in mplsetup.cfg +* :ghpull:`22597`: Backport PR #22594 on branch v3.5.x (FIX: do not pass dashes to collections in errorbar) +* :ghpull:`22594`: FIX: do not pass dashes to collections in errorbar +* :ghpull:`22593`: Backport PR #22559 on branch v3.5.x (fix: fill stairs should have lw=0 instead of edgecolor="none") +* :ghpull:`22005`: Further defer backend selection +* :ghpull:`22559`: fix: fill stairs should have lw=0 instead of edgecolor="none" +* :ghpull:`22592`: Backport PR #22141 on branch v3.5.x (Fix check 1d) +* :ghpull:`22141`: Fix check 1d +* :ghpull:`22588`: Backport PR #22445 on branch v3.5.x (Fix loading tk on windows when current process has >1024 modules.) +* :ghpull:`22445`: Fix loading tk on windows when current process has >1024 modules. +* :ghpull:`22575`: Backport PR #22572 on branch v3.5.x (Fix issue with unhandled Done exception) +* :ghpull:`22578`: Backport PR #22038 on branch v3.5.x (DOC: Include alternatives to deprecations in the documentation) +* :ghpull:`22572`: Fix issue with unhandled Done exception +* :ghpull:`22557`: Backport PR #22549 on branch v3.5.x (Really fix wheel building on CI) +* :ghpull:`22549`: Really fix wheel building on CI +* :ghpull:`22548`: Backport PR #22540 on branch v3.5.x (Reorder text api docs.) +* :ghpull:`22540`: Reorder text api docs. +* :ghpull:`22542`: Backport PR #22534 on branch v3.5.x (Fix issue with manual clabel) +* :ghpull:`22534`: Fix issue with manual clabel +* :ghpull:`22501`: Backport PR #22499 on branch v3.5.x (FIX: make the show API on webagg consistent with others) +* :ghpull:`22499`: FIX: make the show API on webagg consistent with others +* :ghpull:`22500`: Backport PR #22496 on branch v3.5.x (Fix units in quick start example) +* :ghpull:`22496`: Fix units in quick start example +* :ghpull:`22493`: Backport PR #22483 on branch v3.5.x (Tweak arrow demo size.) +* :ghpull:`22492`: Backport PR #22476: FIX: Include (0, 0) offsets in scatter autoscaling +* :ghpull:`22483`: Tweak arrow demo size. +* :ghpull:`22476`: FIX: Include (0, 0) offsets in scatter autoscaling +* :ghpull:`22481`: Backport PR #22479 on branch v3.5.x (adds _enum qualifier for QColorDialog.ShowAlphaChannel. Closes #22471.) +* :ghpull:`22479`: adds _enum qualifier for QColorDialog.ShowAlphaChannel. Closes #22471. +* :ghpull:`22475`: Backport PR #22474 on branch v3.5.x (Clarify secondary_axis documentation) +* :ghpull:`22474`: Clarify secondary_axis documentation +* :ghpull:`22462`: Backport PR #22458 on branch v3.5.x (Fix Radar Chart Gridlines for Non-Circular Charts) +* :ghpull:`22456`: Backport PR #22375 on branch v3.5.x (Re-enable cibuildwheel on push) +* :ghpull:`22375`: Re-enable cibuildwheel on push +* :ghpull:`22443`: Backport PR #22442 on branch v3.5.x (CI: skip test to work around gs bug) +* :ghpull:`22442`: CI: skip test to work around gs bug +* :ghpull:`22441`: Backport PR #22434 on branch v3.5.x (DOC: imbalanced backticks.) +* :ghpull:`22436`: Backport PR #22431 on branch v3.5.x (Update Scipy intersphinx inventory link) +* :ghpull:`22438`: Backport PR #22430 on branch v3.5.x (fix method name in doc) +* :ghpull:`22434`: DOC: imbalanced backticks. +* :ghpull:`22426`: Backport PR #22398 on branch v3.5.x (Pin coverage to fix CI) +* :ghpull:`22428`: Backport PR #22368 on branch v3.5.x (Pin dependencies to fix CI) +* :ghpull:`22427`: Backport PR #22396 on branch v3.5.x (Clarify note in get_cmap()) +* :ghpull:`22396`: Clarify note in get_cmap() +* :ghpull:`22398`: Pin coverage to fix CI +* :ghpull:`22368`: Pin dependencies to fix CI +* :ghpull:`22358`: Backport PR #22349 on branch v3.5.x (Use latex as the program name for kpsewhich) +* :ghpull:`22349`: Use latex as the program name for kpsewhich +* :ghpull:`22348`: Backport PR #22346 on branch v3.5.x (Remove invalid ```` tag in ``animation.HTMLWriter``) +* :ghpull:`22346`: Remove invalid ```` tag in ``animation.HTMLWriter`` +* :ghpull:`22328`: Backport PR #22288 on branch v3.5.x (update documentation after #18966) +* :ghpull:`22288`: update documentation after #18966 +* :ghpull:`22325`: Backport PR #22283: Fixed ``repr`` for ``SecondaryAxis`` +* :ghpull:`22322`: Backport PR #22077 on branch v3.5.x (Fix keyboard event routing in Tk backend (fixes #13484, #14081, and #22028)) +* :ghpull:`22321`: Backport PR #22290 on branch v3.5.x (Respect ``position`` and ``group`` argument in Tk toolmanager add_toolitem) +* :ghpull:`22318`: Backport PR #22293 on branch v3.5.x (Modify example for x-axis tick labels at the top) +* :ghpull:`22319`: Backport PR #22279 on branch v3.5.x (Remove Axes sublists from docs) +* :ghpull:`22327`: Backport PR #22326 on branch v3.5.x (CI: ban coverage 6.3 that may be causing random hangs in fork test) +* :ghpull:`22326`: CI: ban coverage 6.3 that may be causing random hangs in fork test +* :ghpull:`22077`: Fix keyboard event routing in Tk backend (fixes #13484, #14081, and #22028) +* :ghpull:`22290`: Respect ``position`` and ``group`` argument in Tk toolmanager add_toolitem +* :ghpull:`22293`: Modify example for x-axis tick labels at the top +* :ghpull:`22311`: Backport PR #22285 on branch v3.5.x (Don't warn on grid removal deprecation if grid is hidden) +* :ghpull:`22310`: Backport PR #22294 on branch v3.5.x (Add set_cursor method to FigureCanvasTk) +* :ghpull:`22285`: Don't warn on grid removal deprecation if grid is hidden +* :ghpull:`22294`: Add set_cursor method to FigureCanvasTk +* :ghpull:`22309`: Backport PR #22301 on branch v3.5.x (FIX: repositioning axes labels: use get_window_extent instead for spines.) +* :ghpull:`22301`: FIX: repositioning axes labels: use get_window_extent instead for spines. +* :ghpull:`22307`: Backport PR #22306 on branch v3.5.x (FIX: ensure that used sub-packages are actually imported) +* :ghpull:`22306`: FIX: ensure that used sub-packages are actually imported +* :ghpull:`22283`: Fixed ``repr`` for ``SecondaryAxis`` +* :ghpull:`22275`: Backport PR #22254 on branch v3.5.x (Disable QuadMesh cursor data by default) +* :ghpull:`22254`: Disable QuadMesh cursor data by default +* :ghpull:`22269`: Backport PR #22265 on branch v3.5.x (Fix Qt enum access.) +* :ghpull:`22265`: Fix Qt enum access. +* :ghpull:`22259`: Backport PR #22256 on branch v3.5.x (Skip tests on the -doc branches) +* :ghpull:`22238`: Backport PR #22235 on branch v3.5.x (Run wheel builds on PRs when requested by a label) +* :ghpull:`22241`: Revert "Backport PR #22179 on branch v3.5.x (FIX: macosx check case-insensitive app name)" +* :ghpull:`22248`: Backport PR #22206 on branch v3.5.x (Improve formatting of "Anatomy of a figure") +* :ghpull:`22235`: Run wheel builds on PRs when requested by a label +* :ghpull:`22206`: Improve formatting of "Anatomy of a figure" +* :ghpull:`22220`: Backport PR #21833: Enforce backport conditions on v*-doc branches +* :ghpull:`22219`: Backport PR #22218 on branch v3.5.x (Fix typo in ``tutorials/intermediate/arranging_axes.py``) +* :ghpull:`22218`: Fix typo in ``tutorials/intermediate/arranging_axes.py`` +* :ghpull:`22217`: Backport PR #22209 on branch v3.5.x (DOC: Document default join style) +* :ghpull:`22209`: DOC: Document default join style +* :ghpull:`22214`: Backport PR #22208 on branch v3.5.x (Stop sorting artists in Figure Options dialog) +* :ghpull:`22215`: Backport PR #22177 on branch v3.5.x (Document ArtistList) +* :ghpull:`22177`: Document ArtistList +* :ghpull:`22208`: Stop sorting artists in Figure Options dialog +* :ghpull:`22199`: DOC: git:// is deprecated. +* :ghpull:`22210`: Backport PR #22202 on branch v3.5.x (PR: Fix merge of 18966) +* :ghpull:`22202`: PR: Fix merge of 18966 +* :ghpull:`22201`: Backport PR #22053 on branch v3.5.x (DOC: Document default cap styles) +* :ghpull:`22053`: DOC: Document default cap styles +* :ghpull:`22195`: Backport PR #22179 on branch v3.5.x (FIX: macosx check case-insensitive app name) +* :ghpull:`22192`: Backport PR #22190 on branch v3.5.x (DOC: Fix upstream URL for merge in CircleCI) +* :ghpull:`22188`: Backport PR #22187 on branch v3.5.x (Fix typo in ``axhline`` docstring) +* :ghpull:`22187`: Fix typo in ``axhline`` docstring +* :ghpull:`22185`: Backport PR #22184 on branch v3.5.x (Removed dev from 3.10-version) +* :ghpull:`22186`: Backport PR #21943 on branch v3.5.x (DOC: explain too many ticks) +* :ghpull:`21943`: DOC: explain too many ticks +* :ghpull:`22184`: Removed dev from 3.10-version +* :ghpull:`22168`: Backport PR #22144 on branch v3.5.x (Fix cl subgridspec) +* :ghpull:`22144`: Fix cl subgridspec +* :ghpull:`22155`: Backport PR #22082 on branch v3.5.x (Update both zoom/pan states on wx when triggering from keyboard.) +* :ghpull:`22082`: Update both zoom/pan states on wx when triggering from keyboard. +* :ghpull:`22153`: Backport PR #22147 on branch v3.5.x (Fix loading user-defined icons for Tk toolbar) +* :ghpull:`22152`: Backport PR #22135 on branch v3.5.x (Fix loading user-defined icons for Qt plot window) +* :ghpull:`22151`: Backport PR #22078 on branch v3.5.x (Prevent tooltips from overlapping buttons in NavigationToolbar2Tk (fixes issue mentioned in #22028)) +* :ghpull:`22135`: Fix loading user-defined icons for Qt plot window +* :ghpull:`22078`: Prevent tooltips from overlapping buttons in NavigationToolbar2Tk (fixes issue mentioned in #22028) +* :ghpull:`22147`: Fix loading user-defined icons for Tk toolbar +* :ghpull:`22136`: Backport PR #22132 on branch v3.5.x (TST: Increase fp tolerances for some images) +* :ghpull:`22132`: TST: Increase fp tolerances for some images +* :ghpull:`22121`: Backport PR #22116 on branch v3.5.x (FIX: there is no add_text method, fallback to add_artist) +* :ghpull:`22117`: Backport PR #21860 on branch v3.5.x (DOC: Update style sheet reference) +* :ghpull:`22116`: FIX: there is no add_text method, fallback to add_artist +* :ghpull:`22038`: DOC: Include alternatives to deprecations in the documentation +* :ghpull:`22074`: Backport PR #22066 on branch v3.5.x (FIX: Remove trailing zeros from offset significand) +* :ghpull:`22106`: Backport PR #22089: FIX: squash memory leak in colorbar +* :ghpull:`22089`: FIX: squash memory leak in colorbar +* :ghpull:`22101`: Backport PR #22099 on branch v3.5.x (CI: Disable numpy avx512 instructions) +* :ghpull:`22099`: CI: Disable numpy avx512 instructions +* :ghpull:`22095`: Backport PR #22083 on branch v3.5.x (Fix reference to Matplotlib FAQ in doc/index.rst) +* :ghpull:`22066`: FIX: Remove trailing zeros from offset significand +* :ghpull:`22072`: Backport PR #22071 on branch v3.5.x (Fix a small typo in docstring ("loation" --> "location")) +* :ghpull:`22071`: Fix a small typo in docstring ("loation" --> "location") +* :ghpull:`22070`: Backport PR #22069 on branch v3.5.x ([Doc] Fix typo in ``units.py`` documentation example) +* :ghpull:`22069`: [Doc] Fix typo in ``units.py`` documentation example +* :ghpull:`22067`: Backport PR #22064 on branch v3.5.x (DOC: Clarify y parameter in Axes.set_title) +* :ghpull:`22064`: DOC: Clarify y parameter in Axes.set_title +* :ghpull:`22049`: Backport PR #22048 on branch v3.5.x (Document how to prevent TeX from treating ``&``, ``#`` as special.) +* :ghpull:`22048`: Document how to prevent TeX from treating ``&``, ``#`` as special. +* :ghpull:`22047`: Backport PR #22044 on branch v3.5.x (Get correct source code link for decorated functions) +* :ghpull:`22044`: Get correct source code link for decorated functions +* :ghpull:`22024`: Backport PR #22009 on branch v3.5.x (FIX: Prevent set_alpha from changing color of legend patch) +* :ghpull:`22009`: FIX: Prevent set_alpha from changing color of legend patch +* :ghpull:`22019`: Backport PR #22018 on branch v3.5.x (BUG: fix handling of zero-dimensional arrays in cbook._reshape_2D) +* :ghpull:`22018`: BUG: fix handling of zero-dimensional arrays in cbook._reshape_2D +* :ghpull:`21996`: Backport PR #21990 on branch v3.5.x (Fix rubberbanding on wx+py3.10.) +* :ghpull:`21990`: Fix rubberbanding on wx+py3.10. +* :ghpull:`21987`: Backport PR #21862 on branch v3.5.x (DOC: Simplify markevery demo) +* :ghpull:`21969`: Backport PR #21948 on branch v3.5.x (Distinguish AbstractMovieWriter and MovieWriter in docs.) +* :ghpull:`21948`: Distinguish AbstractMovieWriter and MovieWriter in docs. +* :ghpull:`21953`: Backport PR #21946 on branch v3.5.x (DOC: fix interactive to not put Event Handling and Interactive Guide …) +* :ghpull:`21946`: DOC: fix interactive to not put Event Handling and Interactive Guide … + +Issues (61): + +* :ghissue:`22954`: [Doc]: v3.5.1 github stats are missing +* :ghissue:`22959`: [MNT]: macos-latest memory leak over threshold +* :ghissue:`22921`: [Bug]: Regression in animation from #22175 +* :ghissue:`22908`: [Bug]: QuadMesh get_cursor_data errors if no array is set +* :ghissue:`21901`: Suggested clarification of comments in errorbar helpers +* :ghissue:`22932`: [Doc]: small edits to the Pull request guidelines +* :ghissue:`22858`: [Bug]: FigureCanvasTkAgg call creates memory leak +* :ghissue:`20490`: Memory leaks on matplotlib 3.4.2 (and 3.4.0) +* :ghissue:`22900`: [Doc]: Typo in triage acknowledgment +* :ghissue:`22341`: [Bug]: GridSpec or related change between 3.4.3 and 3.5.1 +* :ghissue:`22472`: [Bug]: ConciseDateFormatter not showing year anywhere when plotting <12 months +* :ghissue:`22874`: [Bug]: Textbox doesn't accept input +* :ghissue:`21893`: [Bug]: ``backend_pdf`` gives ``TTLibError`` with ``pdf.fonttype : 42`` +* :ghissue:`22840`: [Bug]: Blank output EPS file when using latex and figure.autolayout = True +* :ghissue:`22762`: [Bug]: Issue with find_nearest_contour in contour.py +* :ghissue:`22823`: [Bug]: Changing Linestyle in plot window swaps some plotted lines +* :ghissue:`22804`: [Bug]: Quiver not working with subfigure? +* :ghissue:`22673`: [Bug]: tight_layout (version 3.5+) +* :ghissue:`21930`: [Bug]: EPS savefig messed up by 'figure.autolayout' rcParam on 3.5.0 +* :ghissue:`22753`: windows CI broken on azure +* :ghissue:`22088`: [Bug]: Tool Manager example broken +* :ghissue:`22624`: [Bug]: invalid value encountered with 'ortho' projection mode +* :ghissue:`22640`: [Bug]: Confusing deprecation warning when empty data passed to axis with category units +* :ghissue:`22137`: [Bug]: Cannot clear figure of subfigures +* :ghissue:`22706`: [Bug]: RangeSlider.set_val does not move the slider (only poly and value) +* :ghissue:`22727`: MAtplolib pan and zoom dead slow on new PC +* :ghissue:`22687`: [Bug]: Empty text or text with a newline at either end + path_effects crashes +* :ghissue:`22694`: Revert set_show_cursor_data +* :ghissue:`22520`: [Bug]: Slow lasso selector over QuadMesh collection +* :ghissue:`22648`: Add packaging to setup_requires? +* :ghissue:`22052`: [Bug]: invert_yaxis function cannot invert the "over value" in colorbar axes +* :ghissue:`22576`: [Bug]: ``inset_axes`` colorbar + ``tight_layout`` raises ``AttributeError`` +* :ghissue:`22590`: [Bug]: ValueError: Do not know how to convert "list" to dashes; when using axes errorbar. +* :ghissue:`21998`: [Bug]: Working with PyQt5, the different import order will make different result. +* :ghissue:`22330`: [Bug]: possible regression with pandas 1.4 with plt.plot when using a single column dataframe as the x argument +* :ghissue:`22125`: [Bug]: ``plt.plot`` thinks ``pandas.Series`` is 2-dimensional when nullable data type is used +* :ghissue:`22378`: [Bug]: TkAgg fails to find Tcl/Tk libraries in Windows for processes with a large number of modules loaded +* :ghissue:`22577`: [Bug]: Erroneous deprecation warning help message +* :ghissue:`21798`: [Bug]: Unhandled _get_renderer.Done exception in wxagg backend +* :ghissue:`22532`: [Issue]: Manually placing contour labels using ``clabel`` not working +* :ghissue:`22470`: [Bug]: Subsequent scatter plots work incorrectly +* :ghissue:`22471`: [Bug]: formlayout fails on PyQt6 due to the unqualified enum ShowAlphaChannel in class ColorButton +* :ghissue:`22473`: [Bug]: Secondary axis does not accept python builtins for transform +* :ghissue:`22384`: [Bug]: Curve styles gets mixed up when edited in the Curves Tab of Figure Options (Edit Axis) +* :ghissue:`22028`: [Bug]: mpl with py3.10.1 - Interactive figures - Constrain pan/zoom to x/y axis not work +* :ghissue:`13484`: Matplotlib keymap stop working after pressing tab +* :ghissue:`20130`: tk toolmanager add_toolitem fails to add tool to group other than the last one +* :ghissue:`21723`: [Bug]: Some styles trigger pcolormesh grid deprecation +* :ghissue:`22300`: [Bug]: Saving a fig with a colorbar using a ``TwoSlopeNorm`` sometimes results in 'posx and posy should be finite values' +* :ghissue:`22305`: [Bug]: Import Error in Matplotlib 3.5.1 +* :ghissue:`21917`: [Bug]: pcolormesh is not responsive in Matplotlib 3.5 +* :ghissue:`22094`: [Doc]: No documentation on ArtistList +* :ghissue:`21979`: [Doc]: Clarify default capstyle +* :ghissue:`22143`: [Bug]: ``constrained_layout`` merging similar subgrids +* :ghissue:`22131`: [Bug]: png icon image fails to load for manually defined tool buttons +* :ghissue:`22093`: [Bug]: AttributeError: 'AxesSubplot' object has no attribute 'add_text' +* :ghissue:`22085`: [Bug]: Memory leak with colorbar.make_axes +* :ghissue:`22065`: [Bug]: Additive offset with trailing zeros +* :ghissue:`15493`: common_texification misses & (ampersand) +* :ghissue:`22039`: [Doc]: [source] link for deprecated functions leads to _api/deprecation.py +* :ghissue:`22016`: [Bug]: matplotlib 3.3 changed how plt.hist handles iterables of zero-dimensional arrays. diff --git a/doc/users/prev_whats_new/github_stats_3.5.3.rst b/doc/users/prev_whats_new/github_stats_3.5.3.rst new file mode 100644 index 000000000000..bafd6d5c27eb --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.5.3.rst @@ -0,0 +1,127 @@ +.. _github-stats-3-5-3: + +GitHub statistics for 3.5.3 (Aug 10, 2022) +========================================== + +GitHub statistics for 2022/05/03 (tag: v3.5.2) - 2022/08/10 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 19 issues and merged 66 pull requests. +The full list can be seen `on GitHub `__ + +The following 20 authors contributed 99 commits. + +* Antony Lee +* Biswapriyo Nath +* David Gilbertson +* DWesl +* Elliott Sales de Andrade +* GavinZhang +* Greg Lucas +* Jody Klymak +* Kayran Schmidt +* Matthew Feickert +* Nickolaos Giannatos +* Oscar Gustafsson +* Ruth Comer +* SaumyaBhushan +* Scott Jones +* Scott Shambaugh +* tfpf +* Thomas A Caswell +* Tim Hoffmann +* wsykala + +GitHub issues and pull requests: + +Pull Requests (66): + +* :ghpull:`23591`: Backport PR #23549 on branch v3.5.x (Don't clip colorbar dividers) +* :ghpull:`23593`: STY: Fix whitespace error from new flake8 +* :ghpull:`23549`: Don't clip colorbar dividers +* :ghpull:`23528`: Backport PR #23523 on branch v3.5.x (TST: Update Quantity test class) +* :ghpull:`23523`: TST: Update Quantity test class +* :ghpull:`23508`: Add explicit registration of units in examples +* :ghpull:`23515`: Backport PR #23462: Fix AttributeError for pickle load of Figure class +* :ghpull:`23518`: Backport PR #23514 on branch v3.5.x (Fix doc build) +* :ghpull:`23517`: Backport PR #23511 on branch v3.5.x (supporting IBM i OS) +* :ghpull:`23511`: supporting IBM i OS +* :ghpull:`23462`: Fix AttributeError for pickle load of Figure class +* :ghpull:`23488`: Backport PR #23066 on branch v3.5.x (BLD: Define PyErr_SetFromWindowsErr on Cygwin.) +* :ghpull:`23066`: BLD: Define PyErr_SetFromWindowsErr on Cygwin. +* :ghpull:`23479`: Pin setuptools_scm on v3.5.x +* :ghpull:`22998`: Backport PR #22987 on branch v3.5.x (CI: bump test limit from tkagg on osx) +* :ghpull:`23478`: Backport PR #23476: FIX: reset to original DPI in getstate +* :ghpull:`23476`: FIX: reset to original DPI in getstate +* :ghpull:`23458`: Backport PR #23445 on branch v3.5.x (Compare thread native ids when checking whether running on main thread.) +* :ghpull:`23440`: Backport PR #23430 on branch v3.5.x (Fix divide by 0 runtime warning) +* :ghpull:`23430`: Fix divide by 0 runtime warning +* :ghpull:`23344`: Backport PR #23333: Fix errorbar handling of nan. +* :ghpull:`23333`: Fix errorbar handling of nan. +* :ghpull:`23338`: Backport PR #23278: Remove internal use of get/set dpi +* :ghpull:`23331`: Backport PR #22835 on branch v3.5.x (Fix BoundaryNorm cursor data output) +* :ghpull:`22835`: Fix BoundaryNorm cursor data output +* :ghpull:`23292`: Backport PR #23232 on branch v3.5.x (Fix passing stem markerfmt positionally when locs are not given) +* :ghpull:`23275`: Backport PR #23260 on branch v3.5.x (Fix Colorbar extend patches to have correct alpha) +* :ghpull:`23312`: Pin to an older pydata-sphinx-theme for v3.5.x +* :ghpull:`23278`: Remove internal use of get/set dpi +* :ghpull:`23232`: Fix passing stem markerfmt positionally when locs are not given +* :ghpull:`22865`: Fix issue with colorbar extend and drawedges +* :ghpull:`23260`: Fix Colorbar extend patches to have correct alpha +* :ghpull:`23245`: Backport PR #23144 on branch v3.5.x (Only import setuptools_scm when we are in a matplotlib git repo) +* :ghpull:`23144`: Only import setuptools_scm when we are in a matplotlib git repo +* :ghpull:`23242`: Backport PR #23203 on branch v3.5.x (Honour ``panchor`` keyword for colorbar on subplot) +* :ghpull:`23203`: Honour ``panchor`` keyword for colorbar on subplot +* :ghpull:`23228`: Backport PR #23209 on branch v3.5.x (Fix the vertical alignment of overunder symbols.) +* :ghpull:`23209`: Fix the vertical alignment of overunder symbols. +* :ghpull:`23184`: Backport PR #23174: Make sure SubFigure has _cachedRenderer +* :ghpull:`23194`: Backport PR #23095: Try to unbreak CI by xfailing OSX Tk tests +* :ghpull:`23113`: Backport PR #23057 and #23106 +* :ghpull:`23185`: Backport PR #23168 on branch v3.5.x (Corrected docstring for artist.Artist.set_agg_filter) +* :ghpull:`23168`: Corrected docstring for artist.Artist.set_agg_filter +* :ghpull:`23174`: Make sure SubFigure has _cachedRenderer +* :ghpull:`23110`: Tweak subprocess_run_helper. +* :ghpull:`23138`: Backport PR #23137 on branch v3.5.x (DOC fix typo) +* :ghpull:`23137`: DOC fix typo +* :ghpull:`23125`: Backport PR #23122 on branch v3.5.x (Remove redundant rcparam default) +* :ghpull:`23120`: Backport PR #23115 on branch v3.5.x (DOC fixed duplicate/wrong default) +* :ghpull:`23095`: Try to unbreak CI by xfailing OSX Tk tests +* :ghpull:`23106`: Reuse subprocess_run_helper in test_pylab_integration. +* :ghpull:`23112`: Backport PR #23111 on branch v3.5.x (Fix _g_sig_digits for value<0 and delta=0.) +* :ghpull:`23111`: Fix _g_sig_digits for value<0 and delta=0. +* :ghpull:`23057`: FIX: ensure switching the backend installs repl hook +* :ghpull:`23075`: Backport PR #23069 on branch v3.5.x (TST: forgive more failures on pyside2 / pyside6 cross imports) +* :ghpull:`23069`: TST: forgive more failures on pyside2 / pyside6 cross imports +* :ghpull:`22981`: Backport PR #22979 on branch v3.5.x (Skip additional backend tests on import error) +* :ghpull:`23064`: Backport PR #22975 on branch v3.5.x (MNT: fix __array__ to numpy) +* :ghpull:`22975`: MNT: fix __array__ to numpy +* :ghpull:`23058`: Backport PR #23051 on branch v3.5.x (Fix variable initialization due to jump bypassing it) +* :ghpull:`23051`: Fix variable initialization due to jump bypassing it +* :ghpull:`23010`: Backport PR #23000 on branch v3.5.x (Additional details on VS install on installation page) +* :ghpull:`22995`: Backport PR #22994 on branch v3.5.x (Docs: ignore >>> on code prompts on documentation prompts) +* :ghpull:`23001`: CI: Add trivial pre-commit.ci config to avoid CI failure +* :ghpull:`22987`: CI: bump test limit from tkagg on osx +* :ghpull:`22979`: Skip additional backend tests on import error + +Issues (19): + +* :ghissue:`22864`: [Bug]: Colorbar with drawedges=True and extend='both' does not draw edges at extremities +* :ghissue:`23382`: [TST] Upcoming dependency test failures +* :ghissue:`23470`: [Bug]: fig.canvas.mpl_connect in 3.5.2 not registering events in jupyter lab unless using widget pan or zoom controls +* :ghissue:`22997`: [Bug]: Cygwin build fails due to use of Windows-only functions in _tkagg.cpp +* :ghissue:`23471`: [Bug]: DPI of a figure is doubled after unpickling on M1 Mac +* :ghissue:`23050`: [Doc]: Docstring for artist.Artist.set_agg_filter is incorrect +* :ghissue:`23307`: [Bug]: PEX warns about missing ``setuptools`` from ``install_requires`` in matplotlib +* :ghissue:`23330`: [Bug]: Missing values cause exception in errorbar plot +* :ghissue:`21915`: [Bug]: scalar mappable format_cursor_data crashes on BoundarNorm +* :ghissue:`22970`: [Bug]: Colorbar extend patches do not have correct alpha +* :ghissue:`23114`: [Bug]: matplotlib __init__.py checks for .git folder 2 levels up, then errors due to setup tools_scm +* :ghissue:`23157`: [Bug]: colorbar ignores keyword panchor=False +* :ghissue:`23229`: [Bug]: matplotlib==3.5.2 breaks ipywidgets +* :ghissue:`18085`: vertical alignment of \sum depends on the presence of subscripts and superscripts +* :ghissue:`23173`: [Bug]: Crash when adding clabels to subfigures +* :ghissue:`23108`: [Bug]: Imshow with all negative values leads to math domain errors. +* :ghissue:`23042`: [Bug]: Figures fail to redraw with IPython +* :ghissue:`23004`: [Bug]: test failure of test_cross_Qt_imports in 3.5.2 +* :ghissue:`22973`: [Bug]: v3.5.2 causing plot to crash when plotting object with ``__array__`` method diff --git a/doc/users/prev_whats_new/github_stats_3.6.0.rst b/doc/users/prev_whats_new/github_stats_3.6.0.rst new file mode 100644 index 000000000000..aac0c0445fd3 --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.6.0.rst @@ -0,0 +1,1292 @@ +.. _github-stats-3-6-0: + +GitHub statistics for 3.6.0 (Sep 15, 2022) +========================================== + +GitHub statistics for 2021/11/16 (tag: v3.5.0) - 2022/09/15 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 202 issues and merged 894 pull requests. +The full list can be seen `on GitHub `__ + +The following 174 authors contributed 4425 commits. + +* Abhishek K M +* Adeel Hassan +* agra +* Aitik Gupta +* ambi7 +* Andras Deak +* Andres Martinez +* Andrew Fennell +* andrzejnovak +* Andrés Martínez +* Anna Mastori +* AnnaMastori +* Ante Sikic +* Antony Lee +* arndRemy +* Ben Root +* Biswapriyo Nath +* cavesdev +* Clément Phan +* Clément Walter +* code-review-doctor +* Connor Cozad +* Constantine Evans +* Croadden +* daniilS +* Danilo Palumbo +* David Gilbertson +* David Ketcheson +* David Matos +* David Poznik +* David Stansby +* Davide Sandonà +* dependabot[bot] +* dermasugita +* Diego Solano +* Dimitri Papadopoulos +* dj4t9n +* Dmitriy Fishman +* DWesl +* Edouard Berthe +* eindH +* Elliott Sales de Andrade +* Eric Firing +* Eric Larson +* Eric Prestat +* Federico Ariza +* Felix Nößler +* Fernando +* Gajendra Pal +* gajendra0180 +* GavinZhang +* Greg Lucas +* hannah +* Hansin Ahuja +* Harshal Prakash Patankar +* Hassan Kibirige +* Haziq Khurshid +* Henry +* henrybeUM +* Hood +* Hood Chatham +* Ian Hunt-Isaak +* Ian Thomas +* igurin-invn +* ikhebgeenaccount +* Isha Mehta +* Jake Bowhay +* Jake Li +* Jake Lishman +* Jake VanderPlas +* Jakub Klus +* James Tocknell +* Jan-Hendrik Müller +* Jay Joshi +* Jay Stanley +* jayjoshi112711 +* Jeff Beck +* Jody Klymak +* Joel Frederico +* Joseph Fox-Rabinovitz +* Josh Soref +* Jouni K. Seppänen +* Kayran Schmidt +* kdpenner +* Kian Eliasi +* Kinshuk Dua +* kislovskiy +* KIU Shueng Chuan +* kjain +* kolibril13 +* krassowski +* Krish-sysadmin +* Leeh Peter +* lgfunderburk +* Liam Toney +* Lucas Ricci +* Luke Davis +* luz paz +* mackopes +* MAKOMO +* MalikIdreesHasa +* Marcin Swaltek +* Mario +* Mario Sergio Valdés Tresanco +* martinRenou +* Matthew Feickert +* Matthias Bussonnier +* Mauricio Collares +* MeeseeksMachine +* melissawm +* Mr-Milk +* Navid C. Constantinou +* Nickolaos Giannatos +* Nicolas P. Rougier +* Niyas Sait +* noatamir +* ojeda-e +* Olivier Gauthé +* Oscar Gustafsson +* patquem +* Philipp Rohde +* Pieter Eendebak +* Pieter P +* Péter Leéh +* Qijia Liu +* Quentin Peter +* Raphael Quast +* rditlar9 +* Richard Penney +* richardsheridan +* Rike-Benjamin Schuppner +* Robert Cimrman +* Roberto Toro +* root +* Ruth Comer +* Ruth G. N +* Ruth Nainggolan +* Ryan May +* Rémi Achard +* SaumyaBhushan +* Scott Jones +* Scott Shambaugh +* selormtamakloe +* Simon Hoxbro +* skywateryang +* Stefanie Molin +* Steffen Rehberg +* stone +* Sven Eschlbeck +* sveneschlbeck +* takimata +* tfpf +* Thomas A Caswell +* Tim Hoffmann +* Tobias Megies +* Tomas Hrnciar +* Tomasz Kuliński +* trichter +* unknown +* Uwe Hubert +* vfdev-5 +* Vishal Chandratreya +* Vishal Pankaj Chandratreya +* Vishnu V K +* vk0812 +* Vlad Korolev +* Will Qian +* William Qian +* wqh17101 +* wsykala +* yaaun +* Yannic Schroeder +* yuanx749 +* 渡邉 美希 + +GitHub issues and pull requests: + +Pull Requests (894): + +* :ghpull:`23814`: Consolidate release notes for 3.6 +* :ghpull:`23899`: Backport PR #23885 on branch v3.6.x (DOC: Rearrange navbar-end elements) +* :ghpull:`23898`: Backport PR #23892 on branch v3.6.x (DOC: Fix docs for linestyles in contour) +* :ghpull:`23885`: DOC: Rearrange navbar-end elements +* :ghpull:`23894`: Backport PR #23881 on branch v3.6.x (Fix Pillow compatibility in example) +* :ghpull:`23897`: Backport PR #23887 on branch v3.6.x (Add missing label argument to barh docs) +* :ghpull:`23892`: DOC: Fix docs for linestyles in contour +* :ghpull:`23887`: Add missing label argument to barh docs +* :ghpull:`23893`: Backport PR #23886 on branch v3.6.x (CI: prefer (older) binaries over (newer) sdists) +* :ghpull:`23881`: Fix Pillow compatibility in example +* :ghpull:`23886`: CI: prefer (older) binaries over (newer) sdists +* :ghpull:`23880`: Backport PR #23862 on branch v3.6.x (Remove triggering of deprecation warning in AnchoredEllipse) +* :ghpull:`23862`: Remove triggering of deprecation warning in AnchoredEllipse +* :ghpull:`23879`: Backport PR #23864 on branch v3.6.x (Correct and improve documentation for anchored artists) +* :ghpull:`23877`: Backport PR #23841 on branch v3.6.x (clarified that hist computes histogram on unbinned data) +* :ghpull:`23872`: Backport PR #23871 on branch v3.6.x (DOC: Fix formatting of pick event demo example) +* :ghpull:`23841`: clarified that hist computes histogram on unbinned data +* :ghpull:`23864`: Correct and improve documentation for anchored artists +* :ghpull:`23871`: DOC: Fix formatting of pick event demo example +* :ghpull:`23869`: Backport PR #23867 on branch v3.6.x (DOC: fix deprecation warnings in examples) +* :ghpull:`23867`: DOC: fix deprecation warnings in examples +* :ghpull:`23858`: Backport PR #23855 on branch v3.6.x (DOC: fix deprecation warnings) +* :ghpull:`23859`: Backport PR #23844 on branch v3.6.x (Further improve dev setup instructions) +* :ghpull:`23844`: Further improve dev setup instructions +* :ghpull:`23855`: DOC: fix deprecation warnings +* :ghpull:`23854`: Backport PR #23852 on branch v3.6.x (Fix cross-compiling internal freetype) +* :ghpull:`23852`: Fix cross-compiling internal freetype +* :ghpull:`23853`: Backport PR #23830 on branch v3.6.x (Start testing on Python 3.11) +* :ghpull:`23830`: Start testing on Python 3.11 +* :ghpull:`23851`: Backport PR #23850 on branch v3.6.x (removed single word in documenting doc) +* :ghpull:`23850`: removed single word in documenting doc +* :ghpull:`23848`: Backport PR #23843 on branch v3.6.x (Clarify that pycairo>=1.14.0 is needed.) +* :ghpull:`23843`: Clarify that pycairo>=1.14.0 is needed. +* :ghpull:`23842`: Backport PR #23840 on branch v3.6.x (Remove documentation for axes_grid) +* :ghpull:`23838`: Backport PR #23834 on branch v3.6.x (Revert "Refactor handling of tick and ticklabel visibility in Axis.clear") +* :ghpull:`23840`: Remove documentation for axes_grid +* :ghpull:`23837`: Backport PR #23833 on branch v3.6.x (Remove search field from sidebar) +* :ghpull:`23836`: Backport PR #23823 on branch v3.6.x ([DOC] Improve dev setup description) +* :ghpull:`23834`: Revert "Refactor handling of tick and ticklabel visibility in Axis.clear" +* :ghpull:`23833`: Remove search field from sidebar +* :ghpull:`23823`: [DOC] Improve dev setup description +* :ghpull:`23822`: Backport PR #23813 on branch v3.6.x (Triplot duplicated label) +* :ghpull:`23813`: Triplot duplicated label +* :ghpull:`23811`: Backport PR #23805 on branch v3.6.x (sphinxext: Do not copy plot_directive.css's metadata) +* :ghpull:`23805`: sphinxext: Do not copy plot_directive.css's metadata +* :ghpull:`23800`: Backport PR #23785 on branch v3.6.x (FIX: ensure type stability for missing cmaps in ``set_cmap``) +* :ghpull:`23799`: Backport PR #23790 on branch v3.6.x (DOC: Add cache busting to all static assets) +* :ghpull:`23785`: FIX: ensure type stability for missing cmaps in ``set_cmap`` +* :ghpull:`23790`: DOC: Add cache busting to all static assets +* :ghpull:`23791`: Backport PR #23774 on branch v3.6.x (Correct rcParams-name in AutoDateFormatter doc-string) +* :ghpull:`23792`: Backport PR #23781 on branch v3.6.x (ci: Add plot types to sphinx-gallery artifacts) +* :ghpull:`23789`: Backport PR #23786 on branch v3.6.x (DOC: fontfallback works for most of the backends) +* :ghpull:`23788`: Backport PR #23784 on branch v3.6.x (DOC: Fix num2date docstring) +* :ghpull:`23786`: DOC: fontfallback works for most of the backends +* :ghpull:`23784`: DOC: Fix num2date docstring +* :ghpull:`23781`: ci: Add plot types to sphinx-gallery artifacts +* :ghpull:`23783`: Backport PR #23782 on branch v3.6.x (Remove ``Axes.cla`` from examples) +* :ghpull:`23782`: Remove ``Axes.cla`` from examples +* :ghpull:`23774`: Correct rcParams-name in AutoDateFormatter doc-string +* :ghpull:`23773`: Backport PR #23772 on branch v3.6.x (3d plots what's new cleanups) +* :ghpull:`23772`: 3d plots what's new cleanups +* :ghpull:`23765`: Backport PR #23762 on branch v3.6.x (FIX: legend handler warning too liberal) +* :ghpull:`23762`: FIX: legend handler warning too liberal +* :ghpull:`23759`: Backport PR #23686 on branch v3.6.x (Improve matplotlib.pyplot importtime by caching ArtistInspector) +* :ghpull:`23686`: Improve matplotlib.pyplot importtime by caching ArtistInspector +* :ghpull:`23756`: Backport PR #23569 on branch v3.6.x (Fix hidden xlabel bug in colorbar) +* :ghpull:`23755`: Backport PR #23742 on branch v3.6.x (FIX: unbreak ipympl) +* :ghpull:`23569`: Fix hidden xlabel bug in colorbar +* :ghpull:`23742`: FIX: unbreak ipympl +* :ghpull:`23752`: Backport PR #23750 on branch v3.6.x (Fix rcParams documentation) +* :ghpull:`23749`: Backport PR #23735 on branch v3.6.x (Correctly handle Axes subclasses that override cla) +* :ghpull:`23735`: Correctly handle Axes subclasses that override cla +* :ghpull:`23748`: Backport PR #23746 on branch v3.6.x (DOC: add numpydoc docstring + commentary to Axis.get_ticklocs) +* :ghpull:`23747`: Backport PR #23721 on branch v3.6.x (3d plot view angle documentation) +* :ghpull:`23746`: DOC: add numpydoc docstring + commentary to Axis.get_ticklocs +* :ghpull:`23721`: 3d plot view angle documentation +* :ghpull:`23744`: Backport PR #23740 on branch v3.6.x (Clarify error for colorbar with unparented mappable) +* :ghpull:`23741`: Backport PR #23674 on branch v3.6.x (Re-rename builtin seaborn styles to not include a dot.) +* :ghpull:`23740`: Clarify error for colorbar with unparented mappable +* :ghpull:`23674`: Re-rename builtin seaborn styles to not include a dot. +* :ghpull:`23738`: Backport PR #23639 on branch v3.6.x (Adding the new contributor meeting) +* :ghpull:`23739`: Backport PR #23712 on branch v3.6.x (FIX: do not try to help CPython with garbage collection) +* :ghpull:`23712`: FIX: do not try to help CPython with garbage collection +* :ghpull:`23639`: Adding the new contributor meeting +* :ghpull:`23732`: Backport PR #23729 on branch v3.6.x (Use cleaner recursion check in PyQt FigureCanvas' resizeEvent.) +* :ghpull:`23734`: Backport PR #23733 on branch v3.6.x (DOC: Update theme configuration for upcoming changes) +* :ghpull:`23733`: DOC: Update theme configuration for upcoming changes +* :ghpull:`23728`: Backport PR #23722 on branch v3.6.x (Restore deprecation class aliases in cbook) +* :ghpull:`23729`: Use cleaner recursion check in PyQt FigureCanvas' resizeEvent. +* :ghpull:`23726`: Backport PR #23711 on branch v3.6.x (Fix deprecation messages for vendoring unused things) +* :ghpull:`23722`: Restore deprecation class aliases in cbook +* :ghpull:`23727`: Backport PR #23724 on branch v3.6.x (Fix/harmonize spacing in dependencies.rst.) +* :ghpull:`23724`: Fix/harmonize spacing in dependencies.rst. +* :ghpull:`23711`: Fix deprecation messages for vendoring unused things +* :ghpull:`23715`: Backport PR #23708 on branch v3.6.x (Loosen up test_Normalize test) +* :ghpull:`23713`: Backport PR #23710 on branch v3.6.x (Fix cmap deprecations) +* :ghpull:`23708`: Loosen up test_Normalize test +* :ghpull:`23710`: Fix cmap deprecations +* :ghpull:`23696`: Backport PR #23695 on branch v3.6.x (Document polar handling of _interpolation_steps.) +* :ghpull:`23706`: Backport PR #23705 on branch v3.6.x (DOC: Added link to class under discussion) +* :ghpull:`23705`: DOC: Added link to class under discussion +* :ghpull:`23695`: Document polar handling of _interpolation_steps. +* :ghpull:`23668`: Api deprecate cmap functions +* :ghpull:`23049`: Add ``minor`` keyword argument to ``plt.x/yticks`` +* :ghpull:`23665`: Harmonize docstrings for boxstyle/connectionstyle/arrowstyle. +* :ghpull:`23636`: FIX: macosx flush_events should process all events +* :ghpull:`23555`: Uncamelcase offsetTrans in draw_path_collection. +* :ghpull:`23682`: Fix generated documentation for deprecated modules +* :ghpull:`23678`: Get rcParams from mpl +* :ghpull:`23571`: Simplify _bind_draw_path_function. +* :ghpull:`23673`: DOC: Highlight information about avoiding labels in legend +* :ghpull:`22506`: Replace MathtextBackend mechanism. +* :ghpull:`23340`: Set correct path for Arc +* :ghpull:`23562`: Fix issue with get_edgecolor and get_facecolor in 3D plots +* :ghpull:`23634`: make.bat: Don't override SPHINXOPTS/O from the environment +* :ghpull:`23675`: Deprecate helper functions in axis3d +* :ghpull:`23676`: MNT: Get rcParams from mpl +* :ghpull:`23677`: TST: Use article class when checking for pgf +* :ghpull:`23669`: CI: Azure update from ubuntu-18.04 to ubuntu-latest and ubuntu-20.04 +* :ghpull:`23670`: Add bar color demo. +* :ghpull:`23644`: Standardize edge-on axis locations when viewing primary 3d axis planes +* :ghpull:`23563`: Fix issue with drawing 3D lines where points are from nparray +* :ghpull:`23666`: MNT: Deprecate macosx prepare subplots tool +* :ghpull:`23572`: Deprecate ``get_grid_positions(..., raw=True)``. +* :ghpull:`23525`: Add functionality to label individual bars with Axes.bar() +* :ghpull:`23667`: Fix flake8 errors introduced by crossed PRs +* :ghpull:`23554`: MNT: Remove unused imports +* :ghpull:`23659`: Simplify/fix save_diff_image. +* :ghpull:`23663`: Small cleanups to _find_fonts_by_props. +* :ghpull:`23662`: Add tolerance to test failing on ppc64le +* :ghpull:`23623`: MNT: remove _gridspecs attribute on Figure classes +* :ghpull:`23654`: Reverts macosx change to ARC +* :ghpull:`23661`: Remove unused fontsize argument from private mathtext _get_info. +* :ghpull:`23655`: Merge branch v3.5.x into main +* :ghpull:`23658`: Increase tolerance on multi-font tests +* :ghpull:`23657`: Add eps to extension list in image triager +* :ghpull:`23656`: Fix broken link to MathML torture tests. +* :ghpull:`23649`: CI: Use anaconda-client v1.10.0 for upload of nightlies +* :ghpull:`23647`: Allow any color format to be used for axis3d.Axis.set_pane_color +* :ghpull:`23643`: Enable wheels for PyPy 3.8+ +* :ghpull:`23621`: DOC: update and extend fonts explanation +* :ghpull:`23612`: CI: try installing a different version of noto on OSX +* :ghpull:`23619`: add pikepdf and visual c++ dependency +* :ghpull:`23631`: Leave out ``barh`` from the basic plot types. +* :ghpull:`23637`: BLD: Add Python 3.11 builds to CI +* :ghpull:`23632`: Add discouraged admonitions +* :ghpull:`23620`: Doc update deps +* :ghpull:`23627`: Bump pypa/cibuildwheel from 2.8.1 to 2.9.0 +* :ghpull:`23628`: Change Title Case to Upper lower in templates +* :ghpull:`23206`: Change exception type for incorrect SVG date metadata +* :ghpull:`23387`: Remove setuptools_scm_git_archive dependency and add sdist test +* :ghpull:`23605`: Fix issues in examples, docs, and tutorials +* :ghpull:`23618`: [Doc]: Document the position parameter in apply_aspect() +* :ghpull:`23355`: Revert "Try to unbreak CI by xfailing OSX Tk tests" +* :ghpull:`23610`: TST: be more forgiving about IDing Noto +* :ghpull:`23609`: print version number when building docs +* :ghpull:`20832`: Implement multi-font embedding for PS Backend +* :ghpull:`20804`: Implement multi-font embedding for PDF Backend +* :ghpull:`23202`: MNT: Remove cached renderer from figure +* :ghpull:`23497`: Avoid gridspec in more examples +* :ghpull:`23602`: Editing "issues for new contributors" +* :ghpull:`23600`: DOC: view_init docstring for 3d axes primary view angles +* :ghpull:`23587`: BUG:datetime list starting with none +* :ghpull:`23559`: re-base of font fallback for pdf and eps output + SVG support +* :ghpull:`23557`: BLD: update the manylinux versions used +* :ghpull:`23596`: Minor cleanup of axes_grid1 +* :ghpull:`23594`: Expire deprecation on passing bytes to FT2Font.set_text +* :ghpull:`23435`: Add conda env to setup instructions +* :ghpull:`23574`: Move colorbar() doc to method itself. +* :ghpull:`23584`: Bump Ubuntu to 20.04 on GitHub Actions +* :ghpull:`23561`: Clean up code in tri +* :ghpull:`23582`: Cleanup axis3d.Axis.draw +* :ghpull:`23510`: Refactor Widget tests +* :ghpull:`20718`: Circle: Build docs in parallel. +* :ghpull:`22452`: ENH: add ability to remove layout engine +* :ghpull:`23516`: warning when scatter plot color settings discarded +* :ghpull:`23577`: apply_aspect cleanups +* :ghpull:`23575`: Cleanup parasite_simple example. +* :ghpull:`23567`: Remove noop setattr_cm. +* :ghpull:`23412`: Fix dash offset bug in Patch +* :ghpull:`21756`: MNT: Clean up some UTF strings and memory autorelease +* :ghpull:`23558`: MNT: Use UTF-8 string in macosx backend +* :ghpull:`23550`: Change exception types, improve argument checking, and cleanups in mpl_toolkits +* :ghpull:`23196`: Unify set_pickradius argument +* :ghpull:`20740`: Implement Font-Fallback in Matplotlib +* :ghpull:`22566`: Add rcparam for figure label size and weight +* :ghpull:`23551`: Remove transform arguments from _iter_collection +* :ghpull:`23444`: Deduplicate common parts in LatexManager.{__init__,_setup_latex_process} +* :ghpull:`23017`: [ENH] : Provide axis('equal') for Axes3D (replace PR #22705) +* :ghpull:`22950`: Simplify definition of mathtext symbols & correctly end tokens in mathtext parsing +* :ghpull:`23409`: Provide axis('equal') for Axes3D (replaces PR #23017) +* :ghpull:`23434`: Fix array-like linewidth for 3d scatter +* :ghpull:`23500`: Move the common implementation of Axes.set_x/y/zscale to Axis. +* :ghpull:`23533`: Add tests for sankey and minor fixes +* :ghpull:`23535`: Make margins error as claimed in doc-string +* :ghpull:`23546`: Simplify impl. of functions optionally used as context managers. +* :ghpull:`23494`: Fix various issues from SonarQube +* :ghpull:`23529`: Add workflow dispatch GitHub CI +* :ghpull:`23539`: Small improvements to WebAgg example +* :ghpull:`23541`: Change doc-build CI install order +* :ghpull:`23526`: DOC: make "family" less ambiguous in FontProperties docs +* :ghpull:`23537`: Move the deprecated RendererGTK{3,4}Cairo to a single place. +* :ghpull:`23140`: [Features] Allow setting legend title alignment +* :ghpull:`23538`: Fix imprecise docs re: backend dependencies. +* :ghpull:`23532`: Add test for RGBAxes +* :ghpull:`23453`: Add more tests for mplot3d +* :ghpull:`23501`: Let Axes.clear iterate over Axises. +* :ghpull:`23469`: Inline _init_axis_artists & _init_gridlines into clear. +* :ghpull:`23475`: Add markerfacealt to pass-through arguments for error bar lines +* :ghpull:`23527`: STY: fix whitespace on an assert +* :ghpull:`23495`: Fix sgskip'd examples +* :ghpull:`23404`: Restore matplotlib.__doc__ in Sphinx docs +* :ghpull:`23507`: Add hint when More than {max_open_warning} figures have been opened +* :ghpull:`23499`: Fix outdated comment re: event handlers in test_backends_interactive. +* :ghpull:`23498`: Fix direct instantiation of webagg_core managers. +* :ghpull:`23504`: Clarify formatting of the code-for-reproduction field in bug reports. +* :ghpull:`23489`: Add missing test data to install +* :ghpull:`23482`: Mathtext spaces must be independent of font style. +* :ghpull:`23486`: Bump pypa/cibuildwheel from 2.8.0 to 2.8.1 +* :ghpull:`23461`: Tweak Axes repr. +* :ghpull:`16931`: Make it easier to improve UI event metadata. +* :ghpull:`23468`: Display grid in floating axes example. +* :ghpull:`23467`: Remove old handling for factor=None in axisartist. +* :ghpull:`23443`: Try running the pgf backend off the article class. +* :ghpull:`23373`: Fix pan/zoom crashing when widget lock is unavailable +* :ghpull:`23466`: Update filename in example. +* :ghpull:`23464`: Deprecate macos close handler. +* :ghpull:`23463`: Deprecate Tick.label +* :ghpull:`23455`: Deprecate properties w_xaxis, w_yaxis, and w_zaxis +* :ghpull:`23448`: Tweak callbacks to generate pick events. +* :ghpull:`23233`: Default stem marker color follows the linecolor +* :ghpull:`23452`: Generalize Axes __repr__ to 3D +* :ghpull:`23445`: Compare thread native ids when checking whether running on main thread. +* :ghpull:`20752`: Set norms using scale names. +* :ghpull:`23438`: DOC: numpydoc-ify date Locator classes +* :ghpull:`23427`: Tweak pgf escapes. +* :ghpull:`23432`: Fixed typo in docs animation api +* :ghpull:`23420`: Clean up test_chunksize_fails() +* :ghpull:`23415`: Minor improvements to units_sample example +* :ghpull:`21339`: Added linear scaling test to Hexbin marginals +* :ghpull:`23414`: Bump pypa/cibuildwheel from 2.7.0 to 2.8.0 +* :ghpull:`23413`: Combine chunk size tests into one +* :ghpull:`23403`: Small cleanup to VertexSelector. +* :ghpull:`23291`: In the new/simplified backend API, don't customize draw_if_interactive. +* :ghpull:`23350`: Fixed SVG-as-text image comparison tests. +* :ghpull:`23406`: DOC: Fix calculation of bin centers in multi-histogram +* :ghpull:`23407`: TST: Add missing warning type to pytest.warns +* :ghpull:`23402`: Link 3D animation examples to one another. +* :ghpull:`23401`: Upload wheel artifacts from the correct directory +* :ghpull:`23374`: GOV: point CoC reports at CoC steering council subcomittee mailing list +* :ghpull:`23393`: Clean up formatting of custom cmap example +* :ghpull:`23146`: Update cibuildwheel +* :ghpull:`23368`: Add a helper to generate closed paths. +* :ghpull:`20220`: DOC: add mission statement +* :ghpull:`22364`: Tweak mathtext/tex docs. +* :ghpull:`23377`: Use tick_params more often over tick iteration +* :ghpull:`22820`: [Doc] consolidate ``rect`` documentation +* :ghpull:`23371`: Default animation.convert_args to ["-layers", "OptimizePlus"]. +* :ghpull:`23148`: DOC: change address to send security issues to +* :ghpull:`23365`: DOC: add new showcase example, replace gendered one +* :ghpull:`23033`: Fix issue with tex-encoding on non-Unicode platforms +* :ghpull:`23358`: Shorten/clarify definition of extension types. +* :ghpull:`23370`: Small cleanups to animation. +* :ghpull:`23364`: Rename/change signature of PyGlyph_new. +* :ghpull:`23363`: Simplify FigureCanvas multiple inheritance init by swapping bases order. +* :ghpull:`23366`: MNT: use devel version of theme +* :ghpull:`23357`: Fixed decimal points not appearing at end of Mathtext string. +* :ghpull:`23351`: DOC/MNT install docs with dev version of sphinx theme +* :ghpull:`23349`: CI: Remove old scipy-wheels-nightly uploads to ensure space +* :ghpull:`23348`: Support multi-figure MultiCursor; prepare improving its signature. +* :ghpull:`23360`: embedding_in_tk_sgskip.py: use root.destroy +* :ghpull:`23354`: MNT: Use list comprehension +* :ghpull:`23299`: FIX/API: do not reset backend key in rc_context +* :ghpull:`23191`: ENH: add width_ratios and height_ratios to subplots +* :ghpull:`23060`: MNT: Change objective C code to Automatic Reference Counting (ARC) +* :ghpull:`23347`: Simplify/improve check for pycairo in Gtk-based backends. +* :ghpull:`23316`: DOC: improve spines crosslinking +* :ghpull:`23100`: Remove custom backend_nbagg.show(), putting logic in manager show. +* :ghpull:`23342`: FIX: make sure addFont test removes the test font +* :ghpull:`23266`: negative_linestyles kwarg in contour.py +* :ghpull:`23332`: Validate Text linespacing on input. +* :ghpull:`23336`: Remove ineffective exclusion of Arcs without parent Axes. +* :ghpull:`23341`: MNT: Use '--pytest-test-first' option for naming clarity +* :ghpull:`23337`: Remove now inexistent "datapath" rcParam from style blacklist. +* :ghpull:`22004`: Make RendererCairo auto-infer surface size. +* :ghpull:`23208`: ENH: enable stripey lines +* :ghpull:`23288`: Correct URL area with rotated texts in PDFs +* :ghpull:`23197`: Add tests for pan +* :ghpull:`22167`: Deprecate selector ``visible`` attribute +* :ghpull:`23322`: Cleanup FontProperties examples. +* :ghpull:`23321`: Tweak examples capitalization/punctuation. +* :ghpull:`23270`: Fix handling of nonmath hyphens in mathtext. +* :ghpull:`23310`: Move Cursor demo from examples/misc to examples/event_handling +* :ghpull:`23313`: Drop CSS styles that are in mpl-sphinx-theme +* :ghpull:`23314`: Don't draw invisible 3D Axes +* :ghpull:`23302`: Deprecate stem(..., use_line_collection=False) +* :ghpull:`23309`: Remove front page examples +* :ghpull:`23282`: Backport PR #22865 on branch v3.5.x (Fix issue with colorbar extend and drawedges) +* :ghpull:`23231`: Add pytest-xvfb as test dependency +* :ghpull:`23318`: No need to return OrderedDict from _gen_axes_spines. +* :ghpull:`23295`: Replace re.sub by the faster str.translate. +* :ghpull:`23300`: Modify example of "Fig Axes Customize Simple" +* :ghpull:`23014`: Improve consistency in LogLocator and LogFormatter API +* :ghpull:`23286`: Refactor URL handling in PDF backend +* :ghpull:`23065`: Fix test_image_comparison_expect_rms +* :ghpull:`23294`: Simplify binary data handling in ps backend. +* :ghpull:`23284`: DOC: Switch to HTML5 and cleanup CSS +* :ghpull:`23276`: Add get/set methods for DPI in SubFigure +* :ghpull:`23207`: Update build environment and improve test +* :ghpull:`23213`: DEV: Add name-tests-test to pre-commit hooks +* :ghpull:`23289`: Properly make Name.hexify go through a deprecation cycle. +* :ghpull:`23177`: Deprecate positional passing of most Artist constructor parameters +* :ghpull:`23287`: Minor tweaks to pdf Name. +* :ghpull:`23285`: In mathtext, replace manual caching (via ``glyphd``) by lru_cache. +* :ghpull:`23034`: Correctly read the 'style' argument while processing 'genfrac'. +* :ghpull:`23247`: Support inverted parentheses in mathtext. +* :ghpull:`23190`: Deprecate unused methods in axis.py +* :ghpull:`23219`: MNT: Rename example files with 'test' in name +* :ghpull:`23277`: MNT: Remove dead code in SVG backend +* :ghpull:`23261`: Bump actions/setup-python from 3 to 4 +* :ghpull:`23264`: Changing environment.yml for it to work on Windows +* :ghpull:`23269`: MNT: Remove dead code in Colorbar +* :ghpull:`23262`: Simplify qt_compat, in particular post-removal of qt4 support. +* :ghpull:`23263`: Private helper to get requested backend without triggering resolution. +* :ghpull:`23243`: Fix spacing after mathtext operators with sub/superscripts +* :ghpull:`22839`: Fix spacing after mathtext operators with sub/superscripts +* :ghpull:`23256`: DOC: Add note about Inkscape install on Windows +* :ghpull:`23258`: DOC: remove Blue Book url +* :ghpull:`23255`: Add a helper to generate mathtext error strings. +* :ghpull:`23246`: Fix argument checking for set_interpolation_stage +* :ghpull:`22881`: Support not embedding glyphs in svg mathtests. +* :ghpull:`23198`: Rename ncol parameter in legend to ncols +* :ghpull:`23251`: Small simplifications to mathtext tests. +* :ghpull:`23249`: Don't allow ``r"$\left\\|\right.$"``, as in TeX. +* :ghpull:`23248`: Rename test markers +* :ghpull:`22507`: Remove *math* parameter of various mathtext internal APIs. +* :ghpull:`23192`: Add tests, improve error messages in axis/_base, and code cleanup +* :ghpull:`23241`: Fix invalid value in radio buttons example +* :ghpull:`23187`: Correct docs and use keyword arguments in _mathtext.py +* :ghpull:`23045`: MNT: Merge locally defined test marks +* :ghpull:`22289`: ENH: compressed layout +* :ghpull:`23237`: Expire BoxStyle._Base deprecation. +* :ghpull:`23225`: DOC: Fix version switcher links to documentation +* :ghpull:`23221`: DOC: recommend numpy random number generator class +* :ghpull:`23223`: Changed offset reference, add small doc +* :ghpull:`23215`: DOC: link the transforms tutorial from the module +* :ghpull:`23201`: Rework tricontour and tricontourf documentation +* :ghpull:`23013`: Add tests for date module +* :ghpull:`23188`: Mnt new default dates +* :ghpull:`22745`: MNT: Don't require renderer for window_extent and tightbbox +* :ghpull:`23077`: MNT: Remove keyword arguments to gca() +* :ghpull:`23182`: Simplify webagg blitting. +* :ghpull:`23181`: Init FigureCanvasAgg._lastKey in ``__init__``. +* :ghpull:`23175`: Point the version switcher to a name listed in switcher.json +* :ghpull:`22669`: Cleanup documentation generation for pyplot +* :ghpull:`22519`: fix markevery plot option with nans in data +* :ghpull:`21584`: Move towards having get_shared_{x,y}_axes return immutable views. +* :ghpull:`23170`: ENH: update ticks when requesting labels +* :ghpull:`23169`: DOC: Migrate to sphinx-design +* :ghpull:`23180`: Improve docstring of triplot() and PatchCollection +* :ghpull:`23153`: Restore accidentally removed pytest.ini and tests.py. +* :ghpull:`23166`: Deprecate passing most Legend arguments positionally +* :ghpull:`23165`: DOCS Fix a few typos +* :ghpull:`23167`: DOCS fix typo +* :ghpull:`23062`: Add stackplot to plot types listing +* :ghpull:`23161`: Added my (open access) book +* :ghpull:`23141`: Minor fix for astropy units support broken in earlier PR +* :ghpull:`23156`: No longer call draw_if_interactive in parasite_axes. +* :ghpull:`23150`: DOC fix typo +* :ghpull:`23149`: DOCS remove duplicate text +* :ghpull:`23145`: Fix format error in switcher.json +* :ghpull:`21755`: MNT: Clean up macosx backend set_message +* :ghpull:`23128`: DOCS Fix typos +* :ghpull:`23130`: Drop pytest warning config in nightly tests +* :ghpull:`23135`: Unpin coverage again +* :ghpull:`23133`: Make module deprecation messages consistent +* :ghpull:`23134`: Remove newline from start of deprecation warnings +* :ghpull:`22964`: Fix spelling errors +* :ghpull:`22929`: Handle NaN in bar labels and error bars +* :ghpull:`23093`: MNT: Removing 3.4 deprecations +* :ghpull:`23090`: Derive new_figure_manager from FigureCanvas.new_manager. +* :ghpull:`23099`: Remove unneeded cutout for webagg in show(). +* :ghpull:`23097`: Tweak check for IPython pylab mode. +* :ghpull:`23088`: Improve error for invalid format strings / misspelled data keys. +* :ghpull:`23092`: Ensure updated monkey-patching of sphinx-gallery EXAMPLE_HEADER +* :ghpull:`23087`: Fix width/height inversion in dviread debug helper. +* :ghpull:`23089`: Normalize tk load failures to ImportErrors. +* :ghpull:`23091`: Move test that fig.add_axes() needs parameters +* :ghpull:`23067`: more explicit in windows doc build instructions +* :ghpull:`23081`: MNT: Deprecate date_ticker_factory +* :ghpull:`23079`: MNT: Remove key_press and button_press from FigureManager +* :ghpull:`23076`: MNT: Remove positional argument handling in LineCollection +* :ghpull:`23078`: MNT: Remove deprecated axis.cla() +* :ghpull:`23054`: Slightly simplify tcl/tk load in extension. +* :ghpull:`23073`: MNT: Remove dummy_threading because threading is always available +* :ghpull:`22405`: DOC: put the gallery keywords in the meta tag +* :ghpull:`23071`: Fix installing contourpy on CI +* :ghpull:`23068`: Slight refactor of _c_internal_utils to linewrap it better. +* :ghpull:`23070`: Pathlibify autotools invocation in build. +* :ghpull:`22755`: Maybe run autogen as part of freetype install +* :ghpull:`23063`: doc: mathtext example: use axhspan() instead of fill_between() for backdrop rectangle shading +* :ghpull:`23055`: Cleanup Annotation.update_position. +* :ghpull:`22567`: Use contourpy for quad contour calculations +* :ghpull:`22801`: TST: fully parameterize test_lazy_linux_headless +* :ghpull:`22180`: ENH: Use rcParams savefig.directory on macosx backend +* :ghpull:`23048`: Add rrulewrapper to docs +* :ghpull:`23047`: Fix issue with hist and float16 data +* :ghpull:`23044`: Fix missing section header for nightly builds +* :ghpull:`23029`: Demonstrate both usetex and non-usetex in demo_text_path.py. +* :ghpull:`23038`: Factor out errorevery parsing for 2D and 3D errorbars. +* :ghpull:`23036`: Suppress traceback chaining for tex subprocess failures. +* :ghpull:`23037`: Suppress exception chaining in FontProperties. +* :ghpull:`23020`: Add test to close legend issue +* :ghpull:`23031`: Specify that style files are utf-8. +* :ghpull:`22991`: Enable ``plt.sca`` on subfigure's axes +* :ghpull:`23030`: DOC: Fix charset declaration in redirects +* :ghpull:`23022`: Fix some possible encoding issues for non-utf8 systems. +* :ghpull:`23023`: Bump docker/setup-qemu-action from 1 to 2 +* :ghpull:`23024`: DOC: do not suggest to sudo pip install Matplotlib +* :ghpull:`23018`: Fix typo in font family +* :ghpull:`22627`: ENH: rect for constrained_layout +* :ghpull:`22891`: Font example monospace +* :ghpull:`23006`: docs: add subplot-mosaic string compact notation +* :ghpull:`23009`: Fixed installation guide command typo +* :ghpull:`22926`: Fix RangeSlider for same init values #22686 +* :ghpull:`22989`: Merge v3.5.x back into main +* :ghpull:`22993`: STY: Fix typos in colormap +* :ghpull:`22777`: DEV: Add codespell to pre-commit hooks +* :ghpull:`22940`: Fixed dpi bug in rainbow text example +* :ghpull:`22298`: MNT: Remove cmap_d colormap access +* :ghpull:`22387`: Add a registry for color sequences +* :ghpull:`21594`: Document text alignment +* :ghpull:`22967`: TST: Add some tests for QuadMesh contains function +* :ghpull:`22936`: ENH: Add full-screen toggle to the macosx backend +* :ghpull:`22886`: MNT: remove mpl_toolkits.axes_grid +* :ghpull:`22952`: Make MarkerStyle immutable +* :ghpull:`22953`: MNT: Move set_cursor to the FigureCanvas +* :ghpull:`18854`: Standardize creation of FigureManager from a given FigureCanvas class. +* :ghpull:`22925`: Standardize creation of FigureManager from a given FigureCanvas class. +* :ghpull:`22875`: Remove Forward definitions where possible. +* :ghpull:`22928`: ENH: Add option to disable raising the window for macosx +* :ghpull:`22912`: DOC: Better doc of colors +* :ghpull:`22931`: BUG: Fix regression with ls=(0, ()) +* :ghpull:`22909`: FIX: skip sub directories when finding fonts on windows +* :ghpull:`22911`: Clarify docstring of [un]install_repl_displayhook() +* :ghpull:`22919`: CI: Add concurrency skips for GH Actions +* :ghpull:`22899`: Fix documentation markup issues +* :ghpull:`22906`: Clarify logic for repl displayhook. +* :ghpull:`22892`: Remove support for IPython<4. +* :ghpull:`22896`: Remove python-dateutil as test requirement +* :ghpull:`22885`: Deprecate two-layered backend_pdf.Op enum. +* :ghpull:`22883`: Tweak argument checking in tripcolor(). +* :ghpull:`22884`: Missing ``f`` prefix on f-strings fix +* :ghpull:`22877`: Small cleanups to mathtext. +* :ghpull:`21374`: Snap selectors +* :ghpull:`22824`: Remove some unnecessary extra boundaries for colorbars with extensions. +* :ghpull:`21448`: Use named groups in mathtext parser. +* :ghpull:`22609`: Improve usability of dviread.Text by third parties. +* :ghpull:`22809`: STY: Apply pre-commit hooks to codebase +* :ghpull:`22730`: Fix removed cross-references +* :ghpull:`22857`: Slightly simplify twin axes detection in MEP22 zoom. +* :ghpull:`22813`: MNT: Deprecate figure callbacks +* :ghpull:`22802`: MNT: make Axes.cla an alias for Axes.clear in all cases +* :ghpull:`22855`: Remove non-needed remove_text=False. +* :ghpull:`22854`: TST: Avoid floating point errors in asinh ticker +* :ghpull:`22850`: Simplify tick creation +* :ghpull:`22841`: Fix Tk error when updating toolbar checkbutton images +* :ghpull:`22707`: Proposed ENH: Allow user to turn off breaking of streamlines in streamplot (rebased) +* :ghpull:`22826`: Bump actions/upload-artifact from 2 to 3 +* :ghpull:`22825`: Bump codecov/codecov-action from 2 to 3 +* :ghpull:`22821`: Use bool for bool keyword arguments +* :ghpull:`22815`: Fix pickling of globally available, dynamically generated norm classes. +* :ghpull:`22702`: Doc tweak transform tutorial +* :ghpull:`22613`: DOC: Add links to explicit vs implicit API everywhere "OO" is used +* :ghpull:`22712`: Use repr in error messages +* :ghpull:`22794`: Fix ps export of colored hatches with no linewidth +* :ghpull:`22797`: Deprecate functions in backends +* :ghpull:`22608`: Axes.inset_axes: enable Axes subclass creation +* :ghpull:`22795`: Replace "marker simplification" by "marker subsampling" in docs. +* :ghpull:`22768`: Fix inkscape tests +* :ghpull:`22791`: Tweak _ConverterError reporting. +* :ghpull:`22447`: Improve bar_label annotation +* :ghpull:`22710`: Fix the error- TypeError: 'float' object is not iterable +* :ghpull:`22444`: Revert "CI: skip test to work around gs bug" +* :ghpull:`22785`: CI: Update weekly dependency test job +* :ghpull:`22784`: Fix 'misspelled' transform variable +* :ghpull:`22778`: Fix LaTeX formatting in examples +* :ghpull:`22779`: Improve mlab documentation (and example) +* :ghpull:`22759`: MNT: Skip existing wheels during nightly wheel upload +* :ghpull:`22751`: BLD: do not put an upper bound on pyparsing +* :ghpull:`22752`: DOC: Correct nightly wheels pip install command +* :ghpull:`22742`: Fix deprecation of backend_tools.ToolBase.destroy +* :ghpull:`22725`: Move towards making texmanager stateless. +* :ghpull:`22734`: Added clim support to tripcolor +* :ghpull:`22733`: CI: Add GHA workflow to upload nightly wheels +* :ghpull:`21637`: Also upload a subset of nightly wheels +* :ghpull:`22698`: Correct cross-references in documentation +* :ghpull:`22263`: DOC: condense version switcher +* :ghpull:`22361`: Revert datetime usetex ticklabels to use default tex font. +* :ghpull:`22721`: Small style fixes. +* :ghpull:`22356`: Cleanup tripcolor() +* :ghpull:`22360`: Let TeX handle multiline strings itself. +* :ghpull:`22418`: Deprecate auto-removal of overlapping Axes by plt.subplot{,2grid}. +* :ghpull:`22722`: Rename confusingly-named cm_fallback. +* :ghpull:`22697`: Deprecate in testing.decorators +* :ghpull:`22556`: Add text.parse_math rcParams +* :ghpull:`22163`: Change colour of Tk toolbar icons on dark backgrounds +* :ghpull:`22704`: Small simplification to textpath. +* :ghpull:`22498`: TST: increase coverage on tk tests +* :ghpull:`21425`: Make Axis3D constructor signature closer to the one of 2D axis. +* :ghpull:`22665`: Improve error message for incorrect color string +* :ghpull:`22685`: Rewrite plot format detection from sphinx build target +* :ghpull:`22670`: Update deprecated vmImage 'vs2017-win2016' in azure pipelines +* :ghpull:`22503`: Deprecate backend_qt.qApp. +* :ghpull:`22683`: Add missing space before : for parameters +* :ghpull:`22591`: Fix Path/str-discrepancy in FontManager.addpath and improve documentation +* :ghpull:`22680`: Bump actions/cache from 2 to 3 +* :ghpull:`22659`: Add description on quiver head parameters +* :ghpull:`22668`: Raise on missing closing quotes in matplotlibrc +* :ghpull:`22675`: Tweak colorbar_placement example. +* :ghpull:`22276`: Merge "Scatter Symbol" and "Scatter Custom Symbol" examples +* :ghpull:`22658`: Remove reference to now-deleted reminder note. +* :ghpull:`22652`: Update documentation example and fix See also +* :ghpull:`22587`: Refactor handling of tick and ticklabel visibility in Axis.clear() +* :ghpull:`22148`: MNT: Deprecate ``docstring`` +* :ghpull:`22170`: Add example to polygon selector docstring showing how to set vertices programmatically +* :ghpull:`22650`: Fix new leak in ft2font introduced in #22604 +* :ghpull:`22644`: FIX: Flush events after closing figures in macosx backend +* :ghpull:`22643`: Suppress exception chaining in colormap lookup. +* :ghpull:`22639`: ENH: MacOSX backend to use sRGB instead of GenericRGB colorspace +* :ghpull:`22509`: Simplifications to ToolManager.{add,remove}_tool. +* :ghpull:`22633`: DOC: remove space in directive. +* :ghpull:`22631`: Add space between individual transform components in svg output. +* :ghpull:`22523`: MNT: Use a context manager to change the norm in colorbar code +* :ghpull:`22615`: FIX: Change get_axis_map to axis_map now +* :ghpull:`22508`: Move tracking of autoscale status to Axis. +* :ghpull:`22547`: Small cleanups around TexManager usage. +* :ghpull:`22511`: Remove redundant rcParam-lookup in patches +* :ghpull:`22516`: Expire deprecations in backends +* :ghpull:`22612`: Updated grammar to reflect more common usage of output vs outputted in animation.py +* :ghpull:`22589`: Support quoted strings in matplotlibrc +* :ghpull:`22604`: MNT: Fix types in C-code to reduce warnings +* :ghpull:`22610`: Fix alternative suggestion in epoch2num() deprecation +* :ghpull:`22554`: Prepare for making create_dummy_axis not necessary. +* :ghpull:`22607`: ENH: Add dark/light mode theme to the buttons +* :ghpull:`21790`: FIX: Update blitting and drawing on the macosx backend +* :ghpull:`22175`: FIX: Update macosx animation handling +* :ghpull:`22569`: Require non-zero dash value +* :ghpull:`22544`: Correct paper sizes +* :ghpull:`20470`: Issues warnings for legend handles without handlers +* :ghpull:`22558`: MNT: Simplify imports +* :ghpull:`22580`: fix doc for annotation_clip parameter +* :ghpull:`22581`: DOC: fix various typos +* :ghpull:`22573`: Bump actions/setup-python from 2 to 3 +* :ghpull:`22568`: Rename qhull source to _qhull_wrapper.cpp. +* :ghpull:`22561`: FIX: Handle stopped animation figure resize +* :ghpull:`22562`: TST: Add a frame test for animations +* :ghpull:`22514`: Expire deprecations in cbook.deprecation +* :ghpull:`22555`: Use picklable callbacks for DraggableBase. +* :ghpull:`22552`: Tweak dependency checking in doc/conf.py. +* :ghpull:`22550`: Require sphinx>=3 & numpydoc>=1.0 for building docs. +* :ghpull:`22539`: Deprecate toplevel mpl.text.get_rotation; normalize rotations early. +* :ghpull:`22502`: Cleanup unused imports and variables in backends +* :ghpull:`20071`: Document, test, and simplify impl. of auto_adjustable_area. +* :ghpull:`22366`: Deprecation removal/updates in axes3d +* :ghpull:`22484`: Simplify the internal API to connect picklable callbacks. +* :ghpull:`22417`: Support passing rgbaFace as an array to agg's draw_path. +* :ghpull:`22412`: Turn _get_axis_map() into a property and remove _get_axis_list() +* :ghpull:`22486`: Expire deprecations in lines and patches +* :ghpull:`22512`: Increase coverage +* :ghpull:`22504`: Simplify FontProperties init. +* :ghpull:`22497`: Remove entries of MathTextParser._backend_mapping deprecated in 3.4. +* :ghpull:`22487`: Don't key MathTextParser cache off a mutable FontProperties. +* :ghpull:`22468`: Turn _mathtext.ship into a plain function. +* :ghpull:`22490`: Deprecate unused, untested Affine2D.identity(). +* :ghpull:`22491`: Linewrap setupext to 79 character lines. +* :ghpull:`22488`: Some more maintenance for mathtext internal implementation. +* :ghpull:`22485`: Change string representation of AxesImage +* :ghpull:`22240`: Add minimum macosx version +* :ghpull:`22480`: Remove _point_size_reduction. +* :ghpull:`22204`: Cleanup _mathtext internal API +* :ghpull:`22469`: Improve readability of mathtext internal structures. +* :ghpull:`22477`: Un-pyplot some examples which were already explicitly referencing axes. +* :ghpull:`22467`: Small cleanup to font handling in agg. +* :ghpull:`21178`: Add asinh axis scaling (*smooth* symmetric logscale) +* :ghpull:`22411`: Move cbook._define_aliases() to _api.define_aliases() +* :ghpull:`22465`: Deprecate unused AddList. +* :ghpull:`22451`: Clarify error message for bad keyword arguments. +* :ghpull:`21267`: Cleanup AnnotationBbox. +* :ghpull:`22464`: Small improvements related to radar_chart example. +* :ghpull:`22421`: Make most params to figure()/Figure() kwonly. +* :ghpull:`22457`: Copy arrowprops argument to FancyAnnotationBbox. +* :ghpull:`22454`: move ``_toolbar_2`` from webagg_core to webagg +* :ghpull:`22413`: Remove some trivial private getters/setters in axisartist +* :ghpull:`21634`: TST: Add future dependency tests as a weekly CI job +* :ghpull:`22079`: Share FigureManager class between gtk3 and gtk4. +* :ghpull:`22440`: Clarify warning about labels with leading underscores. +* :ghpull:`17488`: Make error message explicit in legend.py +* :ghpull:`22453`: Simplify impl. of polar limits setting API. +* :ghpull:`22449`: Small cleanup to quiver. +* :ghpull:`22415`: Make emit and auto args of set_{x,y,z}lim keyword only. +* :ghpull:`22422`: Deprecate backend_ps.convert_psfrags. +* :ghpull:`22194`: Drop support for Python 3.7 +* :ghpull:`22234`: Partial fix for grid alpha +* :ghpull:`22433`: Fix ambiguous link targets in docs. +* :ghpull:`22420`: Update plt.figure() docstring. +* :ghpull:`22388`: Make signature of Axes.annotate() more explicit. +* :ghpull:`22419`: Remove "Matplotlib version" from docs issue template +* :ghpull:`22423`: Avoid indiscriminate glob-remove in xpdf_distill. +* :ghpull:`22406`: [DOC]: Removed a redundant 'The' +* :ghpull:`21442`: Factor out common limits handling for x/y/z axes. +* :ghpull:`22397`: Axes capitalization in widgets and axes3d +* :ghpull:`22394`: Tweak Axes3D docstrings that refer to 2D plotting methods. +* :ghpull:`22383`: TST: fix doc build +* :ghpull:`21877`: DOC: attempt to explain the main different APIs +* :ghpull:`21238`: Raise when unknown signals are connected to CallbackRegistries. +* :ghpull:`22345`: MNT: make layout deprecations pending +* :ghpull:`21597`: FIX: Remove the deepcopy override from transforms +* :ghpull:`22370`: Replace tabs with spaces in C code. +* :ghpull:`22371`: Corrected a mistake in comments (Issue #22369) +* :ghpull:`21352`: Refactor hexbin(). +* :ghpull:`19214`: Improve autoscaling for high order Bezier curves +* :ghpull:`22268`: Deprecated is_decade and is_close_to_int +* :ghpull:`22359`: Slightly refactor TeX source generation. +* :ghpull:`22365`: Remove deprecated ``MovieWriter.cleanup`` +* :ghpull:`22363`: Properly capitalize "Unicode". +* :ghpull:`22025`: Deprecate various custom FigureFrameWx attributes/methods. +* :ghpull:`21391`: Reuse imsave()'s background-blending code in FigureCanvasAgg.print_jpeg. +* :ghpull:`22026`: Simplify wxframe deletion. +* :ghpull:`22351`: Fix "trailing" whitespace in C docstrings. +* :ghpull:`22342`: Docstrings for _qhull. +* :ghpull:`21836`: Slightly shorten ft2font init. +* :ghpull:`21962`: Privatize various internal APIs of backend_pgf. +* :ghpull:`22114`: Rewrite AxesStack independently of cbook.Stack. +* :ghpull:`22332`: Let TransformedPatchPath inherit most functionality from TransformedPath. +* :ghpull:`22292`: Cleanup Axis._translate_tick_kw +* :ghpull:`22339`: wx.App() should be init'ed in new_figure_manager_given_figure +* :ghpull:`22315`: More standardization of floating point slop in mpl_toolkits. +* :ghpull:`22337`: DOC: More cleanup axes -> Axes +* :ghpull:`22323`: Replace sole use of maxdict by lru_cache. +* :ghpull:`22229`: FIX: make safe to add / remove artists during ArtistList iteration +* :ghpull:`22196`: ``dates`` classes and functions support ``tz`` both as string and ``tzinfo`` +* :ghpull:`22161`: Add box when setting ``PolygonSelector.verts`` +* :ghpull:`19368`: Raise warning and downsample if data given to _image.resample is too large +* :ghpull:`22250`: Unify toolbar init across backends. +* :ghpull:`22304`: Added tests for ContourSet.legend_elements +* :ghpull:`21583`: Add pre-commit config and dev instructions +* :ghpull:`21547`: Custom cap widths in box and whisker plots in bxp() and boxplot() +* :ghpull:`20887`: Implement a consistent behavior in TkAgg backend for bad blit bbox +* :ghpull:`22317`: Rename outdated seaborn styles. +* :ghpull:`22271`: Rework/fix Text layout cache. +* :ghpull:`22097`: In mpl_toolkits, use the same floating point slop as for standard ticks. +* :ghpull:`22295`: Display bad format string in error message. +* :ghpull:`22287`: Removed unused code and variables +* :ghpull:`22244`: MNT: colorbar locators properties +* :ghpull:`22270`: Expanded documentation of Axis.set_ticks as per discussion in issue #22262 +* :ghpull:`22280`: Simplify FontProperties.copy(). +* :ghpull:`22174`: Give the Tk toolbar buttons a flat look +* :ghpull:`22046`: Add the ability to change the focal length of the camera for 3D plots +* :ghpull:`22251`: Colorbar docstring reorg +* :ghpull:`21933`: MNT: privatize colorbar attr +* :ghpull:`22258`: DOC: fix version switcher +* :ghpull:`22261`: DOC: fix switcher json +* :ghpull:`22154`: Add some tests for minspan{x,y} in RectangleSelector +* :ghpull:`22246`: DOC: add dropdown +* :ghpull:`22133`: Deprecated ``afm``, ``fontconfig_pattern``, and ``type1font`` +* :ghpull:`22249`: DOC: More capitalization of Axes +* :ghpull:`22021`: Ensure that all toolbar (old/new) subclasses can be init'ed consistently +* :ghpull:`22213`: Improve ft2font error reporting. +* :ghpull:`22245`: Deprecate cleared kwarg to get_renderer. +* :ghpull:`22239`: Fix typos +* :ghpull:`22216`: turn off the grid after creating colorbar axes +* :ghpull:`22055`: FIX: Return value instead of enum in get_capstyle/_joinstyle +* :ghpull:`22228`: Remove some unnecessary getattrs. +* :ghpull:`20426`: ENH: Layout engine +* :ghpull:`22224`: Trivial doc fix to annotations tutorial. +* :ghpull:`21894`: Jointly track x and y in PolygonSelector. +* :ghpull:`22205`: Bump minimum NumPy to 1.19 +* :ghpull:`22203`: Factor out underline-thickness lookups in mathtext. +* :ghpull:`22189`: DOC: Add hatch API to reference +* :ghpull:`22084`: Clean up 3d plot box_aspect zooming +* :ghpull:`22098`: Expire axes_grid1/axisartist deprecations. +* :ghpull:`22013`: Use standard toolbar in wx. +* :ghpull:`22160`: Removed unused variables etc. +* :ghpull:`22179`: FIX: macosx check case-insensitive app name +* :ghpull:`22157`: Improved coverage of mathtext and removed unused code +* :ghpull:`21781`: Use a fixture to get widget testing axes +* :ghpull:`22140`: Ensure log formatters use Unicode minus +* :ghpull:`21342`: Fix drawing animated artists changed in selector callback +* :ghpull:`22134`: Deprecated ``tight_bbox`` and ``tight_layout`` modules +* :ghpull:`21965`: Switch transOffset to offset_transform. +* :ghpull:`22145`: Make Tk windows use the same icon as other backends +* :ghpull:`22107`: Expire mathttext-related deprecations +* :ghpull:`22139`: FIX: width/height were reversed in macosx rectangle creation +* :ghpull:`22123`: Deprecate accepting arbitrary parameters in some get_window_extent() methods +* :ghpull:`22122`: Hint at draw_without_rendering() in Text.get_window_extent +* :ghpull:`22120`: Drop dependency on scipy in the docs. +* :ghpull:`22063`: FIX: Autoposition title when yaxis has offset +* :ghpull:`22119`: Micro-optimize skew(). +* :ghpull:`22109`: Remove unnecessary null checks in macosx.m, and some more maintenance +* :ghpull:`21977`: Add corner coordinate helper methods to Ellipse/Rectangle +* :ghpull:`21830`: Add option of bounding box for PolygonSelector +* :ghpull:`22115`: Turn _localaxes into a plain list. +* :ghpull:`22108`: Micro-optimize rotation transform. +* :ghpull:`22043`: Cleanup differential equations examples. +* :ghpull:`22080`: Simple style(ish) fixes. +* :ghpull:`22110`: Right-aligned status text in backends +* :ghpull:`21873`: DOC: Update and consolidate Custom Tick Formatter for Time Series example +* :ghpull:`22112`: Fix a small typo +* :ghpull:`20117`: Very soft-deprecate AxesDivider.new_{horizontal,vertical}. +* :ghpull:`22034`: Update lines_with_ticks_demo.py +* :ghpull:`22102`: DOC: rename usage tutorial to quick_start +* :ghpull:`19228`: Validate text rotation in setter +* :ghpull:`22081`: Expire colorbar-related deprecations. +* :ghpull:`22008`: Added color keyword argument to math_to_image +* :ghpull:`22058`: Remove exprired mplot3d deprecations for 3.6 +* :ghpull:`22073`: DOC: Add new tutorial to external resources. +* :ghpull:`22054`: MNT: Set CapStyle member names automatically +* :ghpull:`22061`: De-duplicate mplot3D API docs +* :ghpull:`22075`: Remove unnecessary ``.figure`` qualifier in docs. +* :ghpull:`22051`: Make required_interactive_framework required on FigureCanvas. +* :ghpull:`22050`: Deprecate the noop, unused FigureCanvasBase.resize. +* :ghpull:`22030`: Add explanatory comments to "broken" horizontal bar plot example +* :ghpull:`22001`: Fix: [Bug]: triplot with 'ls' argument yields TypeError #21995 +* :ghpull:`22045`: Fill in missing Axes3D box_aspect argument docstring +* :ghpull:`22042`: Keep FontEntry helpers private. +* :ghpull:`21042`: Make rcParams.copy() return a new RcParams instance. +* :ghpull:`22032`: flipy only affects the drawing of texts, not of images. +* :ghpull:`21993`: Added docstring to rrulewrapper class +* :ghpull:`21935`: Significantly improve tight layout performance for cartopy axes +* :ghpull:`22000`: Some gtk cleanups. +* :ghpull:`21983`: Simplify canvas class control in FigureFrameWx. +* :ghpull:`21985`: Slightly tighten the _get_layout_cache_key API. +* :ghpull:`22020`: Simplify wx _print_image. +* :ghpull:`22010`: Fix syntax highlighting in contrib guide. +* :ghpull:`22003`: Initialize RendererCairo.{width,height} in constructor. +* :ghpull:`21992`: Use _make_classic_style_pseudo_toolbar more. +* :ghpull:`21916`: Fix picklability of make_norm_from_scale norms. +* :ghpull:`21981`: FigureCanvasCairo can init RendererCairo; kill RendererCairo subclasses. +* :ghpull:`21986`: InvLogTransform should only return masked arrays for masked inputs. +* :ghpull:`21991`: PEP8ify wx callback names. +* :ghpull:`21975`: DOC: remove experimental tag from CL +* :ghpull:`21989`: Autoinfer norm bounds. +* :ghpull:`21980`: Removed loaded modules logging +* :ghpull:`21982`: Deprecate duplicated FigureManagerGTK{3,4}Agg classes. +* :ghpull:`21963`: Clarify current behavior of draw_path_collection. +* :ghpull:`21974`: Reword inset axes example. +* :ghpull:`21835`: Small improvements to interactive examples +* :ghpull:`21050`: Store dash_pattern as single attribute, not two. +* :ghpull:`21557`: Fix transparency when exporting to png via pgf backend. +* :ghpull:`21904`: Added _repr_html_ for fonts +* :ghpull:`21696`: Use cycling iterators in RendererBase. +* :ghpull:`21955`: Refactor common parts of ImageMagick{,File}Writer. +* :ghpull:`21952`: Clarify coordinates for RectangleSelector properties +* :ghpull:`21964`: Fix some more missing references. +* :ghpull:`21516`: Make _request_autoscale_view more generalizable to 3D. +* :ghpull:`21947`: Slightly cleanup RendererBase docs. +* :ghpull:`21961`: Privatize various internal APIs of backend_pgf. +* :ghpull:`21956`: Remove tests for avconv animation writers. +* :ghpull:`21954`: DOC: Move Animation and MovieWriter inheritance diagrams ... +* :ghpull:`21780`: Add a click_and_move widget test helper +* :ghpull:`21941`: Merge branch v3.5.x into main +* :ghpull:`21936`: Small ``__getstate__`` cleanups. +* :ghpull:`21939`: Update comment re: register_at_fork. +* :ghpull:`21910`: Fold _rgbacache into _imcache. +* :ghpull:`21921`: Clean up RectangleSelector move code +* :ghpull:`21925`: Drop labelling from PR welcome action +* :ghpull:`14930`: Set Dock icon on the macosx backend +* :ghpull:`21920`: Improve square state calculation in RectangleSelector +* :ghpull:`21919`: Fix use_data_coordinates docstring +* :ghpull:`21881`: Add a PolygonSelector.verts setter +* :ghpull:`20839`: Fix centre and square state and add rotation for rectangle selector +* :ghpull:`21874`: DOC: Add Date Tick Locators and Formatters example +* :ghpull:`21799`: Added get_font_names() to fontManager +* :ghpull:`21871`: DOC: Code from markevery_prop_cycle moved to test. +* :ghpull:`21395`: Expire _check_savefig_extra_args-related deprecations. +* :ghpull:`21867`: Remove unused bbox arg to _convert_agg_to_wx_bitmap. +* :ghpull:`21868`: Use partialmethod for better signatures in backend_ps. +* :ghpull:`21520`: Shorten some inset_locator docstrings. +* :ghpull:`21737`: Update the "Rotating a 3D plot" gallery example to show all 3 rotation axes +* :ghpull:`21851`: Re-order a widget test function +* :ghpull:`10762`: Normalization of elevation and azimuth angles for surface plots +* :ghpull:`21426`: Add ability to roll the camera in 3D plots +* :ghpull:`21822`: Replace NSDictionary by switch-case. +* :ghpull:`21512`: MNT: Add modifier key press handling to macosx backend +* :ghpull:`21784`: Set macOS icon when using Qt backend +* :ghpull:`21748`: Shorten PyObjectType defs in macosx.m. +* :ghpull:`21809`: MNT: Turn all macosx warnings into errors while building +* :ghpull:`21792`: Fix missing return value in closeButtonPressed. +* :ghpull:`21767`: Inherit many macos backend docstrings. +* :ghpull:`21766`: Don't hide build log on GHA. +* :ghpull:`21728`: Factor out some macosx gil handling for py-method calls from callbacks. +* :ghpull:`21754`: Update gitattributes so that objc diffs are correctly contextualized. +* :ghpull:`21752`: Add a helper for directly output pdf streams. +* :ghpull:`21750`: Don't sort pdf dicts. +* :ghpull:`21745`: DOC: Clarify Coords Report Example +* :ghpull:`21746`: Fix/add docstring signatures to many C++ methods. +* :ghpull:`21631`: DOC: change gridspec tutorial to arranging_axes tutorial +* :ghpull:`21318`: FIX: better error message for shared axes and axis('equal') +* :ghpull:`21519`: mark_inset should manually unstale axes limits before drawing itself. +* :ghpull:`21724`: Fix copyright date with SOURCE_DATE_EPOCH set +* :ghpull:`21398`: FIX: logic of title repositioning +* :ghpull:`21717`: Simplify macosx toolbar init. +* :ghpull:`21690`: Whitespace/braces/#defines cleanup to macosx. +* :ghpull:`21695`: Use _api.check_shape more. +* :ghpull:`21698`: Small code cleanups and style fixes. +* :ghpull:`21529`: Delay-load keymaps in toolmanager. +* :ghpull:`21525`: Fix support for clim in scatter. +* :ghpull:`21697`: Drop non-significant zeros from ps output. +* :ghpull:`21692`: CI: Remove CI test runs from forks of matplotlib +* :ghpull:`21591`: Make ToolFullScreen a Tool, not a ToolToggle. +* :ghpull:`21677`: Simplify test for negative xerr/yerr. +* :ghpull:`21657`: Replace some image_comparisons by return-value-tests/check_figures_e… +* :ghpull:`21664`: Merge 3.5.x into main +* :ghpull:`21490`: Make Line2D copy its inputs +* :ghpull:`21639`: Skip some uses of packaging's PEP440 version for non-Python versions. +* :ghpull:`21604`: Fix centre square rectangle selector part 1 +* :ghpull:`21593`: Check for images added-and-modified in a same PR +* :ghpull:`20750`: Shorten issue templates +* :ghpull:`21590`: Make gtk3 full_screen_toggle more robust against external changes. +* :ghpull:`21582`: Organize checklist in PR template +* :ghpull:`21580`: Rename/remove _lastCursor, as needed. +* :ghpull:`21567`: Removed the range parameter from the validate_whiskers function's err… +* :ghpull:`21565`: Further remove remnants of offset_position. +* :ghpull:`21542`: [ENH]: Use new style format strings for colorbar ticks +* :ghpull:`21564`: Skip invisible artists when doing 3d projection. +* :ghpull:`21558`: Various small fixes for streamplot(). +* :ghpull:`21544`: Return minorticks as array, not as list. +* :ghpull:`21546`: Added links to the mosaic docs in figure and pyplot module docstrings +* :ghpull:`21545`: Turn mouseover into a mpl-style getset_property. +* :ghpull:`21537`: Remove unnecessary False arg when constructing wx.App. +* :ghpull:`21536`: Reword margins docstrings, and fix bounds on zmargin values. +* :ghpull:`21535`: typo-correction-on-line-185 +* :ghpull:`21534`: Do not use space in directive calling. +* :ghpull:`21494`: Adding tutorial links for blitting in widgets.py +* :ghpull:`21407`: Stash exceptions when FT2Font closes the underlying stream. +* :ghpull:`21431`: set_ticks([single_tick]) should also expand view limits. +* :ghpull:`21444`: Make pipong example self-contained. +* :ghpull:`21392`: Add label about workflow to new contributor PRs +* :ghpull:`21440`: Install sphinx-panels along with development setup +* :ghpull:`21434`: Remove coords_flat variable +* :ghpull:`21415`: Move gui_support.macosx option to packages section. +* :ghpull:`21412`: Privatize some SVG internal APIs. +* :ghpull:`21401`: Uncamelcase some internal variables in axis.py; rename _get_tick_bboxes. +* :ghpull:`21417`: Use Bbox.unit() more. +* :ghpull:`20253`: Simplify parameter handling in FloatingAxesBase. +* :ghpull:`21379`: Simplify filename tracking in FT2Font. +* :ghpull:`21278`: Clear findfont cache when calling addfont(). +* :ghpull:`21400`: Use bbox.{size,bounds,width,height,p0,...} where appropriate. +* :ghpull:`21408`: Reword annotations tutorial section titles. +* :ghpull:`21371`: Rename default branch +* :ghpull:`21389`: Log pixel coordinates in event_handling coords_demo example on terminal/console +* :ghpull:`21376`: Factor common parts of saving to different formats using pillow. +* :ghpull:`21377`: Enable tests for text path based markers +* :ghpull:`21283`: Demonstrate inset_axes in scatter_hist example. +* :ghpull:`21356`: Raise an exception when find_tex_file fails to find a file. +* :ghpull:`21362`: Simplify wording of allowed errorbar() error values +* :ghpull:`21274`: ENH: Add support to save images in WebP format +* :ghpull:`21289`: Simplify _init_legend_box. +* :ghpull:`21256`: Make image_comparison work even without the autoclose fixture. +* :ghpull:`21343`: Fix type1font docstring markup/punctuation. +* :ghpull:`21341`: Fix trivial docstring typo. +* :ghpull:`21301`: Simplify ``Colormap.__call__`` a bit. +* :ghpull:`21280`: Make ``Path.__deepcopy__`` interact better with subclasses, e.g. TextPath. +* :ghpull:`21266`: Fix #21101 Add validator to errorbar method +* :ghpull:`20921`: Fix problem with (deep)copy of TextPath +* :ghpull:`20914`: 19195 rotated markers +* :ghpull:`21276`: Add language about not assigning issues +* :ghpull:`20715`: Improve Type-1 font parsing +* :ghpull:`21218`: Parametrize/simplify test_missing_psfont. +* :ghpull:`21213`: Compress comments in make_image. +* :ghpull:`21187`: Deprecate error_msg_foo helpers. +* :ghpull:`21190`: Deprecate mlab.stride_windows. +* :ghpull:`21152`: Rename ``**kw`` to ``**kwargs``. +* :ghpull:`21087`: Move colormap examples from userdemo to images_contours_and_fields. +* :ghpull:`21074`: Deprecate MarkerStyle(None). +* :ghpull:`20990`: Explicit registration of canvas-specific tool subclasses. +* :ghpull:`21049`: Simplify setting Legend attributes +* :ghpull:`21056`: Deprecate support for no-args MarkerStyle(). +* :ghpull:`21059`: Remove dummy test command from setup.py +* :ghpull:`21015`: Prepare for rcParams.copy() returning a new RcParams instance in the future +* :ghpull:`21021`: Factor out for_layout_only backcompat support in get_tightlayout. +* :ghpull:`21023`: Inline ToolManager._trigger_tool to its sole call site. +* :ghpull:`21005`: Test the rcParams deprecation machinery. +* :ghpull:`21010`: Avoid TransformedBbox where unneeded. +* :ghpull:`21019`: Reword custom_ticker1 example. +* :ghpull:`20995`: Deprecate some backend_gtk3 helper globals. +* :ghpull:`21004`: Remove now-unused rcParams _deprecated entries. +* :ghpull:`20986`: Make HandlerLine2D{,Compound} inherit constructors from HandlerNpoints. +* :ghpull:`20974`: Rename symbol_name to glyph_name where appropriate. +* :ghpull:`20961`: Small cleanups to math_to_image. +* :ghpull:`20957`: legend_handler_map cleanups. +* :ghpull:`20955`: Remove unused HostAxes._get_legend_handles. +* :ghpull:`20851`: Try to install the Noto Sans CJK font + +Issues (202): + +* :ghissue:`23827`: backend_gtk3agg.py calls set_device_scale +* :ghissue:`23560`: [Doc]: mpl_toolkits.axes_grid still mentioned as maintained +* :ghissue:`23794`: [Doc]: Version switcher broken in devdocs +* :ghissue:`23806`: [Bug]: possible regression in axis ticks handling in matplotlib 3.6.0rc2 +* :ghissue:`22965`: [Bug]: triplot duplicates label legend +* :ghissue:`23807`: streamplot raises ValueError when the input is zeros +* :ghissue:`23761`: [Bug]: False positive legend handler warnings in 3.6.0.rc1 +* :ghissue:`23398`: [Bug]: Newer versions of matplotlib ignore xlabel on colorbar axis +* :ghissue:`23699`: [Bug]: Bug with toolbar instantiation in notebook +* :ghissue:`23745`: [Doc]: Minor rcParams/matplotlibrc doc issues +* :ghissue:`23717`: [Bug]: AxesSubplot.get_yticks not returning the actual printed ticks +* :ghissue:`21508`: [Doc]: Create diagram to show rotation directions for 3D plots +* :ghissue:`23709`: [Bug]: colorbar with unattached mappables can't steal space +* :ghissue:`23701`: [Bug]: plt.figure(), plt.close() leaks memory +* :ghissue:`22409`: [Bug]: AttributeError: 'QResizeEvent' object has no attribute 'pos' +* :ghissue:`19609`: DeprecationWarning when changing color maps +* :ghissue:`23716`: MatplotlibDeprecationWarning removal hard-breaks seaborn in 3.6rc1 +* :ghissue:`23719`: [Bug]: register_cmap deprecation message seems wrong +* :ghissue:`23707`: test_Normalize fails on aarch64/ppc64le/s390x +* :ghissue:`21107`: [MNT]: Should plt.xticks() get a minor keyword argument +* :ghissue:`23679`: [Doc]: Deprecated modules not in docs +* :ghissue:`19550`: Arc and pathpatch_2d_to_3d plots full ellipse +* :ghissue:`23329`: [Bug]: ``plt.autoscale()`` fails for partial ``Arc`` +* :ghissue:`11266`: Arc patch ignoring theta1/theta2 when added to Axes via PatchCollection +* :ghissue:`4067`: 'Poly3DCollection' object has no attribute '_facecolors2d' +* :ghissue:`23622`: [MNT]: make.bat not parsing sphinxopt +* :ghissue:`23459`: [Bug]: 'Line3D' object has no attribute '_verts3d' +* :ghissue:`23653`: [Bug]: macosx subplot tool causes segfault when window closed +* :ghissue:`23660`: [Bug]: Test test_figure.py::test_subfigure_ss[png] FAILED on ppc64le +* :ghissue:`23645`: [MNT]: Python 3.11 manylinux wheels +* :ghissue:`23650`: TTF fonts loaded from file are not embedded/displayed properly when saved to pdf +* :ghissue:`23583`: [Doc]: Document the position parameter in apply_aspect() +* :ghissue:`23386`: setuptools_scm-git-archive is obsolete +* :ghissue:`23220`: [Doc]: Clarify ``offset`` parameter in linestyle +* :ghissue:`22746`: [Doc]: Document that rcParams['font.family'] can be a list +* :ghissue:`8187`: Axes doesn't have ````legends```` attribute? +* :ghissue:`23580`: [Bug]: TypeError when plotting against list of datetime.date where 0th element of list is None +* :ghissue:`15514`: Relevant methods are only documented in base classes and thus not easily discoverable +* :ghissue:`21611`: DOC: Add conda environment instructions to developers guide +* :ghissue:`23487`: [Bug]: scatter plot color settings discarded unless c given +* :ghissue:`22977`: [Bug]: offset dash linestyle has no effect in patch objects +* :ghissue:`18883`: Matplotlib would not try to apply all the font in font list to draw all characters in the given string. +* :ghissue:`22570`: [ENH]: Provide ``axis('equal')`` for ``Axes3D``. +* :ghissue:`23433`: [Bug]: array-like linewidth raises an error for scatter3D +* :ghissue:`12388`: Legend Title Left Alignment +* :ghissue:`23375`: [Bug]: markerfacecoloralt not supported when drawing errorbars +* :ghissue:`17973`: DOC: matplotlib.__doc__ not included in online docs ? +* :ghissue:`23474`: [Bug]: ``\,`` and ``\mathrm{\,}`` are not identical in Mathtext when using CM and STIX +* :ghissue:`8715`: event handlers have different signatures across backends +* :ghissue:`18271`: PGF uses the minimal document class +* :ghissue:`23324`: [Bug]: Exception not handled in widgetlock() +* :ghissue:`15710`: doc for type of tz parameter is inconsistent throughout dates.py +* :ghissue:`21165`: Hexbin marginals need a test for linear scaling +* :ghissue:`23105`: [MNT]: Deprecate per-backend customization of draw_if_interactive +* :ghissue:`23147`: [Bug]: with setuptools>=60, cannot find msbuild +* :ghissue:`23379`: [Bug]: Offset notation on y-axis can overlap with a long title +* :ghissue:`22819`: [Doc]: Make rect argument consistent in the docstrings +* :ghissue:`23172`: [Bug]: Calling matplotlib.pyplot.show() outside of matplotlib.pyplot.rc_context no longer works +* :ghissue:`23019`: [Bug]: ``UnicodeDecodeError`` when using some special and accented characters in TeX +* :ghissue:`23334`: [Doc]: Tk embedding example crashes Spyder +* :ghissue:`23298`: [Bug]: get_backend() clears figures from Gcf.figs if they were created under rc_context +* :ghissue:`21942`: [ENH]: add width/height_ratios to subplots and friends +* :ghissue:`23028`: [ENH]: contour kwarg for negative_linestyle +* :ghissue:`19223`: Certain non-hashable parameters to text() give cryptic error messages +* :ghissue:`18351`: Add the ability to plot striped lines +* :ghissue:`23205`: [Bug]: URL-area not rotated in PDFs +* :ghissue:`23268`: [Bug]: hyphen renders different length depending on presence of MathText +* :ghissue:`23308`: [Bug]: set_visible() not working for 3d projection +* :ghissue:`23296`: Set_color method for line2d object in latest document not work +* :ghissue:`22992`: [Bug]: test_image_comparison_expect_rms nondeterministic failure +* :ghissue:`23008`: [ENH]: Use ``\genfrac`` in display style? +* :ghissue:`23214`: [MNT]: Rename examples with "test" in the name +* :ghissue:`17852`: Thin space missing after mathtext operators +* :ghissue:`12078`: Inconsistency in keyword-arguments ncol/ncols, nrow/nrows +* :ghissue:`23239`: [Doc]: steps is not implemented in line styles. +* :ghissue:`23151`: [MNT]: default date limits... +* :ghissue:`9462`: Misaligned bottoms of subplots for png output with bbox_inches='tight' +* :ghissue:`21369`: [Bug]: ax.invert_xaxis() and ax.invert_yaxis() both flip the X axis +* :ghissue:`20797`: ``macosx`` cursors break with images +* :ghissue:`23084`: [TST] Upcoming dependency test failures +* :ghissue:`22910`: [Bug]: bar_label fails with nan errorbar values +* :ghissue:`23074`: [Bug]: matplotlib crashes if ``_tkinter`` doesn't have ``__file__`` +* :ghissue:`23083`: [Bug]: Confusing error messages +* :ghissue:`22391`: [Doc]: Remove "keywords" line at the bottom of all examples +* :ghissue:`20202`: Daylocator causes frozen computer when used with FuncAnimation +* :ghissue:`22529`: Replace C++ quad contouring code with use of ContourPy +* :ghissue:`21710`: [ENH]: macosx backend does not respect rcParams["savefig.directory"] +* :ghissue:`21880`: [Doc]: rrulewrapper not included in API docs +* :ghissue:`22622`: [Bug]: Gaps and overlapping areas between bins when using float16 +* :ghissue:`23043`: [TST] Upcoming dependency test failures +* :ghissue:`17960`: Line2D object markers are lost when retrieved from legend.get_lines() when linestyle='None' +* :ghissue:`23026`: [MNT]: Require that matplotlibrc/style files use utf-8 (or have an encoding cookie) +* :ghissue:`22947`: [Bug]: Can't use ``plt.sca()`` on axes created using subfigures +* :ghissue:`22623`: [ENH]: support rect with constrained_layout ("layout only to part of the figure") +* :ghissue:`22917`: "ab;cd" missing in subplot_mosaic tutorial +* :ghissue:`22686`: [Bug]: can not give init value for RangeSlider widget +* :ghissue:`22740`: [MNT]: Add codespell to pre-commit hooks +* :ghissue:`22893`: rainbow text example is broken +* :ghissue:`21571`: [Doc]: Clarify text positioning +* :ghissue:`22092`: [Bug]: Configure subplots dialog freezes for TkAgg with toolmanager +* :ghissue:`22760`: [Bug]: Macosx legend picker doesn't work anymore +* :ghissue:`16369`: Call to input blocks slider input on osx with the default agg 'MacOSX'. It works fine on when TkAgg is used. +* :ghissue:`22915`: [Bug]: figure.raise_window rcParam does not work on MacOSX backend +* :ghissue:`22930`: [Bug]: Regression in dashes due to #22569 +* :ghissue:`22859`: [Bug]: findSystemFonts should not look in subdirectories of C:\Windows\Fonts\ +* :ghissue:`22882`: Missing ``f`` prefix on f-strings +* :ghissue:`22738`: [MNT]: make Axes.cla an alias for Axes.clear in all cases +* :ghissue:`22708`: [TST] Upcoming dependency test failures +* :ghissue:`8388`: Proposed ENH: Allow user to turn off breaking of streamlines in streamplot +* :ghissue:`20755`: [Bug]: make_norm_from_scale should create picklable classes even when used in-line. +* :ghissue:`18249`: Expand the explanation of the Object-Oriented interface +* :ghissue:`22792`: [Bug]: .eps greyscale hatching of patches when lw=0 +* :ghissue:`22630`: [ENH]: enable passing of projection keyword to Axes.inset_axes +* :ghissue:`22414`: [Bug]: bar_label overlaps bars when y-axis is inverted +* :ghissue:`22726`: [Bug]: tripcolor ignores clim +* :ghissue:`21635`: [ENH]: Add a nightly wheel build +* :ghissue:`9994`: document where nightly wheels are published +* :ghissue:`22350`: [Bug]: text.usetex Vs. DateFormatter +* :ghissue:`4976`: missing imshow() subplots when using tight_layout() +* :ghissue:`22150`: [ENH]: Tool icons are hardly visible in Tk when using a dark theme +* :ghissue:`22662`: Leave color parameter empty should be fine[ENH]: +* :ghissue:`22671`: [Doc]: plot_format adaption invalidates sphinx cache +* :ghissue:`22582`: [Bug]: FontManager.addfont doesn't accept pathlib.Path of TTF font +* :ghissue:`22657`: [ENH]: vector map +* :ghissue:`16181`: The great API cleanup +* :ghissue:`22636`: [Bug]: Infinite loop when there is single double quote in matplotlibrc +* :ghissue:`22266`: [Doc]: Improve examples in documentation +* :ghissue:`11861`: Figure does not close until script finishes execution +* :ghissue:`19288`: Escape # character in matplotlibrc +* :ghissue:`22579`: [Bug]: Replacement for epoch2num behaves differently (does not accept arrays) +* :ghissue:`22605`: [Bug]: Tool contrast low with dark theme on macosx backend +* :ghissue:`17642`: bring osx backend flush_events to feature parity with other backend +* :ghissue:`19268`: Drawing the canvas does not populate ticklabels on MacOSX backend +* :ghissue:`17445`: MacOSX does not render frames in which new artists are added when blitting +* :ghissue:`10980`: Current versions cannot reproduce rotate_axes_3d_demo.py +* :ghissue:`18451`: MacOSX backend fails with animation in certain scripts +* :ghissue:`22603`: [MNT]: Replace str(n)cpy etc with safe versions (C++) +* :ghissue:`19121`: Handle and label not created for Text with label +* :ghissue:`22563`: [Doc]: annotation_clip=None not correctly documented +* :ghissue:`12528`: Empty axes on draw after blitted animation finishes +* :ghissue:`20991`: [Bug]: Error when using path effect with a PolyCollection +* :ghissue:`19563`: path_effects kwarg triggers exception on 3D scatterplot +* :ghissue:`8650`: System Error in backend_agg. (with a fix!) +* :ghissue:`20294`: ``AxesImage.__str__`` is wrong if the image does not span the full Axes. +* :ghissue:`18066`: Document minimum supported OSX version for macos backend +* :ghissue:`17018`: Add documentation about transparency of frame +* :ghissue:`22403`: [MNT]: Confusing prompt in docs issue template +* :ghissue:`8839`: mpl_connect silently does nothing when passed an invalid event type string +* :ghissue:`22343`: [MNT]: Delay (or make pending) the deprecation of set_constrained_layout/set_tight_layout +* :ghissue:`21554`: [Bug]: ``ValueError`` upon deepcopy of a ``Figure`` object +* :ghissue:`22369`: [Doc]: Incorrect comment in example code for creating adjacent subplots +* :ghissue:`19174`: connectionstyle arc3 with high rad value pushes up data interval of x-axis and y-axis. +* :ghissue:`8351`: seaborn styles make "+", "x" markers invisible; proposed workaround for shipped styles +* :ghissue:`22278`: Deprecate/remove maxdict +* :ghissue:`19276`: imshow with very large arrays not working as expected +* :ghissue:`22035`: [ENH]: Specify a custom focal length / FOV for the 3d camera +* :ghissue:`22264`: [Bug]: new constrained_layout causes axes to go invisible(?) +* :ghissue:`21774`: [MNT]: Improvements to widget tests +* :ghissue:`18722`: Consider removing AFM+mathtext support +* :ghissue:`21540`: [Bug]: cm fontset in log scale does not use Unicode minus +* :ghissue:`22062`: [Bug]: Autopositioned title overlaps with offset text +* :ghissue:`22093`: [Bug]: AttributeError: 'AxesSubplot' object has no attribute 'add_text' +* :ghissue:`22012`: [Bug]: Mouseover coordinate/value text should be right aligned +* :ghissue:`21995`: [Bug]: triplot with 'ls' argument yields TypeError +* :ghissue:`20249`: MatplotlibDeprecationWarning when updating rcparams +* :ghissue:`15781`: MatplotlibDeprecationWarning examples.directory is deprecated +* :ghissue:`13118`: No MatplotlibDeprecationWarning for default rcParams +* :ghissue:`21978`: Remove logging debug of loaded modules +* :ghissue:`11738`: pgf backend doesn't make background transparent +* :ghissue:`18039`: Add ``_repr_html_`` for fonts +* :ghissue:`21970`: [Bug]: tight layout breaks with toolbar.push_current() +* :ghissue:`14850`: No icon showing up with macosx backend +* :ghissue:`17283`: Create Date Formatter/Locator Reference +* :ghissue:`21761`: [Doc]: add how to know available fonts... +* :ghissue:`21863`: [Doc]: Remove example "prop_cycle property markevery in rcParams" +* :ghissue:`10241`: Axes3D.view_init elevation issue between 270 and 360 degrees +* :ghissue:`14453`: add third angle to view_init() +* :ghissue:`20486`: Modifier key press events not recognized on MacOSX backend +* :ghissue:`9837`: MacOS: Key modifiers deprecated +* :ghissue:`11416`: RuntimeError: adjustable='datalim' is not allowed when both axes are shared. +* :ghissue:`17711`: inset_locator.mark_inset() misplaces box connectors +* :ghissue:`20854`: [Doc]: Incorrect copyright start year at the bottom of devdocs page +* :ghissue:`21394`: [Bug]: Subplot title does not obey padding +* :ghissue:`20998`: [Bug]: ToolManager does not respect rcParams["keymap."] set after import time +* :ghissue:`7075`: Superscripts in axis label cut when saving .eps with bbox_inches="tight" +* :ghissue:`21514`: [Doc]: Error message of validate_whiskers is not updated +* :ghissue:`21532`: [Doc]: subplot_mosaic docstring should link to the tutorial +* :ghissue:`16550`: Docs: performance discussion of tight_layout +* :ghissue:`21378`: [ENH]: use new style format strings for colorbar ticks +* :ghissue:`19323`: Streamplot color mapping fails on (near-)empty array. +* :ghissue:`19559`: Axes.get_xticks() returns a numpy array but Axes.get_xticks(minor=True) returns a plain list +* :ghissue:`21526`: [Doc]: Little Typo on Introductory Tutorial +* :ghissue:`19195`: Rotate Markers in functions like plot, scatter, etcetera +* :ghissue:`21364`: [Bug]: double free when FT2Font constructor is interrupted by KeyboardInterrupt +* :ghissue:`16581`: Can't not refresh new font in running interpreter +* :ghissue:`21162`: [ENH]: saving images in webp format +* :ghissue:`18168`: The example of the testing decorator does not work. +* :ghissue:`20943`: [Bug]: Deepcopy of TextPath fails +* :ghissue:`21101`: [Bug]: Errorbars separated from markers with negative errors +* :ghissue:`17986`: MEP22 per-backend tool registration +* :ghissue:`4938`: Feature request: add option to disable mathtext parsing +* :ghissue:`11435`: plt.subplot eats my subplots diff --git a/doc/users/prev_whats_new/github_stats_3.6.1.rst b/doc/users/prev_whats_new/github_stats_3.6.1.rst new file mode 100644 index 000000000000..d47dc28fa076 --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.6.1.rst @@ -0,0 +1,143 @@ +.. _github-stats-3-6-1: + +GitHub statistics for 3.6.1 (Oct 08, 2022) +========================================== + +GitHub statistics for 2022/09/16 (tag: v3.6.0) - 2022/10/08 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 22 issues and merged 80 pull requests. +The full list can be seen `on GitHub `__ + +The following 19 authors contributed 129 commits. + +* Antony Lee +* baharev +* David Stansby +* dependabot[bot] +* Eli Rykoff +* Elliott Sales de Andrade +* erykoff +* Greg Lucas +* hannah +* Ian Hunt-Isaak +* Jody Klymak +* melissawm +* Oscar Gustafsson +* Ruth Comer +* slackline +* Steffen Rehberg +* Thomas A Caswell +* Tim Hoffmann +* مهدي شينون (Mehdi Chinoune) + +GitHub issues and pull requests: + +Pull Requests (80): + +* :ghpull:`24124`: Backport PR #24111 on branch v3.6.x (FIX: add missing method to ColormapRegistry) +* :ghpull:`24111`: FIX: add missing method to ColormapRegistry +* :ghpull:`24117`: Backport PR #24113 on branch v3.6.x (Add exception class to pytest.warns calls) +* :ghpull:`24116`: Backport PR #24115 on branch v3.6.x (Fix mask lookup in fill_between for NumPy 1.24+) +* :ghpull:`24113`: Add exception class to pytest.warns calls +* :ghpull:`24115`: Fix mask lookup in fill_between for NumPy 1.24+ +* :ghpull:`24112`: Backport PR #24109 on branch v3.6.x (DOC: add API change note for colorbar deprecation) +* :ghpull:`24109`: DOC: add API change note for colorbar deprecation +* :ghpull:`24107`: Backport PR #24088 on branch v3.6.x (MNT: make orphaned colorbar deprecate versus raise) +* :ghpull:`24088`: MNT: make orphaned colorbar deprecate versus raise +* :ghpull:`24103`: Backport PR #23684 on branch v3.6.x (Fix rectangle and hatches for colorbar) +* :ghpull:`23684`: Fix rectangle and hatches for colorbar +* :ghpull:`24087`: Backport PR #24084 on branch v3.6.x (Revert argument checking for label_mode) +* :ghpull:`24084`: Revert argument checking for label_mode +* :ghpull:`24078`: Backport PR #24047 on branch v3.6.x (Revert #22360: Let TeX handle multiline strings itself) +* :ghpull:`24047`: Revert #22360: Let TeX handle multiline strings itself +* :ghpull:`24077`: Backport PR #24054 on branch v3.6.x ( DOC: Move OO-examples from pyplot section) +* :ghpull:`24054`: DOC: Move OO-examples from pyplot section +* :ghpull:`24072`: Backport PR #24069 on branch v3.6.x (Clarification of marker size in scatter) +* :ghpull:`24073`: Backport PR #24070 on branch v3.6.x (DOC: colorbar may steal from array of axes) +* :ghpull:`24070`: DOC: colorbar may steal from array of axes +* :ghpull:`24069`: Clarification of marker size in scatter +* :ghpull:`24059`: Backport PR #23638 on branch v3.6.x (FIX: correctly handle generic font families in svg text-as-text mode) +* :ghpull:`23638`: FIX: correctly handle generic font families in svg text-as-text mode +* :ghpull:`24048`: Backport PR #24045 on branch v3.6.x (Fix _FigureManagerGTK.resize on GTK4) +* :ghpull:`24055`: Backport PR #24046 on branch v3.6.x (Ignore 'CFMessagePort: bootstrap_register' messages) +* :ghpull:`24046`: Ignore 'CFMessagePort: bootstrap_register' messages +* :ghpull:`24051`: Backport PR #24037 on branch v3.6.x ([DOC]: make spanselector example codeblock continuous) +* :ghpull:`24037`: [DOC]: make spanselector example codeblock continuous +* :ghpull:`24045`: Fix _FigureManagerGTK.resize on GTK4 +* :ghpull:`24043`: Backport PR #24041 on branch v3.6.x (DOC: Fix incorrect redirect) +* :ghpull:`24030`: Backport PR #24019 on branch v3.6.x (Don't require FigureCanvas on backend module more) +* :ghpull:`24040`: Backport PR #24018 on branch v3.6.x (When comparing eps images, run ghostscript with -dEPSCrop.) +* :ghpull:`24018`: When comparing eps images, run ghostscript with -dEPSCrop. +* :ghpull:`24033`: Backport PR #24032 on branch v3.6.x (Reword SpanSelector example.) +* :ghpull:`24029`: Backport PR #24026 on branch v3.6.x (Don't modify Axes property cycle in stackplot) +* :ghpull:`23994`: Backport PR #23964 on branch v3.6.x (Fix issue with empty line in ps backend) +* :ghpull:`24019`: Don't require FigureCanvas on backend module more +* :ghpull:`24026`: Don't modify Axes property cycle in stackplot +* :ghpull:`24027`: Backport PR #23904 on branch v3.6.x (added a reversing section to colormap reference) +* :ghpull:`24017`: Backport PR #24014 on branch v3.6.x (Bump pypa/cibuildwheel from 2.10.1 to 2.10.2) +* :ghpull:`24014`: Bump pypa/cibuildwheel from 2.10.1 to 2.10.2 +* :ghpull:`24007`: Backport PR #24004 on branch v3.6.x (Increase consistency in tutorials and examples) +* :ghpull:`23964`: Fix issue with empty line in ps backend +* :ghpull:`23904`: added a reversing section to colormap reference +* :ghpull:`23990`: Backport PR #23978 on branch v3.6.x (DOC: Suppress IPython output in examples and tutorials where not needed) +* :ghpull:`23978`: DOC: Suppress IPython output in examples and tutorials where not needed +* :ghpull:`23916`: Backport PR #23912 on branch v3.6.x (FIX: only expect FigureCanvas on backend module if using new style) +* :ghpull:`23989`: Backport PR #23944 on branch v3.6.x (FIX: ValueError when hexbin is run with empty arrays and log scaling.) +* :ghpull:`23944`: FIX: ValueError when hexbin is run with empty arrays and log scaling. +* :ghpull:`23988`: Backport PR #23987 on branch v3.6.x (FIX: do not set constrained layout on false-y values) +* :ghpull:`23987`: FIX: do not set constrained layout on false-y values +* :ghpull:`23982`: Backport PR #23980 on branch v3.6.x (DOC: Move Quick Start Tutorial to first position) +* :ghpull:`23979`: Backport PR #23975 on branch v3.6.x (Reword docstring of reset_position.) +* :ghpull:`23975`: Reword docstring of reset_position. +* :ghpull:`23966`: Backport PR #23930 on branch v3.6.x (Fix edge color, links, wording; closes matplotlib/matplotlib#23895) +* :ghpull:`23971`: Backport PR #23906 on branch v3.6.x (Edit mplot3d examples for correctness and consistency) +* :ghpull:`23906`: Edit mplot3d examples for correctness and consistency +* :ghpull:`23963`: Backport PR #23957 on branch v3.6.x (Bump pypa/cibuildwheel from 2.9.0 to 2.10.1) +* :ghpull:`23930`: Fix edge color, links, wording; closes matplotlib/matplotlib#23895 +* :ghpull:`23910`: FIX: do not append None to stream in ps +* :ghpull:`23957`: Bump pypa/cibuildwheel from 2.9.0 to 2.10.1 +* :ghpull:`23960`: Backport PR #23947 on branch v3.6.x (Fix building on MINGW) +* :ghpull:`23942`: DOC: fix versions in v3.6.x doc switcher +* :ghpull:`23961`: Backport PR #23958 on branch v3.6.x (DOC: Remove Adding Animations section) +* :ghpull:`23958`: DOC: Remove Adding Animations section +* :ghpull:`23947`: Fix building on MINGW +* :ghpull:`23945`: Backport PR #23941 on branch v3.6.x (consistent notation for minor/patch branches) +* :ghpull:`23956`: Backport PR #23751 on branch v3.6.x (FIX: show bars when the first location is nan) +* :ghpull:`23751`: FIX: show bars when the first location is nan +* :ghpull:`23938`: Backport PR #23919 on branch v3.6.x (DOC: remove dead "Show Source" links) +* :ghpull:`23952`: Backport PR #23951 on branch v3.6.x (DOC: Make animation continuous) +* :ghpull:`23949`: DOC: Display "dev" instead of "devdocs" in the version switcher +* :ghpull:`23940`: Fix typos in github_stats.rst +* :ghpull:`23936`: Backport PR #23935 on branch v3.6.x (DOC: fix versions is doc switcher) +* :ghpull:`23933`: Backport PR #23932 on branch v3.6.x (DOC: Fix formatting in image tutorial) +* :ghpull:`23932`: DOC: Fix formatting in image tutorial +* :ghpull:`23926`: Backport PR #23925 on branch v3.6.x (FIX: use process_event in dpi changes on macosx backend) +* :ghpull:`23925`: FIX: use process_event in dpi changes on macosx backend +* :ghpull:`23912`: FIX: only expect FigureCanvas on backend module if using new style + +Issues (22): + +* :ghissue:`23981`: [ENH]: Default ``matplotlib.colormaps[None]`` to call ``matplotlib.colormaps[matplotlib.rcParams['image.cmap']]``? +* :ghissue:`24106`: [Bug]: fill_between gives IndexError with numpy 1.24.0.dev +* :ghissue:`24053`: Cartopy axes_grid_basic example broken by Matplotlib 3.6 +* :ghissue:`23977`: [Bug]: Eqnarray in AnchoredText results in misplaced text (new in v3.6.0) +* :ghissue:`23973`: [Bug]: ValueError: Unable to determine Axes to steal space for Colorbar. +* :ghissue:`23456`: [Bug]: Horizontal colorbars drawn incorrectly with hatches +* :ghissue:`15922`: Pyplot gallery section is mostly OO examples +* :ghissue:`23700`: [Doc]: scatter points +* :ghissue:`23492`: [Bug]: svg backend does not use configured generic family lists +* :ghissue:`22528`: [Bug]: problem with font property in text elements of svg figures +* :ghissue:`23911`: [Bug]: 3.6.0 doesn't interact well with pycharm throwing "backend_interagg" exception +* :ghissue:`24024`: stackplot should not change Axes cycler +* :ghissue:`23954`: [Bug]: Text label with empty line causes a "TypeError: cannot unpack non-iterable NoneType object" in PostScript backend +* :ghissue:`23922`: [Bug]: Refactor of hexbin for 3.6.0 crashes with empty arrays and log scaling +* :ghissue:`23986`: [Bug]: Constrained layout UserWarning even when False +* :ghissue:`23895`: [Bug]: 3D surface is not plotted for the contour3d_3 example in the gallery +* :ghissue:`23955`: [Doc]: Adding animations to Youtube channel +* :ghissue:`23943`: [Bug]: Couldn't build matplotlib 3.6.0 with both Clang-15 and GCC-12 +* :ghissue:`23687`: [Bug]: barplot does not show anything when x or bottom start and end with NaN +* :ghissue:`23876`: [Doc]: Missing source files +* :ghissue:`23909`: [Doc]: add animation examples to show animated subplots +* :ghissue:`23921`: [Bug]: resize_event deprecation warnings when creating figure on macOS with version 3.6.0 diff --git a/doc/users/prev_whats_new/github_stats_3.6.2.rst b/doc/users/prev_whats_new/github_stats_3.6.2.rst new file mode 100644 index 000000000000..f633448aeaf1 --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.6.2.rst @@ -0,0 +1,151 @@ +.. _github-stats-3-6-2: + +GitHub statistics for 3.6.2 (Nov 02, 2022) +========================================== + +GitHub statistics for 2022/10/08 (tag: v3.6.1) - 2022/11/02 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 21 issues and merged 86 pull requests. +The full list can be seen `on GitHub `__ + +The following 22 authors contributed 27 commits. + +* Antony Lee +* Carsten Schnober +* dependabot[bot] +* Elliott Sales de Andrade +* hannah +* j1642 +* Jaco Verster +* jacoverster +* Jae-Joon Lee +* Jeffrey Aaron Paul +* jeffreypaul15 +* Jody Klymak +* Kostya Farber +* Kyle Sunden +* Martok +* Muhammad Abdur Rakib +* Oscar Gustafsson +* Pavel Grunt +* Ruth Comer +* Thomas A Caswell +* Tiger Nie +* Tim Hoffmann + +GitHub issues and pull requests: + +Pull Requests (86): + +* :ghpull:`24341`: Backport PR #24301 on branch v3.6.x (Restore get_renderer function in deprecated tight_layout) +* :ghpull:`24301`: Restore get_renderer function in deprecated tight_layout +* :ghpull:`24337`: Backport PR #24238 on branch v3.6.x (Update example and docstring to encourage the use of functools.partial in FuncAnimation) +* :ghpull:`24336`: Backport PR #24335 on branch v3.6.x (Fix missing word in ImageMagickWriter docstring.) +* :ghpull:`20358`: Updates example and docstring to encourage the use of functools.partial in FuncAnimation +* :ghpull:`24238`: Update example and docstring to encourage the use of functools.partial in FuncAnimation +* :ghpull:`24335`: Fix missing word in ImageMagickWriter docstring. +* :ghpull:`24330`: Backport PR #24282 on branch v3.6.x (Fix some minor docstring typos) +* :ghpull:`24323`: Backport PR #24320 on branch v3.6.x (DOC: add warning note to imsave) +* :ghpull:`24282`: Fix some minor docstring typos +* :ghpull:`24327`: Backport PR #24310 on branch v3.6.x (show axes changing in animate decay example) +* :ghpull:`24310`: show axes changing in animate decay example +* :ghpull:`24324`: Backport PR #24259 on branch v3.6.x (Move empty hexbin fix to make_norm_from_scale.) +* :ghpull:`24325`: Backport PR #24095 on branch v3.6.x (nb/webagg: Move mouse events to outer canvas div) +* :ghpull:`24326`: Backport PR #24318 on branch v3.6.x (Bump pypa/cibuildwheel from 2.11.1 to 2.11.2) +* :ghpull:`24318`: Bump pypa/cibuildwheel from 2.11.1 to 2.11.2 +* :ghpull:`24095`: nb/webagg: Move mouse events to outer canvas div +* :ghpull:`24259`: Move empty hexbin fix to make_norm_from_scale. +* :ghpull:`24320`: DOC: add warning note to imsave +* :ghpull:`24297`: Backport PR #24294 on branch v3.6.x (Run test if fontconfig is present) +* :ghpull:`24294`: Run test if fontconfig is present +* :ghpull:`24286`: Backport PR #24284 on branch v3.6.x (Remove comment about cmap from voxels docstring) +* :ghpull:`24284`: Remove comment about cmap from voxels docstring +* :ghpull:`24280`: Backport PR #24145 on branch v3.6.x (Updated Angles on Bracket arrow styles example to make angles clear #23176) +* :ghpull:`24145`: Updated Angles on Bracket arrow styles example to make angles clear #23176 +* :ghpull:`24270`: Backport PR #24265 on branch v3.6.x (Restore (and warn on) seaborn styles in style.library) +* :ghpull:`24271`: Backport PR #24266 on branch v3.6.x (TST: Increase fp tolerance on more tests for new NumPy) +* :ghpull:`24266`: TST: Increase fp tolerance on more tests for new NumPy +* :ghpull:`24265`: Restore (and warn on) seaborn styles in style.library +* :ghpull:`24267`: Backport PR #24261 on branch v3.6.x (Fix pie chart in demo_agg_filter.py) +* :ghpull:`24261`: Fix pie chart in demo_agg_filter.py +* :ghpull:`24258`: Backport PR #24108 on branch v3.6.x (Add 3D plots to plot_types doc page) +* :ghpull:`24108`: Add 3D plots to plot_types doc page +* :ghpull:`24255`: Backport PR #24250 on branch v3.6.x (Fix key reporting in pick events) +* :ghpull:`24250`: Fix key reporting in pick events +* :ghpull:`24237`: Backport PR #24197 on branch v3.6.x (Properly set and inherit backend_version.) +* :ghpull:`24197`: Properly set and inherit backend_version. +* :ghpull:`24234`: Backport PR #23607 on branch v3.6.x (DOC: document that appearance is part of our stable API) +* :ghpull:`24233`: Backport PR #23985 on branch v3.6.x (Improve rubberband rendering in wx and tk) +* :ghpull:`24232`: Backport PR #24096 on branch v3.6.x ([DOC]: Add simple animation scatter plot to the example documentation) +* :ghpull:`24231`: Backport PR #24009 on branch v3.6.x (Fix evaluating colormaps on non-numpy arrays) +* :ghpull:`24230`: Backport PR #24229 on branch v3.6.x (FIX: do not mutate dictionaries passed in by user) +* :ghpull:`23607`: DOC: document that appearance is part of our stable API +* :ghpull:`23985`: Improve rubberband rendering in wx and tk +* :ghpull:`24096`: [DOC]: Add simple animation scatter plot to the example documentation +* :ghpull:`24009`: Fix evaluating colormaps on non-numpy arrays +* :ghpull:`24229`: FIX: do not mutate dictionaries passed in by user +* :ghpull:`24223`: Backport PR #24184 on branch v3.6.x (Add tests for ToolManager) +* :ghpull:`24219`: Backport PR #23995 on branch v3.6.x (DOC: Lowercase some parameter names) +* :ghpull:`23995`: DOC: Lowercase some parameter names +* :ghpull:`24184`: Add tests for ToolManager +* :ghpull:`24211`: Backport PR #24202 on branch v3.6.x (Bump pypa/cibuildwheel from 2.10.2 to 2.11.1) +* :ghpull:`24214`: Backport PR #24169 on branch v3.6.x ([DOC]: added parent link for ``FuncAnimation`` and ``ArtistAnimation``) +* :ghpull:`24169`: [DOC]: add parent link for ``FuncAnimation`` and ``ArtistAnimation`` +* :ghpull:`24202`: Bump pypa/cibuildwheel from 2.10.2 to 2.11.1 +* :ghpull:`24206`: Backport PR #24081 on branch v3.6.x (TST: force test with shared test image to run in serial) +* :ghpull:`24181`: Backport PR #24177 on branch v3.6.x (Don't simplify paths used for autoscaling) +* :ghpull:`24200`: Backport PR #24193 on branch v3.6.x (DOC: Explain gridsize in hexbin()) +* :ghpull:`24201`: Backport PR #24194 on branch v3.6.x (DOC: Improve plot_directive documentation) +* :ghpull:`24194`: DOC: Improve plot_directive documentation +* :ghpull:`24193`: DOC: Explain gridsize in hexbin() +* :ghpull:`24192`: Backport PR #24187 on branch v3.6.x (DOC: Fix toc structure in explain/interactive) +* :ghpull:`24186`: Backport PR #24157 on branch v3.6.x (test only PR milestoning guidance) +* :ghpull:`24187`: DOC: Fix toc structure in explain/interactive +* :ghpull:`24190`: DOC: fix markup +* :ghpull:`24157`: test only PR milestoning guidance +* :ghpull:`24183`: Backport PR #24178 on branch v3.6.x (Fall back to Python-level Thread for GUI warning) +* :ghpull:`24180`: Backport PR #24173 on branch v3.6.x (TST: convert nose-style tests) +* :ghpull:`24178`: Fall back to Python-level Thread for GUI warning +* :ghpull:`24177`: Don't simplify paths used for autoscaling +* :ghpull:`24173`: TST: convert nose-style tests +* :ghpull:`24174`: Backport PR #24171 on branch v3.6.x (Fix example where wrong variable was used) +* :ghpull:`24176`: Backport PR #24167 on branch v3.6.x (FIX: turn off layout engine tightbbox) +* :ghpull:`24167`: FIX: turn off layout engine tightbbox +* :ghpull:`24171`: Fix example where wrong variable was used +* :ghpull:`24172`: Backport PR #24158 on branch v3.6.x (Fix Qt with PySide6 6.4.0) +* :ghpull:`24158`: Fix Qt with PySide6 6.4.0 +* :ghpull:`24165`: Backport PR #24164 on branch v3.6.x (Fix argument order in hist() docstring.) +* :ghpull:`24164`: Fix argument order in hist() docstring. +* :ghpull:`24151`: Backport PR #24149 on branch v3.6.x (FIX: handle input to ax.bar that is all nan) +* :ghpull:`24149`: FIX: handle input to ax.bar that is all nan +* :ghpull:`24146`: Backport PR #24137 on branch v3.6.x (Add note about blitting and zorder in animations) +* :ghpull:`24137`: Add note about blitting and zorder in animations +* :ghpull:`24134`: Backport PR #24130 on branch v3.6.x (DOC: align contour parameter doc with implementation) +* :ghpull:`24130`: DOC: align contour parameter doc with implementation +* :ghpull:`24081`: TST: force test with shared test image to run in serial + +Issues (21): + +* :ghissue:`20326`: FuncAnimation Named Arguments +* :ghissue:`24332`: [Bug]: backend bug in matplotlib==3.6.1 with python3.11 and PySide6==6.4.0.1 +* :ghissue:`24296`: [Doc]: Axes limits not updated in animate decay +* :ghissue:`24089`: [Bug]: Resizing does not work in WebAgg backend in Safari +* :ghissue:`3657`: matplotlib.pyplot.imsave colormaps some grayscale images before saving them +* :ghissue:`24060`: [TST] Upcoming dependency test failures +* :ghissue:`24264`: [Bug]: Setting matplotlib.pyplot.style.library['seaborn-colorblind'] result in key error on matplotlib v3.6.1 +* :ghissue:`23900`: [Doc]: Adding some 3D plots to plot gallery +* :ghissue:`24199`: [Bug]: pick events do not forward mouseevent-key on Linux +* :ghissue:`23969`: [ENH]: Make rubber band more visible +* :ghissue:`23132`: [Bug]: call cmap object on torch.tensor will output first element all 0 +* :ghissue:`21349`: [Bug]: Hexbin gridsize interpreted differently for x and y +* :ghissue:`22905`: [Doc]: Duplicated toc entries +* :ghissue:`24094`: [Bug]: macOS: PyPy 3.8 (v7.3.9) threading get_native_id Broken +* :ghissue:`24097`: [Bug]: ax.hist density not auto-scaled when using histtype='step' +* :ghissue:`24148`: remove nose-style test classes +* :ghissue:`24133`: [Bug]: Incorrect crop after constrained layout with equal aspect ratio and bbox_inches = tight +* :ghissue:`24155`: [Bug]: TypeError: int() argument must be a string, a bytes-like object or a number, not 'KeyboardModifier' +* :ghissue:`24127`: [Bug]: ax.bar raises for all-nan data on matplotlib 3.6.1 +* :ghissue:`2959`: artists zorder is ignored during animations +* :ghissue:`24121`: [Doc]: Contour functions: auto-generated levels diff --git a/doc/users/prev_whats_new/github_stats_3.6.3.rst b/doc/users/prev_whats_new/github_stats_3.6.3.rst new file mode 100644 index 000000000000..b1d17a791c87 --- /dev/null +++ b/doc/users/prev_whats_new/github_stats_3.6.3.rst @@ -0,0 +1,165 @@ +.. _github-stats-3-6-3: + +GitHub statistics for 3.6.3 (Jan 11, 2023) +========================================== + +GitHub statistics for 2022/11/02 (tag: v3.6.2) - 2023/01/11 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 16 issues and merged 107 pull requests. +The full list can be seen `on GitHub `__ + +The following 20 authors contributed 198 commits. + +* Antony Lee +* Chahak Mehta +* David Stansby +* Elliott Sales de Andrade +* Eric Larson +* hannah +* iofall +* Jody Klymak +* Kaidong Hu +* Kyle Sunden +* matt statham +* Matthias Bussonnier +* Muhammad Abdur Rakib +* Oscar Gustafsson +* ramvikrams +* Ruth Comer +* Steffen Rehberg +* Thomas A Caswell +* Tim Hoffmann +* yuanx749 + +GitHub issues and pull requests: + +Pull Requests (107): + +* :ghpull:`24939`: Backport PR #23390 on branch v3.6.x (FIX: colorbar contour with log norm should default to log locator and formatter...) +* :ghpull:`24936`: Backport PR #24927 on branch v3.6.x (DOC: Remove space after directive name, before double-colon) +* :ghpull:`23390`: FIX: colorbar contour with log norm should default to log locator and formatter... +* :ghpull:`24932`: Backport PR #24783 on branch v3.6.x (inset locator fix with tests added) +* :ghpull:`24783`: inset locator fix with tests added +* :ghpull:`24927`: DOC: Remove space after directive name, before double-colon +* :ghpull:`24881`: Backport PR #24880 on branch v3.6.x (Minor cleanups to named colors example.) +* :ghpull:`24876`: Backport PR #24873 on branch v3.6.x (Copy-edit fonts docs.) +* :ghpull:`24857`: Backport PR #24856 on branch v3.6.x (fix typo) +* :ghpull:`24852`: Backport PR #24843 on branch v3.6.x (Show that fill_between and span_where provide similar functionalities.) +* :ghpull:`24808`: Backport PR #24807 on branch v3.6.x (Axes.stem docstring document orientation as literals) +* :ghpull:`24807`: Axes.stem docstring document orientation as literals +* :ghpull:`24791`: Backport PR #24785 on branch v3.6.x (Fix random generation of single floats) +* :ghpull:`24777`: Backport PR #24772 on branch v3.6.x (Fix Left ventricle bullseye example) +* :ghpull:`24775`: Backport PR #24774 on branch v3.6.x (DOC: fix strip_chart example with numpy 1.24) +* :ghpull:`24765`: Backport PR #24764 on branch v3.6.x (DOC: ``subplot_mosaic`` tutorial - clarify ratios keywords used directly) +* :ghpull:`24739`: Backport PR #24732 on branch v3.6.x (Use masked stack to preserve mask info) +* :ghpull:`24738`: Backport PR #24735 on branch v3.6.x (Correct note about aspect) +* :ghpull:`24732`: Use masked stack to preserve mask info +* :ghpull:`24735`: Correct note about aspect +* :ghpull:`24729`: Backport PR #24715 on branch v3.6.x (Add note that users do not instantiate Axes directly) +* :ghpull:`24715`: Add note that users do not instantiate Axes directly +* :ghpull:`24721`: Backport PR #24607 on branch v3.6.x (DOC: tweak wording on Figure.show warning) +* :ghpull:`24607`: DOC: tweak wording on Figure.show warning +* :ghpull:`24694`: Backport PR #24692 on branch v3.6.x (Avoid rgba8888->argb32 conversion if qt can do it for us.) +* :ghpull:`24692`: Avoid rgba8888->argb32 conversion if qt can do it for us. +* :ghpull:`24684`: Backport PR #24654: Don't manually invalidate cached lines in _update_transScale +* :ghpull:`24687`: Backport PR #24003 on branch v3.6.x (Fix wording and links lifecycle tutorial) +* :ghpull:`24685`: Backport PR #23974 on branch v3.6.x (Fix repeated word typos) +* :ghpull:`24680`: Backport PR #24677 on branch v3.6.x (FIX: do not replace the Axes._children list object) +* :ghpull:`24677`: FIX: do not replace the Axes._children list object +* :ghpull:`24659`: Backport PR #24657 on branch v3.6.x (BUG: Fix bug with mutable input modification) +* :ghpull:`24657`: BUG: Fix bug with mutable input modification +* :ghpull:`24654`: Don't manually invalidate cached lines in _update_transScale. +* :ghpull:`24650`: Backport PR #24645 on branch v3.6.x (Removed 'above' wording from Input hook integration docs (#24632)) +* :ghpull:`24647`: Backport PR #24643 on branch v3.6.x (DOC: annotation coords are not floats) +* :ghpull:`24643`: DOC: annotation coords are not floats +* :ghpull:`24625`: Backport PR #24606: FIX: do not use deprecated API in gtk4 backend +* :ghpull:`24633`: Backport PR #24592 on branch v3.6.x (DOC: Don't try to link paths that are on a different drive) +* :ghpull:`24592`: DOC: Don't try to link paths that are on a different drive +* :ghpull:`24628`: Backport PR #24584 on branch v3.6.x (DOC: add "See Also: draw_idle" reference to pyplot.draw) +* :ghpull:`24584`: DOC: add "See Also: draw_idle" reference to pyplot.draw +* :ghpull:`24601`: Backport PR #24600 on branch v3.6.x (Fix: Gracefully fail the string validator for tuple inputs) +* :ghpull:`24609`: Backport PR #24595 on branch v3.6.x (ci: Stop building wheels on AppVeyor) +* :ghpull:`24616`: Backport PR #24397 on branch v3.6.x (Simplify appveyor to only use conda) +* :ghpull:`24615`: Backport PR #24598 on branch v3.6.x (Check for errors/warnings on failed doc-builds) +* :ghpull:`24606`: FIX: do not use deprecated API in gtk4 backend +* :ghpull:`24612`: Backport PR #23868 on branch v3.6.x (Show errors and warnings in doc CI after build.) +* :ghpull:`24595`: ci: Stop building wheels on AppVeyor +* :ghpull:`24600`: Fix: Gracefully fail the string validator for tuple inputs +* :ghpull:`24593`: Backport PR #24580 on branch v3.6.x (Update the polar transform information in doc #24499) +* :ghpull:`24587`: Backport PR #24579: Add explicit permissions to GitHub Actions +* :ghpull:`24579`: Add explicit permissions to GitHub Actions +* :ghpull:`24561`: Backport PR #24540 on branch v3.6.x (DOC: add note about enabling c++11 support for old gcc) +* :ghpull:`24559`: Backport PR #24299 on branch v3.6.x (Rework style sheet reference example to cycle props) +* :ghpull:`24551`: Backport PR #24548 on branch v3.6.x (DOC: improved the doc for layout_engine.py) +* :ghpull:`24548`: DOC: improved the doc for layout_engine.py +* :ghpull:`24535`: Backport PR #24514 on branch v3.6.x (Fix potential issue in contour) +* :ghpull:`24534`: Backport PR #24521 on branch v3.6.x (Doc: improve spelling and grammar) +* :ghpull:`24533`: Backport PR #24517 on branch v3.6.x (DOC: improve grammar and consistency) +* :ghpull:`24532`: Backport PR #24520 on branch v3.6.x (Doc: Fix grammar and spelling) +* :ghpull:`24514`: Fix potential issue in contour +* :ghpull:`24521`: Doc: improve spelling and grammar +* :ghpull:`24517`: DOC: improve grammar and consistency +* :ghpull:`24520`: Doc: Fix grammar and spelling +* :ghpull:`24515`: Backport PR #24512 on branch v3.6.x (Tweak markup in toolkits tutorials.) +* :ghpull:`24503`: Backport PR #24502 on branch v3.6.x (Remove link from demo_floating_axes title.) +* :ghpull:`24505`: Backport PR #24482 on branch v3.6.x (Use relative frame path in HTMLWriter) +* :ghpull:`24506`: Backport of PR#24488 (Update for pydata-sphinx-theme 0.12.0) +* :ghpull:`24482`: Use relative frame path in HTMLWriter +* :ghpull:`24496`: Backport PR #24495 on branch v3.6.x (Update adding of google analytics key for docs) +* :ghpull:`24495`: Update adding of google analytics key for docs +* :ghpull:`24488`: Update for pydata-sphinx-theme 0.12.0 +* :ghpull:`24485`: Backport PR #24481 on branch v3.6.x (Fix floating-point drift in oscilloscope example) +* :ghpull:`24475`: DOC: Fix examples gallery layout issues +* :ghpull:`24478`: Backport PR #24444 on branch v3.6.x (DOC: AnnotationBbox keyword descriptions) +* :ghpull:`24444`: DOC: AnnotationBbox keyword descriptions +* :ghpull:`24468`: Backport PR #24429 on branch v3.6.x (DOC: Clarify transparency in colors) +* :ghpull:`24466`: Backport PR #24460 on branch v3.6.x (Define autoscale() based on autoscale_None().) +* :ghpull:`24460`: Define autoscale() based on autoscale_None(). +* :ghpull:`24463`: Backport PR #24459 on branch v3.6.x (removed unused variable and fixed text in doc) +* :ghpull:`24459`: removed unused variable and fixed text in doc +* :ghpull:`24458`: Backport PR #24434 on branch v3.6.x (Fix pyplot.figlegend docstring) +* :ghpull:`24434`: Fix pyplot.figlegend docstring +* :ghpull:`24456`: Backport PR #24402 on branch v3.6.x (DOC: Fix title formats in backend api docs) +* :ghpull:`24438`: Backport PR #24435 on branch v3.6.x (Minor improvements to LogLocator docstring) +* :ghpull:`24435`: Minor improvements to LogLocator docstring +* :ghpull:`24426`: Backport PR #24422 on branch v3.6.x (Make QT_API a link in the qt embedding example.) +* :ghpull:`24411`: Backport PR #24407 on branch v3.6.x (Reword "Reordering is not commutative" phrase in tutorial.) +* :ghpull:`24400`: Backport PR #24399 on branch v3.6.x (Fix docstring of Figure.subfigures.) +* :ghpull:`24399`: Fix docstring of Figure.subfigures. +* :ghpull:`24391`: Backport PR #24380 on branch v3.6.x (DOC: Remove the example "Pythonic Matplotlib") +* :ghpull:`24384`: Backport PR #24377 on branch v3.6.x (DOC: Cleanup Spine placement example) +* :ghpull:`24381`: Backport PR #24366 on branch v3.6.x (DOC: Improve Image Slices Viewer example) +* :ghpull:`24382`: Backport PR #24378 on branch v3.6.x (DOC: Cleanup spines usage in examples) +* :ghpull:`24378`: DOC: Cleanup spines usage in examples +* :ghpull:`24366`: DOC: Improve Image Slices Viewer example +* :ghpull:`24370`: Backport PR #24368 on branch v3.6.x (DOC: Install dev dependencies before building matplotlib) +* :ghpull:`24368`: DOC: Install dev dependencies before building matplotlib +* :ghpull:`24365`: Backport PR #24363 on branch v3.6.x (DOC: Fix syntax of suggestion) +* :ghpull:`24358`: Backport PR #24354 on branch v3.6.x (DOC: clarify rc_context resets all rcParams changes) +* :ghpull:`24354`: DOC: clarify rc_context resets all rcParams changes +* :ghpull:`24353`: Backport PR #24343 on branch v3.6.x (Emit "axes not compatible with tight_layout" in a single place.) +* :ghpull:`24343`: Emit "axes not compatible with tight_layout" in a single place. +* :ghpull:`24346`: Backport PR #24344 on branch v3.6.x (Add test for colorbar extend alpha) +* :ghpull:`24344`: Add test for colorbar extend alpha +* :ghpull:`23974`: Fix repeated word typos + +Issues (16): + +* :ghissue:`23389`: [Bug]: Colorbar with log scales wrong format +* :ghissue:`24589`: [Bug]: inset_locator is broken when used with subfigures +* :ghissue:`10160`: Low resolution (dpi problem) with Qt5 backend on new iMac Pro Retina +* :ghissue:`24545`: [Bug]: ``matplotlib.pyplot.scatter`` does not respect mask rules with ``datetime`` +* :ghissue:`24639`: [Bug]: The Axes3D does not work as expected. +* :ghissue:`22169`: [Doc]: figure.show works beyond what is documented +* :ghissue:`23968`: [Bug]: Zoom rubber band lags in larger window +* :ghissue:`24574`: [Bug]: Extension error (sphinx.ext.linkcode) while building docs +* :ghissue:`24602`: ``close_event`` deprecated warning. +* :ghissue:`24518`: [Doc]: ``layout_engine`` description +* :ghissue:`23581`: [BUG]: frame paths relative to the html file when saving an animation to html +* :ghissue:`23976`: [Doc]: Examples Gallery Layout changed to one or two columns +* :ghissue:`24390`: [Doc]: alpha setting for annotation ``TextArea`` +* :ghissue:`24433`: [Doc]: figlegend examples call ``fig.figlegend`` instead of ``plt.figlegend`` or ``fig.legend`` +* :ghissue:`24360`: [ENH]: imshow support for multiple slice image volume +* :ghissue:`24359`: [Bug]: Documentation not so clear that a C/C++-compiler is required to install from source diff --git a/doc/users/prev_whats_new/whats_new_1.0.rst b/doc/users/prev_whats_new/whats_new_1.0.rst index af078f2a734d..ab902977cb1e 100644 --- a/doc/users/prev_whats_new/whats_new_1.0.rst +++ b/doc/users/prev_whats_new/whats_new_1.0.rst @@ -45,7 +45,7 @@ indexing (starts with 0). e.g.:: See :doc:`/gallery/subplots_axes_and_figures/subplot` for several code examples. -Contour fixes and and triplot +Contour fixes and triplot ----------------------------- Ian Thomas has fixed a long-standing bug that has vexed our most diff --git a/doc/users/prev_whats_new/whats_new_1.1.rst b/doc/users/prev_whats_new/whats_new_1.1.rst index 5b09327a496b..901351466fac 100644 --- a/doc/users/prev_whats_new/whats_new_1.1.rst +++ b/doc/users/prev_whats_new/whats_new_1.1.rst @@ -51,7 +51,7 @@ Tight Layout A frequent issue raised by users of matplotlib is the lack of a layout engine to nicely space out elements of the plots. While matplotlib still adheres to the philosophy of giving users complete control over the placement -of plot elements, Jae-Joon Lee created the :mod:`~matplotlib.tight_layout` +of plot elements, Jae-Joon Lee created the ``matplotlib.tight_layout`` module and introduced a new command :func:`~matplotlib.pyplot.tight_layout` to address the most common layout issues. diff --git a/doc/users/prev_whats_new/whats_new_1.3.rst b/doc/users/prev_whats_new/whats_new_1.3.rst index 383c70938655..855235069917 100644 --- a/doc/users/prev_whats_new/whats_new_1.3.rst +++ b/doc/users/prev_whats_new/whats_new_1.3.rst @@ -101,7 +101,7 @@ Updated Axes3D.contour methods Damon McDougall updated the :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.tricontour` and :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.tricontourf` methods to allow 3D -contour plots on abitrary unstructured user-specified triangulations. +contour plots on arbitrary unstructured user-specified triangulations. .. figure:: ../../gallery/mplot3d/images/sphx_glr_tricontour3d_001.png :target: ../../gallery/mplot3d/tricontour3d.html diff --git a/doc/users/prev_whats_new/whats_new_1.4.rst b/doc/users/prev_whats_new/whats_new_1.4.rst index c4123c9b6254..febcb93047b9 100644 --- a/doc/users/prev_whats_new/whats_new_1.4.rst +++ b/doc/users/prev_whats_new/whats_new_1.4.rst @@ -242,7 +242,7 @@ Simple quiver plot for mplot3d toolkit A team of students in an *Engineering Large Software Systems* course, taught by Prof. Anya Tafliovich at the University of Toronto, implemented a simple version of a quiver plot in 3D space for the mplot3d toolkit as one of their -term project. This feature is documented in :func:`~mpl_toolkits.mplot3d.Axes3D.quiver`. +term project. This feature is documented in `~.Axes3D.quiver`. The team members are: Ryan Steve D'Souza, Victor B, xbtsw, Yang Wang, David, Caradec Bisesar and Vlad Vassilovski. diff --git a/doc/users/prev_whats_new/whats_new_1.5.rst b/doc/users/prev_whats_new/whats_new_1.5.rst index 2d598f580314..b94355b97b93 100644 --- a/doc/users/prev_whats_new/whats_new_1.5.rst +++ b/doc/users/prev_whats_new/whats_new_1.5.rst @@ -36,7 +36,7 @@ that the draw command is deferred and only called once. The upshot of this is that for interactive backends (including ``%matplotlib notebook``) in interactive mode (with ``plt.ion()``) -.. code-block :: python +.. code-block:: python import matplotlib.pyplot as plt fig, ax = plt.subplots() @@ -340,7 +340,7 @@ Added a :mod:`.legend_handler` for :class:`~matplotlib.collections.PolyCollectio Support for alternate pivots in mplot3d quiver plot ``````````````````````````````````````````````````` -Added a :code:`pivot` kwarg to :func:`~mpl_toolkits.mplot3d.Axes3D.quiver` +Added a :code:`pivot` kwarg to `~.Axes3D.quiver` that controls the pivot point around which the quiver line rotates. This also determines the placement of the arrow head along the quiver line. diff --git a/doc/users/prev_whats_new/whats_new_2.2.rst b/doc/users/prev_whats_new/whats_new_2.2.rst index 3dbe6482fd87..77b9056048f4 100644 --- a/doc/users/prev_whats_new/whats_new_2.2.rst +++ b/doc/users/prev_whats_new/whats_new_2.2.rst @@ -60,7 +60,7 @@ New ``figure`` kwarg for ``GridSpec`` In order to facilitate ``constrained_layout``, ``GridSpec`` now accepts a ``figure`` keyword. This is backwards compatible, in that not supplying this will simply cause ``constrained_layout`` to not operate on the subplots -orgainzed by this ``GridSpec`` instance. Routines that use ``GridSpec`` (e.g. +organized by this ``GridSpec`` instance. Routines that use ``GridSpec`` (e.g. ``fig.subplots``) have been modified to pass the figure to ``GridSpec``. diff --git a/doc/users/prev_whats_new/whats_new_3.0.rst b/doc/users/prev_whats_new/whats_new_3.0.rst index 99de2caf9309..683a7b5c702d 100644 --- a/doc/users/prev_whats_new/whats_new_3.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.0.rst @@ -109,8 +109,7 @@ Support for axes.prop_cycle property *markevery* in rcParams The Matplotlib ``rcParams`` settings object now supports configuration of the attribute :rc:`axes.prop_cycle` with cyclers using the *markevery* -Line2D object property. An example of this feature is provided at -:doc:`/gallery/lines_bars_and_markers/markevery_prop_cycle`. +Line2D object property. Multi-page PDF support for pgf backend -------------------------------------- @@ -142,10 +141,10 @@ independent on the axes size or units. To revert to the previous behaviour set the axes' aspect ratio to automatic by using ``ax.set_aspect("auto")`` or ``plt.axis("auto")``. -Add ``ax.get_gridspec`` to `.SubplotBase` ------------------------------------------ +Add ``ax.get_gridspec`` to ``SubplotBase`` +------------------------------------------ -New method `.SubplotBase.get_gridspec` is added so that users can +New method ``SubplotBase.get_gridspec`` is added so that users can easily get the gridspec that went into making an axes: .. code:: diff --git a/doc/users/prev_whats_new/whats_new_3.3.0.rst b/doc/users/prev_whats_new/whats_new_3.3.0.rst index f9c9fdcd3713..58959627246a 100644 --- a/doc/users/prev_whats_new/whats_new_3.3.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.3.0.rst @@ -50,7 +50,7 @@ or as a string (with single-character Axes labels): ha='center', va='center', fontsize=36, color='darkgrey') -See :doc:`/tutorials/provisional/mosaic` for more details and examples. +See :doc:`/gallery/subplots_axes_and_figures/mosaic` for more details and examples. ``GridSpec.subplots()`` ----------------------- @@ -373,8 +373,8 @@ set with the new rcParameter :rc:`axes.titley`. .. plot:: fig, axs = plt.subplots(1, 2, constrained_layout=True, figsize=(5, 2)) - axs[0].set_title('y=0.7\n$\sum_{j_n} x_j$', y=0.7) - axs[1].set_title('y=None\n$\sum_{j_n} x_j$') + axs[0].set_title('y=0.7\n$\\sum_{j_n} x_j$', y=0.7) + axs[1].set_title('y=None\n$\\sum_{j_n} x_j$') plt.show() Offset text is now set to the top when using ``axis.tick_top()`` diff --git a/doc/users/prev_whats_new/whats_new_3.4.0.rst b/doc/users/prev_whats_new/whats_new_3.4.0.rst index 1f4c139db816..85a2f8a80975 100644 --- a/doc/users/prev_whats_new/whats_new_3.4.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.4.0.rst @@ -866,7 +866,7 @@ Stem plots in 3D Axes --------------------- Stem plots are now supported on 3D Axes. Much like 2D stems, -`~.axes3d.Axes3D.stem3D` supports plotting the stems in various orientations: +`~.axes3d.Axes3D.stem` supports plotting the stems in various orientations: .. plot:: diff --git a/doc/users/prev_whats_new/whats_new_3.5.0.rst b/doc/users/prev_whats_new/whats_new_3.5.0.rst index a1ad4a35f513..5e69ab6be926 100644 --- a/doc/users/prev_whats_new/whats_new_3.5.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.5.0.rst @@ -126,7 +126,7 @@ Setting collection offset transform after initialization The added `.collections.Collection.set_offset_transform` may be used to set the offset transform after initialization. This can be helpful when creating a `.collections.Collection` outside an Axes object, and later adding it with -`.Axes.add_collection()` and setting the offset transform to `.Axes.transData`. +`.Axes.add_collection()` and setting the offset transform to ``Axes.transData``. Colors and colormaps ==================== @@ -623,7 +623,7 @@ the Agg or Cairo renderers. Simultaneously, support for Qt4 has been dropped. Both Qt6 and Qt5 are supported by a combined backend (QtAgg or QtCairo), and the loaded version is determined by modules already imported, the :envvar:`QT_API` environment variable, and available packages. See -:ref:`QT_API-usage` for details. The versioned Qt5 backend names (Qt5Agg or +:ref:`QT_bindings` for details. The versioned Qt5 backend names (Qt5Agg or Qt5Cairo) remain supported for backwards compatibility. .. _PyQt6: https://www.riverbankcomputing.com/static/Docs/PyQt6/ diff --git a/doc/users/prev_whats_new/whats_new_3.5.2.rst b/doc/users/prev_whats_new/whats_new_3.5.2.rst index c87258ac8210..85b1c38862eb 100644 --- a/doc/users/prev_whats_new/whats_new_3.5.2.rst +++ b/doc/users/prev_whats_new/whats_new_3.5.2.rst @@ -1,3 +1,16 @@ +============================================= +What's new in Matplotlib 3.5.2 (May 02, 2022) +============================================= + +For a list of all of the issues and pull requests since the last revision, see +the :ref:`github-stats`. + +.. contents:: Table of Contents + :depth: 4 + +.. toctree:: + :maxdepth: 4 + Windows on ARM support ---------------------- diff --git a/doc/users/prev_whats_new/whats_new_3.6.0.rst b/doc/users/prev_whats_new/whats_new_3.6.0.rst new file mode 100644 index 000000000000..b7bb958d67ad --- /dev/null +++ b/doc/users/prev_whats_new/whats_new_3.6.0.rst @@ -0,0 +1,872 @@ +============================================= +What's new in Matplotlib 3.6.0 (Sep 15, 2022) +============================================= + +For a list of all of the issues and pull requests since the last revision, see +the :ref:`github-stats`. + +.. contents:: Table of Contents + :depth: 4 + +.. toctree:: + :maxdepth: 4 + +Figure and Axes creation / management +===================================== + +``subplots``, ``subplot_mosaic`` accept *height_ratios* and *width_ratios* arguments +------------------------------------------------------------------------------------ + +The relative width and height of columns and rows in `~.Figure.subplots` and +`~.Figure.subplot_mosaic` can be controlled by passing *height_ratios* and +*width_ratios* keyword arguments to the methods: + +.. plot:: + :include-source: true + + fig = plt.figure() + axs = fig.subplots(3, 1, sharex=True, height_ratios=[3, 1, 1]) + +Previously, this required passing the ratios in *gridspec_kw* arguments:: + + fig = plt.figure() + axs = fig.subplots(3, 1, sharex=True, + gridspec_kw=dict(height_ratios=[3, 1, 1])) + +Constrained layout is no longer considered experimental +------------------------------------------------------- + +The constrained layout engine and API is no longer considered experimental. +Arbitrary changes to behaviour and API are no longer permitted without a +deprecation period. + +New ``layout_engine`` module +---------------------------- + +Matplotlib ships with ``tight_layout`` and ``constrained_layout`` layout +engines. A new `.layout_engine` module is provided to allow downstream +libraries to write their own layout engines and `~.figure.Figure` objects can +now take a `.LayoutEngine` subclass as an argument to the *layout* parameter. + +Compressed layout for fixed-aspect ratio Axes +--------------------------------------------- + +Simple arrangements of Axes with fixed aspect ratios can now be packed together +with ``fig, axs = plt.subplots(2, 3, layout='compressed')``. + +With ``layout='tight'`` or ``'constrained'``, Axes with a fixed aspect ratio +can leave large gaps between each other: + +.. plot:: + + fig, axs = plt.subplots(2, 2, figsize=(5, 3), + sharex=True, sharey=True, layout="constrained") + for ax in axs.flat: + ax.imshow([[0, 1], [2, 3]]) + fig.suptitle("fixed-aspect plots, layout='constrained'") + +Using the ``layout='compressed'`` layout reduces the space between the Axes, +and adds the extra space to the outer margins: + +.. plot:: + + fig, axs = plt.subplots(2, 2, figsize=(5, 3), + sharex=True, sharey=True, layout='compressed') + for ax in axs.flat: + ax.imshow([[0, 1], [2, 3]]) + fig.suptitle("fixed-aspect plots, layout='compressed'") + +See :ref:`compressed_layout` for further details. + +Layout engines may now be removed +--------------------------------- + +The layout engine on a Figure may now be removed by calling +`.Figure.set_layout_engine` with ``'none'``. This may be useful after computing +layout in order to reduce computations, e.g., for subsequent animation loops. + +A different layout engine may be set afterwards, so long as it is compatible +with the previous layout engine. + +``Axes.inset_axes`` flexibility +------------------------------- + +`matplotlib.axes.Axes.inset_axes` now accepts the *projection*, *polar* and +*axes_class* keyword arguments, so that subclasses of `matplotlib.axes.Axes` +may be returned. + +.. plot:: + :include-source: true + + fig, ax = plt.subplots() + + ax.plot([0, 2], [1, 2]) + + polar_ax = ax.inset_axes([0.75, 0.25, 0.2, 0.2], projection='polar') + polar_ax.plot([0, 2], [1, 2]) + +WebP is now a supported output format +------------------------------------- + +Figures may now be saved in WebP format by using the ``.webp`` file extension, +or passing ``format='webp'`` to `~.Figure.savefig`. This relies on `Pillow +`_ support for WebP. + +Garbage collection is no longer run on figure close +--------------------------------------------------- + +Matplotlib has a large number of circular references (between Figure and +Manager, between Axes and Figure, Axes and Artist, Figure and Canvas, etc.) so +when the user drops their last reference to a Figure (and clears it from +pyplot's state), the objects will not immediately be deleted. + +To account for this we have long (since before 2004) had a `gc.collect` (of the +lowest two generations only) in the closing code in order to promptly clean up +after ourselves. However this is both not doing what we want (as most of our +objects will actually survive) and due to clearing out the first generation +opened us up to having unbounded memory usage. + +In cases with a very tight loop between creating the figure and destroying it +(e.g. ``plt.figure(); plt.close()``) the first generation will never grow large +enough for Python to consider running the collection on the higher generations. +This will lead to unbounded memory usage as the long-lived objects are never +re-considered to look for reference cycles and hence are never deleted. + +We now no longer do any garbage collection when a figure is closed, and rely on +Python automatically deciding to run garbage collection periodically. If you +have strict memory requirements, you can call `gc.collect` yourself but this +may have performance impacts in a tight computation loop. + +Plotting methods +================ + +Striped lines (experimental) +---------------------------- + +The new *gapcolor* parameter to `~.Axes.plot` enables the creation of striped +lines. + +.. plot:: + :include-source: true + + x = np.linspace(1., 3., 10) + y = x**3 + + fig, ax = plt.subplots() + ax.plot(x, y, linestyle='--', color='orange', gapcolor='blue', + linewidth=3, label='a striped line') + ax.legend() + +Custom cap widths in box and whisker plots in ``bxp`` and ``boxplot`` +--------------------------------------------------------------------- + +The new *capwidths* parameter to `~.Axes.bxp` and `~.Axes.boxplot` allows +controlling the widths of the caps in box and whisker plots. + +.. plot:: + :include-source: true + + x = np.linspace(-7, 7, 140) + x = np.hstack([-25, x, 25]) + capwidths = [0.01, 0.2] + + fig, ax = plt.subplots() + ax.boxplot([x, x], notch=True, capwidths=capwidths) + ax.set_title(f'{capwidths=}') + +Easier labelling of bars in bar plot +------------------------------------ + +The *label* argument of `~.Axes.bar` and `~.Axes.barh` can now be passed a list +of labels for the bars. The list must be the same length as *x* and labels the +individual bars. Repeated labels are not de-duplicated and will cause repeated +label entries, so this is best used when bars also differ in style (e.g., by +passing a list to *color*, as below.) + +.. plot:: + :include-source: true + + x = ["a", "b", "c"] + y = [10, 20, 15] + color = ['C0', 'C1', 'C2'] + + fig, ax = plt.subplots() + ax.bar(x, y, color=color, label=x) + ax.legend() + +New style format string for colorbar ticks +------------------------------------------ + +The *format* argument of `~.Figure.colorbar` (and other colorbar methods) now +accepts ``{}``-style format strings. + +.. code-block:: python + + fig, ax = plt.subplots() + im = ax.imshow(z) + fig.colorbar(im, format='{x:.2e}') # Instead of '%.2e' + +Linestyles for negative contours may be set individually +-------------------------------------------------------- + +The line style of negative contours may be set by passing the +*negative_linestyles* argument to `.Axes.contour`. Previously, this style could +only be set globally via :rc:`contour.negative_linestyles`. + +.. plot:: + :include-source: true + + delta = 0.025 + x = np.arange(-3.0, 3.0, delta) + y = np.arange(-2.0, 2.0, delta) + X, Y = np.meshgrid(x, y) + Z1 = np.exp(-X**2 - Y**2) + Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) + Z = (Z1 - Z2) * 2 + + fig, axs = plt.subplots(1, 2) + + CS = axs[0].contour(X, Y, Z, 6, colors='k') + axs[0].clabel(CS, fontsize=9, inline=True) + axs[0].set_title('Default negative contours') + + CS = axs[1].contour(X, Y, Z, 6, colors='k', negative_linestyles='dotted') + axs[1].clabel(CS, fontsize=9, inline=True) + axs[1].set_title('Dotted negative contours') + +Improved quad contour calculations via ContourPy +------------------------------------------------ + +The contouring functions `~.axes.Axes.contour` and `~.axes.Axes.contourf` have +a new keyword argument *algorithm* to control which algorithm is used to +calculate the contours. There is a choice of four algorithms to use, and the +default is to use ``algorithm='mpl2014'`` which is the same algorithm that +Matplotlib has been using since 2014. + +Previously Matplotlib shipped its own C++ code for calculating the contours of +quad grids. Now the external library `ContourPy +`_ is used instead. + +Other possible values of the *algorithm* keyword argument at this time are +``'mpl2005'``, ``'serial'`` and ``'threaded'``; see the `ContourPy +documentation `_ for further details. + +.. note:: + + Contour lines and polygons produced by ``algorithm='mpl2014'`` will be the + same as those produced before this change to within floating-point + tolerance. The exception is for duplicate points, i.e. contours containing + adjacent (x, y) points that are identical; previously the duplicate points + were removed, now they are kept. Contours affected by this will produce the + same visual output, but there will be a greater number of points in the + contours. + + The locations of contour labels obtained by using `~.axes.Axes.clabel` may + also be different. + +``errorbar`` supports *markerfacecoloralt* +------------------------------------------ + +The *markerfacecoloralt* parameter is now passed to the line plotter from +`.Axes.errorbar`. The documentation now accurately lists which properties are +passed to `.Line2D`, rather than claiming that all keyword arguments are passed +on. + +.. plot:: + :include-source: true + + x = np.arange(0.1, 4, 0.5) + y = np.exp(-x) + + fig, ax = plt.subplots() + ax.errorbar(x, y, xerr=0.2, yerr=0.4, + linestyle=':', color='darkgrey', + marker='o', markersize=20, fillstyle='left', + markerfacecolor='tab:blue', markerfacecoloralt='tab:orange', + markeredgecolor='tab:brown', markeredgewidth=2) + +``streamplot`` can disable streamline breaks +-------------------------------------------- + +It is now possible to specify that streamplots have continuous, unbroken +streamlines. Previously streamlines would end to limit the number of lines +within a single grid cell. See the difference between the plots below: + +.. plot:: + + w = 3 + Y, X = np.mgrid[-w:w:100j, -w:w:100j] + U = -1 - X**2 + Y + V = 1 + X - Y**2 + speed = np.sqrt(U**2 + V**2) + + fig, (ax0, ax1) = plt.subplots(1, 2, sharex=True) + + ax0.streamplot(X, Y, U, V, broken_streamlines=True) + ax0.set_title('broken_streamlines=True') + + ax1.streamplot(X, Y, U, V, broken_streamlines=False) + ax1.set_title('broken_streamlines=False') + +New axis scale ``asinh`` (experimental) +--------------------------------------- + +The new ``asinh`` axis scale offers an alternative to ``symlog`` that smoothly +transitions between the quasi-linear and asymptotically logarithmic regions of +the scale. This is based on an arcsinh transformation that allows plotting both +positive and negative values that span many orders of magnitude. + +.. plot:: + + fig, (ax0, ax1) = plt.subplots(1, 2, sharex=True) + x = np.linspace(-3, 6, 100) + + ax0.plot(x, x) + ax0.set_yscale('symlog') + ax0.grid() + ax0.set_title('symlog') + + ax1.plot(x, x) + ax1.set_yscale('asinh') + ax1.grid() + ax1.set_title(r'$sinh^{-1}$') + + for p in (-2, 2): + for ax in (ax0, ax1): + c = plt.Circle((p, p), radius=0.5, fill=False, + color='red', alpha=0.8, lw=3) + ax.add_patch(c) + +``stairs(..., fill=True)`` hides patch edge by setting linewidth +---------------------------------------------------------------- + +``stairs(..., fill=True)`` would previously hide Patch edges by setting +``edgecolor="none"``. Consequently, calling ``set_color()`` on the Patch later +would make the Patch appear larger. + +Now, by using ``linewidth=0``, this apparent size change is prevented. Likewise +calling ``stairs(..., fill=True, linewidth=3)`` will behave more transparently. + +Fix the dash offset of the Patch class +-------------------------------------- + +Formerly, when setting the line style on a `.Patch` object using a dash tuple, +the offset was ignored. Now the offset is applied to the Patch as expected and +it can be used as it is used with `.Line2D` objects. + +Rectangle patch rotation point +------------------------------ + +The rotation point of the `~matplotlib.patches.Rectangle` can now be set to +'xy', 'center' or a 2-tuple of numbers using the *rotation_point* argument. + +.. plot:: + + fig, ax = plt.subplots() + + rect = plt.Rectangle((0, 0), 1, 1, facecolor='none', edgecolor='C0') + ax.add_patch(rect) + ax.annotate('Unrotated', (1, 0), color='C0', + horizontalalignment='right', verticalalignment='top', + xytext=(0, -3), textcoords='offset points') + + for rotation_point, color in zip(['xy', 'center', (0.75, 0.25)], + ['C1', 'C2', 'C3']): + ax.add_patch( + plt.Rectangle((0, 0), 1, 1, facecolor='none', edgecolor=color, + angle=45, rotation_point=rotation_point)) + + if rotation_point == 'center': + point = 0.5, 0.5 + elif rotation_point == 'xy': + point = 0, 0 + else: + point = rotation_point + ax.plot(point[:1], point[1:], color=color, marker='o') + + label = f'{rotation_point}' + if label == 'xy': + label += ' (default)' + ax.annotate(label, point, color=color, + xytext=(3, 3), textcoords='offset points') + + ax.set_aspect(1) + ax.set_title('rotation_point options') + +Colors and colormaps +==================== + +Color sequence registry +----------------------- + +The color sequence registry, `.ColorSequenceRegistry`, contains sequences +(i.e., simple lists) of colors that are known to Matplotlib by name. This will +not normally be used directly, but through the universal instance at +`matplotlib.color_sequences`. + +Colormap method for creating a different lookup table size +---------------------------------------------------------- + +The new method `.Colormap.resampled` creates a new `.Colormap` instance +with the specified lookup table size. This is a replacement for manipulating +the lookup table size via ``get_cmap``. + +Use:: + + get_cmap(name).resampled(N) + +instead of:: + + get_cmap(name, lut=N) + +Setting norms with strings +-------------------------- + +Norms can now be set (e.g. on images) using the string name of the +corresponding scale, e.g. ``imshow(array, norm="log")``. Note that in that +case, it is permissible to also pass *vmin* and *vmax*, as a new Norm instance +will be created under the hood. + +Titles, ticks, and labels +========================= + +``plt.xticks`` and ``plt.yticks`` support *minor* keyword argument +------------------------------------------------------------------ + +It is now possible to set or get minor ticks using `.pyplot.xticks` and +`.pyplot.yticks` by setting ``minor=True``. + +.. plot:: + :include-source: true + + plt.figure() + plt.plot([1, 2, 3, 3.5], [2, 1, 0, -0.5]) + plt.xticks([1, 2, 3], ["One", "Zwei", "Trois"]) + plt.xticks([np.sqrt(2), 2.5, np.pi], + [r"$\sqrt{2}$", r"$\frac{5}{2}$", r"$\pi$"], minor=True) + +Legends +======= + +Legend can control alignment of title and handles +------------------------------------------------- + +`.Legend` now supports controlling the alignment of the title and handles via +the keyword argument *alignment*. You can also use `.Legend.set_alignment` to +control the alignment on existing Legends. + +.. plot:: + :include-source: true + + fig, axs = plt.subplots(3, 1) + for i, alignment in enumerate(['left', 'center', 'right']): + axs[i].plot(range(10), label='test') + axs[i].legend(title=f'{alignment=}', alignment=alignment) + +*ncol* keyword argument to ``legend`` renamed to *ncols* +-------------------------------------------------------- + +The *ncol* keyword argument to `~.Axes.legend` for controlling the number of +columns is renamed to *ncols* for consistency with the *ncols* and *nrows* +keywords of `~.Figure.subplots` and `~.GridSpec`. *ncol* remains supported for +backwards compatibility, but is discouraged. + +Markers +======= + +``marker`` can now be set to the string "none" +---------------------------------------------- + +The string "none" means *no-marker*, consistent with other APIs which support +the lowercase version. Using "none" is recommended over using "None", to avoid +confusion with the None object. + +Customization of ``MarkerStyle`` join and cap style +--------------------------------------------------- + +New `.MarkerStyle` parameters allow control of join style and cap style, and +for the user to supply a transformation to be applied to the marker (e.g. a +rotation). + +.. plot:: + :include-source: true + + from matplotlib.markers import CapStyle, JoinStyle, MarkerStyle + from matplotlib.transforms import Affine2D + + fig, axs = plt.subplots(3, 1, layout='constrained') + for ax in axs: + ax.axis('off') + ax.set_xlim(-0.5, 2.5) + + axs[0].set_title('Cap styles', fontsize=14) + for col, cap in enumerate(CapStyle): + axs[0].plot(col, 0, markersize=32, markeredgewidth=8, + marker=MarkerStyle('1', capstyle=cap)) + # Show the marker edge for comparison with the cap. + axs[0].plot(col, 0, markersize=32, markeredgewidth=1, + markerfacecolor='none', markeredgecolor='lightgrey', + marker=MarkerStyle('1')) + axs[0].annotate(cap.name, (col, 0), + xytext=(20, -5), textcoords='offset points') + + axs[1].set_title('Join styles', fontsize=14) + for col, join in enumerate(JoinStyle): + axs[1].plot(col, 0, markersize=32, markeredgewidth=8, + marker=MarkerStyle('*', joinstyle=join)) + # Show the marker edge for comparison with the join. + axs[1].plot(col, 0, markersize=32, markeredgewidth=1, + markerfacecolor='none', markeredgecolor='lightgrey', + marker=MarkerStyle('*')) + axs[1].annotate(join.name, (col, 0), + xytext=(20, -5), textcoords='offset points') + + axs[2].set_title('Arbitrary transforms', fontsize=14) + for col, (size, rot) in enumerate(zip([2, 5, 7], [0, 45, 90])): + t = Affine2D().rotate_deg(rot).scale(size) + axs[2].plot(col, 0, marker=MarkerStyle('*', transform=t)) + +Fonts and Text +============== + +Font fallback +------------- + +It is now possible to specify a list of fonts families and Matplotlib will try +them in order to locate a required glyph. + +.. plot:: + :caption: Demonstration of mixed English and Chinese text with font fallback. + :alt: The phrase "There are 几个汉字 in between!" rendered in various fonts. + :include-source: True + + plt.rcParams["font.size"] = 20 + fig = plt.figure(figsize=(4.75, 1.85)) + + text = "There are 几个汉字 in between!" + fig.text(0.05, 0.85, text, family=["WenQuanYi Zen Hei"]) + fig.text(0.05, 0.65, text, family=["Noto Sans CJK JP"]) + fig.text(0.05, 0.45, text, family=["DejaVu Sans", "Noto Sans CJK JP"]) + fig.text(0.05, 0.25, text, family=["DejaVu Sans", "WenQuanYi Zen Hei"]) + +This currently works with the Agg (and all of the GUI embeddings), svg, pdf, +ps, and inline backends. + +List of available font names +---------------------------- + +The list of available fonts are now easily accessible. To get a list of the +available font names in Matplotlib use: + +.. code-block:: python + + from matplotlib import font_manager + font_manager.get_font_names() + +``math_to_image`` now has a *color* keyword argument +---------------------------------------------------- + +To easily support external libraries that rely on the MathText rendering of +Matplotlib to generate equation images, a *color* keyword argument was added to +`~matplotlib.mathtext.math_to_image`. + +.. code-block:: python + + from matplotlib import mathtext + mathtext.math_to_image('$x^2$', 'filename.png', color='Maroon') + +Active URL area rotates with link text +-------------------------------------- + +When link text is rotated in a figure, the active URL area will now include the +rotated link area. Previously, the active area remained in the original, +non-rotated, position. + +rcParams improvements +===================== + +Allow setting figure label size and weight globally and separately from title +----------------------------------------------------------------------------- + +For figure labels, ``Figure.supxlabel`` and ``Figure.supylabel``, the size and +weight can be set separately from the figure title using :rc:`figure.labelsize` +and :rc:`figure.labelweight`. + +.. plot:: + :include-source: true + + # Original (previously combined with below) rcParams: + plt.rcParams['figure.titlesize'] = 64 + plt.rcParams['figure.titleweight'] = 'bold' + + # New rcParams: + plt.rcParams['figure.labelsize'] = 32 + plt.rcParams['figure.labelweight'] = 'bold' + + fig, axs = plt.subplots(2, 2, layout='constrained') + for ax in axs.flat: + ax.set(xlabel='xlabel', ylabel='ylabel') + + fig.suptitle('suptitle') + fig.supxlabel('supxlabel') + fig.supylabel('supylabel') + +Note that if you have changed :rc:`figure.titlesize` or +:rc:`figure.titleweight`, you must now also change the introduced parameters +for a result consistent with past behaviour. + +Mathtext parsing can be disabled globally +----------------------------------------- + +The :rc:`text.parse_math` setting may be used to disable parsing of mathtext in +all `.Text` objects (most notably from the `.Axes.text` method). + +Double-quoted strings in matplotlibrc +------------------------------------- + +You can now use double-quotes around strings. This allows using the '#' +character in strings. Without quotes, '#' is interpreted as start of a comment. +In particular, you can now define hex-colors: + +.. code-block:: none + + grid.color: "#b0b0b0" + +3D Axes improvements +==================== + +Standardized views for primary plane viewing angles +--------------------------------------------------- + +When viewing a 3D plot in one of the primary view planes (i.e., perpendicular +to the XY, XZ, or YZ planes), the Axis will be displayed in a standard +location. For further information on 3D views, see +:ref:`toolkit_mplot3d-view-angles` and :doc:`/gallery/mplot3d/view_planes_3d`. + +Custom focal length for 3D camera +--------------------------------- + +The 3D Axes can now better mimic real-world cameras by specifying the focal +length of the virtual camera. The default focal length of 1 corresponds to a +Field of View (FOV) of 90°, and is backwards-compatible with existing 3D plots. +An increased focal length between 1 and infinity "flattens" the image, while a +decreased focal length between 1 and 0 exaggerates the perspective and gives +the image more apparent depth. + +The focal length can be calculated from a desired FOV via the equation: + +.. mathmpl:: + + focal\_length = 1/\tan(FOV/2) + +.. plot:: + :include-source: true + + from mpl_toolkits.mplot3d import axes3d + + X, Y, Z = axes3d.get_test_data(0.05) + + fig, axs = plt.subplots(1, 3, figsize=(7, 4), + subplot_kw={'projection': '3d'}) + + for ax, focal_length in zip(axs, [0.2, 1, np.inf]): + ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10) + ax.set_proj_type('persp', focal_length=focal_length) + ax.set_title(f"{focal_length=}") + +3D plots gained a 3rd "roll" viewing angle +------------------------------------------ + +3D plots can now be viewed from any orientation with the addition of a 3rd roll +angle, which rotates the plot about the viewing axis. Interactive rotation +using the mouse still only controls elevation and azimuth, meaning that this +feature is relevant to users who create more complex camera angles +programmatically. The default roll angle of 0 is backwards-compatible with +existing 3D plots. + +.. plot:: + :include-source: true + + from mpl_toolkits.mplot3d import axes3d + + X, Y, Z = axes3d.get_test_data(0.05) + + fig, ax = plt.subplots(subplot_kw={'projection': '3d'}) + + ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10) + ax.view_init(elev=0, azim=0, roll=30) + ax.set_title('elev=0, azim=0, roll=30') + +Equal aspect ratio for 3D plots +------------------------------- + +Users can set the aspect ratio for the X, Y, Z axes of a 3D plot to be 'equal', +'equalxy', 'equalxz', or 'equalyz' rather than the default of 'auto'. + +.. plot:: + :include-source: true + + from itertools import combinations, product + + aspects = [ + ['auto', 'equal', '.'], + ['equalxy', 'equalyz', 'equalxz'], + ] + fig, axs = plt.subplot_mosaic(aspects, figsize=(7, 6), + subplot_kw={'projection': '3d'}) + + # Draw rectangular cuboid with side lengths [1, 1, 5] + r = [0, 1] + scale = np.array([1, 1, 5]) + pts = combinations(np.array(list(product(r, r, r))), 2) + for start, end in pts: + if np.sum(np.abs(start - end)) == r[1] - r[0]: + for ax in axs.values(): + ax.plot3D(*zip(start*scale, end*scale), color='C0') + + # Set the aspect ratios + for aspect, ax in axs.items(): + ax.set_box_aspect((3, 4, 5)) + ax.set_aspect(aspect) + ax.set_title(f'set_aspect({aspect!r})') + +Interactive tool improvements +============================= + +Rotation, aspect ratio correction and add/remove state +------------------------------------------------------ + +The `.RectangleSelector` and `.EllipseSelector` can now be rotated +interactively between -45° and 45°. The range limits are currently dictated by +the implementation. The rotation is enabled or disabled by striking the *r* key +('r' is the default key mapped to 'rotate' in *state_modifier_keys*) or by +calling ``selector.add_state('rotate')``. + +The aspect ratio of the axes can now be taken into account when using the +"square" state. This is enabled by specifying ``use_data_coordinates='True'`` +when the selector is initialized. + +In addition to changing selector state interactively using the modifier keys +defined in *state_modifier_keys*, the selector state can now be changed +programmatically using the *add_state* and *remove_state* methods. + +.. code-block:: python + + from matplotlib.widgets import RectangleSelector + + values = np.arange(0, 100) + + fig = plt.figure() + ax = fig.add_subplot() + ax.plot(values, values) + + selector = RectangleSelector(ax, print, interactive=True, + drag_from_anywhere=True, + use_data_coordinates=True) + selector.add_state('rotate') # alternatively press 'r' key + # rotate the selector interactively + + selector.remove_state('rotate') # alternatively press 'r' key + + selector.add_state('square') + +``MultiCursor`` now supports Axes split over multiple figures +------------------------------------------------------------- + +Previously, `.MultiCursor` only worked if all target Axes belonged to the same +figure. + +As a consequence of this change, the first argument to the `.MultiCursor` +constructor has become unused (it was previously the joint canvas of all Axes, +but the canvases are now directly inferred from the list of Axes). + +``PolygonSelector`` bounding boxes +---------------------------------- + +`.PolygonSelector` now has a *draw_bounding_box* argument, which when set to +`True` will draw a bounding box around the polygon once it is complete. The +bounding box can be resized and moved, allowing the points of the polygon to be +easily resized. + +Setting ``PolygonSelector`` vertices +------------------------------------ + +The vertices of `.PolygonSelector` can now be set programmatically by using the +`.PolygonSelector.verts` property. Setting the vertices this way will reset the +selector, and create a new complete selector with the supplied vertices. + +``SpanSelector`` widget can now be snapped to specified values +-------------------------------------------------------------- + +The `.SpanSelector` widget can now be snapped to values specified by the +*snap_values* argument. + +More toolbar icons are styled for dark themes +--------------------------------------------- + +On the macOS and Tk backends, toolbar icons will now be inverted when using a +dark theme. + +Platform-specific changes +========================= + +Wx backend uses standard toolbar +-------------------------------- + +Instead of a custom sizer, the toolbar is set on Wx windows as a standard +toolbar. + +Improvements to macosx backend +------------------------------ + +Modifier keys handled more consistently +....................................... + +The macosx backend now handles modifier keys in a manner more consistent with +other backends. See the table in :ref:`event-connections` for further +information. + +``savefig.directory`` rcParam support +..................................... + +The macosx backend will now obey the :rc:`savefig.directory` setting. If set to +a non-empty string, then the save dialog will default to this directory, and +preserve subsequent save directories as they are changed. + +``figure.raise_window`` rcParam support +....................................... + +The macosx backend will now obey the :rc:`figure.raise_window` setting. If set +to False, figure windows will not be raised to the top on update. + +Full-screen toggle support +.......................... + +As supported on other backends, the macosx backend now supports toggling +fullscreen view. By default, this view can be toggled by pressing the :kbd:`f` +key. + +Improved animation and blitting support +....................................... + +The macosx backend has been improved to fix blitting, animation frames with new +artists, and to reduce unnecessary draw calls. + +macOS application icon applied on Qt backend +-------------------------------------------- + +When using the Qt-based backends on macOS, the application icon will now be +set, as is done on other backends/platforms. + +New minimum macOS version +------------------------- + +The macosx backend now requires macOS >= 10.12. + +Windows on ARM support +---------------------- + +Preliminary support for Windows on arm64 target has been added. This support +requires FreeType 2.11 or above. + +No binary wheels are available yet but it may be built from source. diff --git a/doc/users/prev_whats_new/whats_new_3.7.0.rst b/doc/users/prev_whats_new/whats_new_3.7.0.rst new file mode 100644 index 000000000000..af464da50969 --- /dev/null +++ b/doc/users/prev_whats_new/whats_new_3.7.0.rst @@ -0,0 +1,450 @@ +============================================= +What's new in Matplotlib 3.7.0 (Feb 13, 2023) +============================================= + +For a list of all of the issues and pull requests since the last revision, see +the :ref:`github-stats`. + +.. contents:: Table of Contents + :depth: 4 + +.. toctree:: + :maxdepth: 4 + +Plotting and Annotation improvements +==================================== + + +``hatch`` parameter for pie +--------------------------- + +`~matplotlib.axes.Axes.pie` now accepts a *hatch* keyword that takes as input +a hatch or list of hatches: + +.. plot:: + :include-source: true + :alt: Two pie charts, identified as ax1 and ax2, both have a small blue slice, a medium orange slice, and a large green slice. ax1 has a dot hatching on the small slice, a small open circle hatching on the medium slice, and a large open circle hatching on the large slice. ax2 has the same large open circle with a dot hatch on every slice. + + fig, (ax1, ax2) = plt.subplots(ncols=2) + x = [10, 30, 60] + + ax1.pie(x, hatch=['.', 'o', 'O']) + ax2.pie(x, hatch='.O') + + ax1.set_title("hatch=['.', 'o', 'O']") + ax2.set_title("hatch='.O'") + + +Polar plot errors drawn in polar coordinates +-------------------------------------------- +Caps and error lines are now drawn with respect to polar coordinates, +when plotting errorbars on polar plots. + +.. figure:: /gallery/pie_and_polar_charts/images/sphx_glr_polar_error_caps_001.png + :target: ../../gallery/pie_and_polar_charts/polar_error_caps.html + + + +Additional format string options in `~matplotlib.axes.Axes.bar_label` +--------------------------------------------------------------------- + +The ``fmt`` argument of `~matplotlib.axes.Axes.bar_label` now accepts +{}-style format strings: + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + + fruit_names = ['Coffee', 'Salted Caramel', 'Pistachio'] + fruit_counts = [4000, 2000, 7000] + + fig, ax = plt.subplots() + bar_container = ax.bar(fruit_names, fruit_counts) + ax.set(ylabel='pints sold', title='Gelato sales by flavor', ylim=(0, 8000)) + ax.bar_label(bar_container, fmt='{:,.0f}') + +It also accepts callables: + +.. plot:: + :include-source: true + + animal_names = ['Lion', 'Gazelle', 'Cheetah'] + mph_speed = [50, 60, 75] + + fig, ax = plt.subplots() + bar_container = ax.bar(animal_names, mph_speed) + ax.set(ylabel='speed in MPH', title='Running speeds', ylim=(0, 80)) + ax.bar_label( + bar_container, fmt=lambda x: '{:.1f} km/h'.format(x * 1.61) + ) + + + +``ellipse`` boxstyle option for annotations +------------------------------------------- + +The ``'ellipse'`` option for boxstyle can now be used to create annotations +with an elliptical outline. It can be used as a closed curve shape for +longer texts instead of the ``'circle'`` boxstyle which can get quite big. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + fig, ax = plt.subplots(figsize=(5, 5)) + t = ax.text(0.5, 0.5, "elliptical box", + ha="center", size=15, + bbox=dict(boxstyle="ellipse,pad=0.3")) + + +The *extent* of ``imshow`` can now be expressed with units +---------------------------------------------------------- +The *extent* parameter of `~.axes.Axes.imshow` and `~.AxesImage.set_extent` +can now be expressed with units. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + import numpy as np + + fig, ax = plt.subplots(layout='constrained') + date_first = np.datetime64('2020-01-01', 'D') + date_last = np.datetime64('2020-01-11', 'D') + + arr = [[i+j for i in range(10)] for j in range(10)] + + ax.imshow(arr, origin='lower', extent=[0, 10, date_first, date_last]) + + plt.show() + +Reversed order of legend entries +-------------------------------- +The order of legend entries can now be reversed by passing ``reverse=True`` to +`~.Axes.legend`. + + +``pcolormesh`` accepts RGB(A) colors +------------------------------------ + +The `~.Axes.pcolormesh` method can now handle explicit colors +specified with RGB(A) values. To specify colors, the array must be 3D +with a shape of ``(M, N, [3, 4])``. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + import numpy as np + + colors = np.linspace(0, 1, 90).reshape((5, 6, 3)) + plt.pcolormesh(colors) + plt.show() + + + + +View current appearance settings for ticks, tick labels, and gridlines +---------------------------------------------------------------------- + +The new `~matplotlib.axis.Axis.get_tick_params` method can be used to +retrieve the appearance settings that will be applied to any +additional ticks, tick labels, and gridlines added to the plot: + +.. code-block:: pycon + + >>> import matplotlib.pyplot as plt + + >>> fig, ax = plt.subplots() + >>> ax.yaxis.set_tick_params(labelsize=30, labelcolor='red', + ... direction='out', which='major') + >>> ax.yaxis.get_tick_params(which='major') + {'direction': 'out', + 'left': True, + 'right': False, + 'labelleft': True, + 'labelright': False, + 'gridOn': False, + 'labelsize': 30, + 'labelcolor': 'red'} + >>> ax.yaxis.get_tick_params(which='minor') + {'left': True, + 'right': False, + 'labelleft': True, + 'labelright': False, + 'gridOn': False} + + + +Style files can be imported from third-party packages +----------------------------------------------------- + +Third-party packages can now distribute style files that are globally available +as follows. Assume that a package is importable as ``import mypackage``, with +a ``mypackage/__init__.py`` module. Then a ``mypackage/presentation.mplstyle`` +style sheet can be used as ``plt.style.use("mypackage.presentation")``. + +The implementation does not actually import ``mypackage``, making this process +safe against possible import-time side effects. Subpackages (e.g. +``dotted.package.name``) are also supported. + + +Improvements to 3D Plotting +=========================== + + +3D plot pan and zoom buttons +---------------------------- + +The pan and zoom buttons in the toolbar of 3D plots are now enabled. +Unselect both to rotate the plot. When the zoom button is pressed, +zoom in by using the left mouse button to draw a bounding box, and +out by using the right mouse button to draw the box. When zooming a +3D plot, the current view aspect ratios are kept fixed. + + +*adjustable* keyword argument for setting equal aspect ratios in 3D +------------------------------------------------------------------- + +While setting equal aspect ratios for 3D plots, users can choose to modify +either the data limits or the bounding box in parity with 2D Axes. + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + import numpy as np + from itertools import combinations, product + + aspects = ('auto', 'equal', 'equalxy', 'equalyz', 'equalxz') + fig, axs = plt.subplots(1, len(aspects), subplot_kw={'projection': '3d'}, + figsize=(12, 6)) + + # Draw rectangular cuboid with side lengths [4, 3, 5] + r = [0, 1] + scale = np.array([4, 3, 5]) + pts = combinations(np.array(list(product(r, r, r))), 2) + for start, end in pts: + if np.sum(np.abs(start - end)) == r[1] - r[0]: + for ax in axs: + ax.plot3D(*zip(start*scale, end*scale), color='C0') + + # Set the aspect ratios + for i, ax in enumerate(axs): + ax.set_aspect(aspects[i], adjustable='datalim') + # Alternatively: ax.set_aspect(aspects[i], adjustable='box') + # which will change the box aspect ratio instead of axis data limits. + ax.set_title(f"set_aspect('{aspects[i]}')") + + plt.show() + + +``Poly3DCollection`` supports shading +------------------------------------- + +It is now possible to shade a `.Poly3DCollection`. This is useful if the +polygons are obtained from e.g. a 3D model. + +.. plot:: + :include-source: true + + import numpy as np + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + # Define 3D shape + block = np.array([ + [[1, 1, 0], + [1, 0, 0], + [0, 1, 0]], + [[1, 1, 0], + [1, 1, 1], + [1, 0, 0]], + [[1, 1, 0], + [1, 1, 1], + [0, 1, 0]], + [[1, 0, 0], + [1, 1, 1], + [0, 1, 0]] + ]) + + ax = plt.subplot(projection='3d') + pc = Poly3DCollection(block, facecolors='b', shade=True) + ax.add_collection(pc) + plt.show() + + + +rcParam for 3D pane color +------------------------- + +The rcParams :rc:`axes3d.xaxis.panecolor`, :rc:`axes3d.yaxis.panecolor`, +:rc:`axes3d.zaxis.panecolor` can be used to change the color of the background +panes in 3D plots. Note that it is often beneficial to give them slightly +different shades to obtain a "3D effect" and to make them slightly transparent +(alpha < 1). + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + with plt.rc_context({'axes3d.xaxis.panecolor': (0.9, 0.0, 0.0, 0.5), + 'axes3d.yaxis.panecolor': (0.7, 0.0, 0.0, 0.5), + 'axes3d.zaxis.panecolor': (0.8, 0.0, 0.0, 0.5)}): + fig = plt.figure() + fig.add_subplot(projection='3d') + + + + +Figure and Axes Layout +====================== + +``colorbar`` now has a *location* keyword argument +-------------------------------------------------- + +The ``colorbar`` method now supports a *location* keyword argument to more +easily position the color bar. This is useful when providing your own inset +axes using the *cax* keyword argument and behaves similar to the case where +axes are not provided (where the *location* keyword is passed through). +*orientation* and *ticklocation* are no longer required as they are +determined by *location*. *ticklocation* can still be provided if the +automatic setting is not preferred. (*orientation* can also be provided but +must be compatible with the *location*.) + +An example is: + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + import numpy as np + rng = np.random.default_rng(19680801) + imdata = rng.random((10, 10)) + fig, ax = plt.subplots(layout='constrained') + im = ax.imshow(imdata) + fig.colorbar(im, cax=ax.inset_axes([0, 1.05, 1, 0.05]), + location='top') + + + +Figure legends can be placed outside figures using constrained_layout +--------------------------------------------------------------------- +Constrained layout will make space for Figure legends if they are specified +by a *loc* keyword argument that starts with the string "outside". The +codes are unique from axes codes, in that "outside upper right" will +make room at the top of the figure for the legend, whereas +"outside right upper" will make room on the right-hand side of the figure. +See :doc:`/tutorials/intermediate/legend_guide` for details. + + +Per-subplot keyword arguments in ``subplot_mosaic`` +---------------------------------------------------- + +It is now possible to pass keyword arguments through to Axes creation in each +specific call to ``add_subplot`` in `.Figure.subplot_mosaic` and +`.pyplot.subplot_mosaic` : + +.. plot:: + :include-source: true + + fig, axd = plt.subplot_mosaic( + "AB;CD", + per_subplot_kw={ + "A": {"projection": "polar"}, + ("C", "D"): {"xscale": "log"}, + "B": {"projection": "3d"}, + }, + ) + + +This is particularly useful for creating mosaics with mixed projections, but +any keyword arguments can be passed through. + + +``subplot_mosaic`` no longer provisional +---------------------------------------- + +The API on `.Figure.subplot_mosaic` and `.pyplot.subplot_mosaic` are now +considered stable and will change under Matplotlib's normal deprecation +process. + + +Widget Improvements +=================== + + +Custom styling of button widgets +-------------------------------- + +Additional custom styling of button widgets may be achieved via the +*label_props* and *radio_props* arguments to `.RadioButtons`; and the +*label_props*, *frame_props*, and *check_props* arguments to `.CheckButtons`. + +.. plot:: + + from matplotlib.widgets import CheckButtons, RadioButtons + + fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(5, 2), width_ratios=[1, 2]) + default_rb = RadioButtons(ax[0, 0], ['Apples', 'Oranges']) + styled_rb = RadioButtons(ax[0, 1], ['Apples', 'Oranges'], + label_props={'color': ['red', 'orange'], + 'fontsize': [16, 20]}, + radio_props={'edgecolor': ['red', 'orange'], + 'facecolor': ['mistyrose', 'peachpuff']}) + + default_cb = CheckButtons(ax[1, 0], ['Apples', 'Oranges'], + actives=[True, True]) + styled_cb = CheckButtons(ax[1, 1], ['Apples', 'Oranges'], + actives=[True, True], + label_props={'color': ['red', 'orange'], + 'fontsize': [16, 20]}, + frame_props={'edgecolor': ['red', 'orange'], + 'facecolor': ['mistyrose', 'peachpuff']}, + check_props={'color': ['darkred', 'darkorange']}) + + ax[0, 0].set_title('Default') + ax[0, 1].set_title('Stylized') + + +Blitting in Button widgets +-------------------------- + +The `.Button`, `.CheckButtons`, and `.RadioButtons` widgets now support +blitting for faster rendering, on backends that support it, by passing +``useblit=True`` to the constructor. Blitting is enabled by default on +supported backends. + + +Other Improvements +================== + + +Source links can be shown or hidden for each Sphinx plot directive +------------------------------------------------------------------ +The :doc:`Sphinx plot directive ` +(``.. plot::``) now supports a ``:show-source-link:`` option to show or hide +the link to the source code for each plot. The default is set using the +``plot_html_show_source_link`` variable in :file:`conf.py` (which +defaults to True). + + + +Figure hooks +------------ + +The new :rc:`figure.hooks` provides a mechanism to register +arbitrary customizations on pyplot figures; it is a list of +"dotted.module.name:dotted.callable.name" strings specifying functions +that are called on each figure created by `.pyplot.figure`; these +functions can e.g. attach callbacks or modify the toolbar. See +:doc:`/gallery/user_interfaces/mplcvd` for an example of toolbar customization. + + +New & Improved Narrative Documentation +====================================== +* Brand new :doc:`Animations ` tutorial. +* New grouped and stacked `bar chart <../../gallery/index.html#lines_bars_and_markers>`_ examples. +* New section for new contributors and reorganized git instructions in the :ref:`contributing guide`. +* Restructured :doc:`/tutorials/text/annotations` tutorial. diff --git a/doc/users/project/citing.rst b/doc/users/project/citing.rst index ac1a80f2fb28..74f9fe13b56f 100644 --- a/doc/users/project/citing.rst +++ b/doc/users/project/citing.rst @@ -29,6 +29,24 @@ By version .. START OF AUTOGENERATED +v3.6.3 + .. image:: ../../_static/zenodo_cache/7527665.svg + :target: https://doi.org/10.5281/zenodo.7527665 +v3.6.2 + .. image:: ../../_static/zenodo_cache/7275322.svg + :target: https://doi.org/10.5281/zenodo.7275322 +v3.6.1 + .. image:: ../../_static/zenodo_cache/7162185.svg + :target: https://doi.org/10.5281/zenodo.7162185 +v3.6.0 + .. image:: ../../_static/zenodo_cache/7084615.svg + :target: https://doi.org/10.5281/zenodo.7084615 +v3.5.3 + .. image:: ../../_static/zenodo_cache/6982547.svg + :target: https://doi.org/10.5281/zenodo.6982547 +v3.5.2 + .. image:: ../../_static/zenodo_cache/6513224.svg + :target: https://doi.org/10.5281/zenodo.6513224 v3.5.1 .. image:: ../../_static/zenodo_cache/5773480.svg :target: https://doi.org/10.5281/zenodo.5773480 diff --git a/doc/users/project/history.rst b/doc/users/project/history.rst index 8725e0773522..3291d7e43f8b 100644 --- a/doc/users/project/history.rst +++ b/doc/users/project/history.rst @@ -1,5 +1,7 @@ .. redirect-from:: /users/history +.. _project_history: + History ======= @@ -11,7 +13,7 @@ History Matplotlib is a library for making 2D plots of arrays in `Python `_. Although it has its origins in emulating the MATLAB graphics commands, it is -independent of MATLAB, and can be used in a Pythonic, object oriented +independent of MATLAB, and can be used in a Pythonic, object-oriented way. Although Matplotlib is written primarily in pure Python, it makes heavy use of `NumPy `_ and other extension code to provide good performance even for large arrays. diff --git a/doc/users/project/index.rst b/doc/users/project/index.rst index 132c0905b23c..27f60d166abb 100644 --- a/doc/users/project/index.rst +++ b/doc/users/project/index.rst @@ -6,7 +6,8 @@ Project information .. toctree:: :maxdepth: 2 - license.rst + mission.rst + history.rst citing.rst + license.rst credits.rst - history.rst diff --git a/doc/users/project/mission.rst b/doc/users/project/mission.rst new file mode 100644 index 000000000000..431da04e4891 --- /dev/null +++ b/doc/users/project/mission.rst @@ -0,0 +1,21 @@ +Mission Statement +================= + +The Matplotlib developer community develops, maintains, and supports Matplotlib +and its extensions to provide data visualization tools for the Scientific +Python Ecosystem. + +Adapting the requirements :ref:`laid out by John Hunter ` +Matplotlib should: + +* Support users of the Scientific Python ecosystem; +* Facilitate interactive data exploration; +* Produce high-quality raster and vector format outputs suitable for publication; +* Provide a simple graphical user interface and support embedding in applications; +* Be understandable and extensible by people familiar with data processing in Python; +* Make common plots easy, and novel or complex visualizations possible. + +We believe that a diverse developer community creates the best software, and we +welcome anyone who shares our mission, and our values described in the `code of +conduct +`__. diff --git a/doc/users/release_notes.rst b/doc/users/release_notes.rst index 5f61fe78df48..cf41d6de8a06 100644 --- a/doc/users/release_notes.rst +++ b/doc/users/release_notes.rst @@ -7,7 +7,31 @@ Release notes ============= .. include from another document so that it's easy to exclude this for releases - .. include:: release_notes_next.rst +.. include:: release_notes_next.rst + + +Version 3.7 +=========== +.. toctree:: + :maxdepth: 1 + + prev_whats_new/whats_new_3.7.0.rst + ../api/prev_api_changes/api_changes_3.7.0.rst + github_stats.rst + + +Version 3.6 +=========== +.. toctree:: + :maxdepth: 1 + + prev_whats_new/whats_new_3.6.0.rst + ../api/prev_api_changes/api_changes_3.6.1.rst + ../api/prev_api_changes/api_changes_3.6.0.rst + prev_whats_new/github_stats_3.6.3.rst + prev_whats_new/github_stats_3.6.2.rst + prev_whats_new/github_stats_3.6.1.rst + prev_whats_new/github_stats_3.6.0.rst Version 3.5 =========== @@ -16,9 +40,11 @@ Version 3.5 prev_whats_new/whats_new_3.5.2.rst prev_whats_new/whats_new_3.5.0.rst + ../api/prev_api_changes/api_changes_3.5.3.rst ../api/prev_api_changes/api_changes_3.5.2.rst ../api/prev_api_changes/api_changes_3.5.0.rst - github_stats.rst + prev_whats_new/github_stats_3.5.3.rst + prev_whats_new/github_stats_3.5.2.rst prev_whats_new/github_stats_3.5.1.rst prev_whats_new/github_stats_3.5.0.rst diff --git a/doc/users/resources/index.rst b/doc/users/resources/index.rst index bd83759766fb..a2c4ccd4a7fc 100644 --- a/doc/users/resources/index.rst +++ b/doc/users/resources/index.rst @@ -11,6 +11,10 @@ External resources Books, chapters and articles ============================ +* `Scientific Visualization: Python + Matplotlib (2021) + `_ + by Nicolas P. Rougier + * `Mastering matplotlib `_ by Duncan M. McGreggor @@ -70,10 +74,14 @@ Tutorials * `The Python Graph Gallery `_ by Yan Holtz - + * `Matplotlib tutorial `_ by Nicolas P. Rougier * `Anatomy of Matplotlib - IPython Notebooks `_ by Benjamin Root + +* `Beyond the Basics: Data Visualization in Python + `_ + by Stefanie Molin diff --git a/environment.yml b/environment.yml index d153f969362a..22cf6796ac5e 100644 --- a/environment.yml +++ b/environment.yml @@ -8,12 +8,16 @@ name: mpl-dev channels: - conda-forge dependencies: + # runtime dependencies - cairocffi + - contourpy>=1.0.1 - cycler>=0.10.0 - fonttools>=4.22.0 + - importlib-resources>=3.2.0 - kiwisolver>=1.0.1 - - numpy>=1.17 + - numpy>=1.20 - pillow>=6.2 + - pybind11>=2.6.0 - pygobject - pyparsing - pyqt @@ -29,26 +33,26 @@ dependencies: - numpydoc>=0.8 - packaging - pydata-sphinx-theme - - scipy - sphinx>=1.8.1,!=2.0.0 - sphinx-copybutton - sphinx-gallery>=0.10 - - sphinx-panels + - sphinx-design - pip - pip: - mpl-sphinx-theme - sphinxcontrib-svg2pdfconverter + - pikepdf # testing - coverage - flake8>=3.8 - flake8-docstrings>=1.4.0 - - gtk3 + - gtk4 - ipykernel - nbconvert[execute]!=6.0.0,!=6.0.1 - nbformat!=5.0.0,!=5.0.1 - pandas!=0.25.0 - - pikepdf - psutil + - pre-commit - pydocstyle>=5.1.0 - pytest!=4.6.0,!=5.4.0 - pytest-cov diff --git a/examples/README.txt b/examples/README.txt index 87098fb881ce..8e41fb83d300 100644 --- a/examples/README.txt +++ b/examples/README.txt @@ -9,6 +9,6 @@ Examples This page contains example plots. Click on any image to see the full image and source code. -For longer tutorials, see our `tutorials page <../tutorials/index.html>`_. -You can also find `external resources <../users/resources/index.html>`_ and -a `FAQ <../users/faq/index.html>`_ in our `user guide <../users/index.html>`_. +For longer tutorials, see our :ref:`tutorials page `. +You can also find :ref:`external resources ` and +a :ref:`FAQ ` in our :ref:`user guide `. diff --git a/examples/animation/animate_decay.py b/examples/animation/animate_decay.py index c4e8eded4e6e..cfd95e4e5c29 100644 --- a/examples/animation/animate_decay.py +++ b/examples/animation/animate_decay.py @@ -7,6 +7,8 @@ - using a generator to drive an animation, - changing axes limits during an animation. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import itertools @@ -24,7 +26,7 @@ def data_gen(): def init(): ax.set_ylim(-1.1, 1.1) - ax.set_xlim(0, 10) + ax.set_xlim(0, 1) del xdata[:] del ydata[:] line.set_data(xdata, ydata) @@ -50,5 +52,7 @@ def run(data): return line, -ani = animation.FuncAnimation(fig, run, data_gen, interval=10, init_func=init) +# Only save last 100 frames, but run forever +ani = animation.FuncAnimation(fig, run, data_gen, interval=100, init_func=init, + save_count=100) plt.show() diff --git a/examples/animation/animated_histogram.py b/examples/animation/animated_histogram.py index 737d4c9a3833..3b242c4ce4cc 100644 --- a/examples/animation/animated_histogram.py +++ b/examples/animation/animated_histogram.py @@ -45,6 +45,8 @@ def animate(frame_number): # ``prepare_animation`` will define ``animate`` function working with supplied # `.BarContainer`, all this is used to setup `.FuncAnimation`. +# Output generated via `matplotlib.animation.Animation.to_jshtml`. + fig, ax = plt.subplots() _, _, bar_container = ax.hist(data, HIST_BINS, lw=1, ec="yellow", fc="green", alpha=0.5) diff --git a/examples/animation/animation_demo.py b/examples/animation/animation_demo.py index beab2801d731..d2de7c43e7b5 100644 --- a/examples/animation/animation_demo.py +++ b/examples/animation/animation_demo.py @@ -10,6 +10,8 @@ examples that use it. Note that calling `time.sleep` instead of `~.pyplot.pause` would *not* work. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import matplotlib.pyplot as plt @@ -20,9 +22,9 @@ fig, ax = plt.subplots() -for i in range(len(data)): - ax.cla() - ax.imshow(data[i]) - ax.set_title("frame {}".format(i)) +for i, img in enumerate(data): + ax.clear() + ax.imshow(img) + ax.set_title(f"frame {i}") # Note that using time.sleep does *not* work here! plt.pause(0.1) diff --git a/examples/animation/bayes_update.py b/examples/animation/bayes_update.py index d1c43c71fa31..1b56b74f656a 100644 --- a/examples/animation/bayes_update.py +++ b/examples/animation/bayes_update.py @@ -7,6 +7,8 @@ new data arrives. The vertical line represents the theoretical value to which the plotted distribution should converge. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import math @@ -47,7 +49,7 @@ def __call__(self, i): return self.line, # Choose success based on exceed a threshold with a uniform pick - if np.random.rand(1,) < self.prob: + if np.random.rand() < self.prob: self.success += 1 y = beta_pdf(self.x, self.success + 1, (i - self.success) + 1) self.line.set_data(self.x, y) diff --git a/examples/animation/double_pendulum.py b/examples/animation/double_pendulum.py index 8f50f6cb195d..8f055be79286 100644 --- a/examples/animation/double_pendulum.py +++ b/examples/animation/double_pendulum.py @@ -7,12 +7,13 @@ Double pendulum formula translated from the C code at http://www.physics.usyd.edu.au/~wheat/dpend_html/solve_dpend.c + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ from numpy import sin, cos import numpy as np import matplotlib.pyplot as plt -import scipy.integrate as integrate import matplotlib.animation as animation from collections import deque @@ -22,13 +23,13 @@ L = L1 + L2 # maximal length of the combined pendulum M1 = 1.0 # mass of pendulum 1 in kg M2 = 1.0 # mass of pendulum 2 in kg -t_stop = 5 # how many seconds to simulate +t_stop = 2.5 # how many seconds to simulate history_len = 500 # how many trajectory points to display -def derivs(state, t): - +def derivs(t, state): dydx = np.zeros_like(state) + dydx[0] = state[1] delta = state[2] - state[0] @@ -51,7 +52,7 @@ def derivs(state, t): return dydx # create a time array from 0..t_stop sampled at 0.02 second steps -dt = 0.02 +dt = 0.01 t = np.arange(0, t_stop, dt) # th1 and th2 are the initial angles (degrees) @@ -64,8 +65,15 @@ def derivs(state, t): # initial state state = np.radians([th1, w1, th2, w2]) -# integrate your ODE using scipy.integrate. -y = integrate.odeint(derivs, state, t) +# integrate the ODE using Euler's method +y = np.empty((len(t), 4)) +y[0] = state +for i in range(1, len(t)): + y[i] = y[i - 1] + derivs(t[i - 1], y[i - 1]) * dt + +# A more accurate estimate could be obtained e.g. using scipy: +# +# y = scipy.integrate.solve_ivp(derivs, t[[0, -1]], state, t_eval=t).y.T x1 = L1*sin(y[:, 0]) y1 = -L1*cos(y[:, 0]) diff --git a/examples/animation/dynamic_image.py b/examples/animation/dynamic_image.py index 5543da039639..d74e8bc8c1a9 100644 --- a/examples/animation/dynamic_image.py +++ b/examples/animation/dynamic_image.py @@ -3,6 +3,7 @@ Animated image using a precomputed list of images ================================================= +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np @@ -23,8 +24,8 @@ def f(x, y): # each frame ims = [] for i in range(60): - x += np.pi / 15. - y += np.pi / 20. + x += np.pi / 15 + y += np.pi / 30 im = ax.imshow(f(x, y), animated=True) if i == 0: ax.imshow(f(x, y)) # show an initial one first diff --git a/examples/animation/frame_grabbing_sgskip.py b/examples/animation/frame_grabbing_sgskip.py index e74576249aa0..3a3d5d39c04d 100644 --- a/examples/animation/frame_grabbing_sgskip.py +++ b/examples/animation/frame_grabbing_sgskip.py @@ -6,6 +6,8 @@ Use a MovieWriter directly to grab individual frames and write them to a file. This avoids any event loop integration, and thus works even with the Agg backend. This is not recommended for use in an interactive setting. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np diff --git a/examples/animation/multiple_axes.py b/examples/animation/multiple_axes.py new file mode 100644 index 000000000000..f3c246d151ac --- /dev/null +++ b/examples/animation/multiple_axes.py @@ -0,0 +1,81 @@ +""" +======================= +Multiple axes animation +======================= + +This example showcases: + +- how animation across multiple subplots works, +- using a figure artist in the animation. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. +""" + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.animation as animation +from matplotlib.patches import ConnectionPatch + +fig, (axl, axr) = plt.subplots( + ncols=2, + sharey=True, + figsize=(6, 2), + gridspec_kw=dict(width_ratios=[1, 3], wspace=0), +) +axl.set_aspect(1) +axr.set_box_aspect(1 / 3) +axr.yaxis.set_visible(False) +axr.xaxis.set_ticks([0, np.pi, 2 * np.pi], ["0", r"$\pi$", r"$2\pi$"]) + +# draw circle with initial point in left Axes +x = np.linspace(0, 2 * np.pi, 50) +axl.plot(np.cos(x), np.sin(x), "k", lw=0.3) +point, = axl.plot(0, 0, "o") + +# draw full curve to set view limits in right Axes +sine, = axr.plot(x, np.sin(x)) + +# draw connecting line between both graphs +con = ConnectionPatch( + (1, 0), + (0, 0), + "data", + "data", + axesA=axl, + axesB=axr, + color="C0", + ls="dotted", +) +fig.add_artist(con) + + +def animate(i): + x = np.linspace(0, i, int(i * 25 / np.pi)) + sine.set_data(x, np.sin(x)) + x, y = np.cos(i), np.sin(i) + point.set_data([x], [y]) + con.xy1 = x, y + con.xy2 = i, y + return point, sine, con + + +ani = animation.FuncAnimation( + fig, + animate, + interval=50, + blit=False, # blitting can't be used with Figure artists + frames=x, + repeat_delay=100, +) + +plt.show() + +############################################################################# +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.patches.ConnectionPatch` +# - `matplotlib.animation.FuncAnimation` diff --git a/examples/animation/pause_resume.py b/examples/animation/pause_resume.py index 0a8c2f7c549a..4d6f281a7e0f 100644 --- a/examples/animation/pause_resume.py +++ b/examples/animation/pause_resume.py @@ -7,6 +7,16 @@ - using the Animation.pause() method to pause an animation. - using the Animation.resume() method to resume an animation. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import matplotlib.pyplot as plt diff --git a/examples/animation/rain.py b/examples/animation/rain.py index 270a6a0787af..e5c2a467dbc0 100644 --- a/examples/animation/rain.py +++ b/examples/animation/rain.py @@ -7,6 +7,8 @@ of 50 scatter points. Author: Nicolas P. Rougier + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np @@ -67,5 +69,5 @@ def update(frame_number): # Construct the animation, using the update function as the animation director. -animation = FuncAnimation(fig, update, interval=10) +animation = FuncAnimation(fig, update, interval=10, save_count=100) plt.show() diff --git a/examples/animation/random_walk.py b/examples/animation/random_walk.py index f49c099a5c2d..83cd530f6b42 100644 --- a/examples/animation/random_walk.py +++ b/examples/animation/random_walk.py @@ -3,6 +3,7 @@ Animated 3D random walk ======================= +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np diff --git a/examples/animation/simple_anim.py b/examples/animation/simple_anim.py index 3bb3c4d952ba..54480bc4ae6e 100644 --- a/examples/animation/simple_anim.py +++ b/examples/animation/simple_anim.py @@ -3,6 +3,7 @@ Animated line plot ================== +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np diff --git a/examples/animation/simple_scatter.py b/examples/animation/simple_scatter.py new file mode 100644 index 000000000000..a0583c81e740 --- /dev/null +++ b/examples/animation/simple_scatter.py @@ -0,0 +1,32 @@ +""" +============================= +Animated scatter saved as GIF +============================= + +Output generated via `matplotlib.animation.Animation.to_jshtml`. +""" +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.animation as animation + +fig, ax = plt.subplots() +ax.set_xlim([0, 10]) + +scat = ax.scatter(1, 0) +x = np.linspace(0, 10) + + +def animate(i): + scat.set_offsets((x[i], 0)) + return scat, + +ani = animation.FuncAnimation(fig, animate, repeat=True, + frames=len(x) - 1, interval=50) + +# To save the animation using Pillow as a gif +# writer = animation.PillowWriter(fps=15, +# metadata=dict(artist='Me'), +# bitrate=1800) +# ani.save('scatter.gif', writer=writer) + +plt.show() diff --git a/examples/animation/strip_chart.py b/examples/animation/strip_chart.py index 5e3b8fea0d04..9467ed3249b5 100644 --- a/examples/animation/strip_chart.py +++ b/examples/animation/strip_chart.py @@ -4,6 +4,8 @@ ============ Emulates an oscilloscope. + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np @@ -26,13 +28,16 @@ def __init__(self, ax, maxt=2, dt=0.02): def update(self, y): lastt = self.tdata[-1] - if lastt > self.tdata[0] + self.maxt: # reset the arrays + if lastt >= self.tdata[0] + self.maxt: # reset the arrays self.tdata = [self.tdata[-1]] self.ydata = [self.ydata[-1]] self.ax.set_xlim(self.tdata[0], self.tdata[0] + self.maxt) self.ax.figure.canvas.draw() - t = self.tdata[-1] + self.dt + # This slightly more complex calculation avoids floating-point issues + # from just repeatedly adding `self.dt` to the previous value. + t = self.tdata[0] + len(self.tdata) * self.dt + self.tdata.append(t) self.ydata.append(y) self.line.set_data(self.tdata, self.ydata) @@ -42,11 +47,11 @@ def update(self, y): def emitter(p=0.1): """Return a random value in [0, 1) with probability p, else 0.""" while True: - v = np.random.rand(1) + v = np.random.rand() if v > p: yield 0. else: - yield np.random.rand(1) + yield np.random.rand() # Fixing random state for reproducibility @@ -58,6 +63,6 @@ def emitter(p=0.1): # pass a generator in "emitter" to produce data for the update func ani = animation.FuncAnimation(fig, scope.update, emitter, interval=50, - blit=True) + blit=True, save_count=100) plt.show() diff --git a/examples/animation/unchained.py b/examples/animation/unchained.py index 522cea10b683..3915f55dedd4 100644 --- a/examples/animation/unchained.py +++ b/examples/animation/unchained.py @@ -7,6 +7,8 @@ (mostly known because of the cover for Joy Division's Unknown Pleasures). Author: Nicolas P. Rougier + +Output generated via `matplotlib.animation.Animation.to_jshtml`. """ import numpy as np @@ -69,5 +71,5 @@ def update(*args): return lines # Construct the animation, using the update function as the animation director. -anim = animation.FuncAnimation(fig, update, interval=10) +anim = animation.FuncAnimation(fig, update, interval=10, save_count=100) plt.show() diff --git a/examples/axes_grid1/README.txt b/examples/axes_grid1/README.txt index e9afa00b9a72..42cc4b5af75c 100644 --- a/examples/axes_grid1/README.txt +++ b/examples/axes_grid1/README.txt @@ -2,5 +2,5 @@ .. _axes_grid1-examples-index: -axes_grid1 -========== +The axes_grid1 module +===================== diff --git a/examples/axes_grid1/demo_anchored_direction_arrows.py b/examples/axes_grid1/demo_anchored_direction_arrows.py index cdf16dc05754..24d3ddfcc4ad 100644 --- a/examples/axes_grid1/demo_anchored_direction_arrows.py +++ b/examples/axes_grid1/demo_anchored_direction_arrows.py @@ -42,7 +42,7 @@ # Rotated arrow fontprops = fm.FontProperties(family='serif') -roatated_arrow = AnchoredDirectionArrows( +rotated_arrow = AnchoredDirectionArrows( ax.transAxes, '30', '120', loc='center', @@ -50,7 +50,7 @@ angle=30, fontproperties=fontprops ) -ax.add_artist(roatated_arrow) +ax.add_artist(rotated_arrow) # Altering arrow directions a1 = AnchoredDirectionArrows( diff --git a/examples/axes_grid1/demo_axes_divider.py b/examples/axes_grid1/demo_axes_divider.py index 51f1e4c25491..242c33a935a2 100644 --- a/examples/axes_grid1/demo_axes_divider.py +++ b/examples/axes_grid1/demo_axes_divider.py @@ -1,6 +1,6 @@ """ ============ -Axes Divider +Axes divider ============ Axes divider to calculate location of axes and @@ -68,7 +68,7 @@ def demo_locatable_axes_easy(ax): divider = make_axes_locatable(ax) - ax_cb = divider.new_horizontal(size="5%", pad=0.05) + ax_cb = divider.append_axes("right", size="5%", pad=0.05) fig = ax.get_figure() fig.add_axes(ax_cb) @@ -86,7 +86,7 @@ def demo_images_side_by_side(ax): divider = make_axes_locatable(ax) Z, extent = get_demo_image() - ax2 = divider.new_horizontal(size="100%", pad=0.05) + ax2 = divider.append_axes("right", size="100%", pad=0.05) fig1 = ax.get_figure() fig1.add_axes(ax2) @@ -112,7 +112,7 @@ def demo(): # PLOT 3 # image and colorbar whose location is adjusted in the drawing time. - # a easy way + # an easy way ax = fig.add_subplot(2, 2, 3) demo_locatable_axes_easy(ax) diff --git a/examples/axes_grid1/demo_axes_grid.py b/examples/axes_grid1/demo_axes_grid.py index 57297ba77e6a..27d0f3268b01 100644 --- a/examples/axes_grid1/demo_axes_grid.py +++ b/examples/axes_grid1/demo_axes_grid.py @@ -3,7 +3,7 @@ Demo Axes Grid ============== -Grid of 2x2 images with single or own colorbar. +Grid of 2x2 images with a single colorbar or with one colorbar per axes. """ from matplotlib import cbook @@ -11,115 +11,70 @@ from mpl_toolkits.axes_grid1 import ImageGrid -def get_demo_image(): - z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) - # z is a numpy array of 15x15 - return z, (-3, 4, -4, 3) - - -def demo_simple_grid(fig): - """ - A grid of 2x2 images with 0.05 inch pad between images and only - the lower-left axes is labeled. - """ - grid = ImageGrid(fig, 141, # similar to subplot(141) - nrows_ncols=(2, 2), - axes_pad=0.05, - label_mode="1", - ) - Z, extent = get_demo_image() - for ax in grid: - ax.imshow(Z, extent=extent) - # This only affects axes in first column and second row as share_all=False. - grid.axes_llc.set_xticks([-2, 0, 2]) - grid.axes_llc.set_yticks([-2, 0, 2]) - - -def demo_grid_with_single_cbar(fig): - """ - A grid of 2x2 images with a single colorbar - """ - grid = ImageGrid(fig, 142, # similar to subplot(142) - nrows_ncols=(2, 2), - axes_pad=0.0, - share_all=True, - label_mode="L", - cbar_location="top", - cbar_mode="single", - ) - - Z, extent = get_demo_image() - for ax in grid: - im = ax.imshow(Z, extent=extent) - grid.cbar_axes[0].colorbar(im) - - for cax in grid.cbar_axes: - cax.toggle_label(False) - - # This affects all axes as share_all = True. - grid.axes_llc.set_xticks([-2, 0, 2]) - grid.axes_llc.set_yticks([-2, 0, 2]) - - -def demo_grid_with_each_cbar(fig): - """ - A grid of 2x2 images. Each image has its own colorbar. - """ - grid = ImageGrid(fig, 143, # similar to subplot(143) - nrows_ncols=(2, 2), - axes_pad=0.1, - label_mode="1", - share_all=True, - cbar_location="top", - cbar_mode="each", - cbar_size="7%", - cbar_pad="2%", - ) - Z, extent = get_demo_image() - for ax, cax in zip(grid, grid.cbar_axes): - im = ax.imshow(Z, extent=extent) - cax.colorbar(im) - cax.toggle_label(False) - - # This affects all axes because we set share_all = True. - grid.axes_llc.set_xticks([-2, 0, 2]) - grid.axes_llc.set_yticks([-2, 0, 2]) - - -def demo_grid_with_each_cbar_labelled(fig): - """ - A grid of 2x2 images. Each image has its own colorbar. - """ - grid = ImageGrid(fig, 144, # similar to subplot(144) - nrows_ncols=(2, 2), - axes_pad=(0.45, 0.15), - label_mode="1", - share_all=True, - cbar_location="right", - cbar_mode="each", - cbar_size="7%", - cbar_pad="2%", - ) - Z, extent = get_demo_image() - - # Use a different colorbar range every time - limits = ((0, 1), (-2, 2), (-1.7, 1.4), (-1.5, 1)) - for ax, cax, vlim in zip(grid, grid.cbar_axes, limits): - im = ax.imshow(Z, extent=extent, vmin=vlim[0], vmax=vlim[1]) - cb = cax.colorbar(im) - cb.set_ticks((vlim[0], vlim[1])) - - # This affects all axes because we set share_all = True. - grid.axes_llc.set_xticks([-2, 0, 2]) - grid.axes_llc.set_yticks([-2, 0, 2]) +Z = cbook.get_sample_data( # (15, 15) array + "axes_grid/bivariate_normal.npy", np_load=True) +extent = (-3, 4, -4, 3) fig = plt.figure(figsize=(10.5, 2.5)) fig.subplots_adjust(left=0.05, right=0.95) -demo_simple_grid(fig) -demo_grid_with_single_cbar(fig) -demo_grid_with_each_cbar(fig) -demo_grid_with_each_cbar_labelled(fig) + +# A grid of 2x2 images with 0.05 inch pad between images and only the +# lower-left axes is labeled. +grid = ImageGrid( + fig, 141, # similar to fig.add_subplot(141). + nrows_ncols=(2, 2), axes_pad=0.05, label_mode="1") +for ax in grid: + ax.imshow(Z, extent=extent) +# This only affects axes in first column and second row as share_all=False. +grid.axes_llc.set_xticks([-2, 0, 2]) +grid.axes_llc.set_yticks([-2, 0, 2]) + + +# A grid of 2x2 images with a single colorbar +grid = ImageGrid( + fig, 142, # similar to fig.add_subplot(142). + nrows_ncols=(2, 2), axes_pad=0.0, label_mode="L", share_all=True, + cbar_location="top", cbar_mode="single") +for ax in grid: + im = ax.imshow(Z, extent=extent) +grid.cbar_axes[0].colorbar(im) +for cax in grid.cbar_axes: + cax.toggle_label(False) +# This affects all axes as share_all = True. +grid.axes_llc.set_xticks([-2, 0, 2]) +grid.axes_llc.set_yticks([-2, 0, 2]) + + +# A grid of 2x2 images. Each image has its own colorbar. +grid = ImageGrid( + fig, 143, # similar to fig.add_subplot(143). + nrows_ncols=(2, 2), axes_pad=0.1, label_mode="1", share_all=True, + cbar_location="top", cbar_mode="each", cbar_size="7%", cbar_pad="2%") +for ax, cax in zip(grid, grid.cbar_axes): + im = ax.imshow(Z, extent=extent) + cax.colorbar(im) + cax.toggle_label(False) +# This affects all axes because we set share_all = True. +grid.axes_llc.set_xticks([-2, 0, 2]) +grid.axes_llc.set_yticks([-2, 0, 2]) + + +# A grid of 2x2 images. Each image has its own colorbar. +grid = ImageGrid( + fig, 144, # similar to fig.add_subplot(144). + nrows_ncols=(2, 2), axes_pad=(0.45, 0.15), label_mode="1", share_all=True, + cbar_location="right", cbar_mode="each", cbar_size="7%", cbar_pad="2%") +# Use a different colorbar range every time +limits = ((0, 1), (-2, 2), (-1.7, 1.4), (-1.5, 1)) +for ax, cax, vlim in zip(grid, grid.cbar_axes, limits): + im = ax.imshow(Z, extent=extent, vmin=vlim[0], vmax=vlim[1]) + cb = cax.colorbar(im) + cb.set_ticks((vlim[0], vlim[1])) +# This affects all axes because we set share_all = True. +grid.axes_llc.set_xticks([-2, 0, 2]) +grid.axes_llc.set_yticks([-2, 0, 2]) + plt.show() diff --git a/examples/axes_grid1/demo_axes_hbox_divider.py b/examples/axes_grid1/demo_axes_hbox_divider.py index 7bbbeff950a6..b3bfcc508468 100644 --- a/examples/axes_grid1/demo_axes_hbox_divider.py +++ b/examples/axes_grid1/demo_axes_hbox_divider.py @@ -1,43 +1,54 @@ """ -=================== -`.HBoxDivider` demo -=================== +================================ +HBoxDivider and VBoxDivider demo +================================ Using an `.HBoxDivider` to arrange subplots. + +Note that both axes' location are adjusted so that they have +equal heights while maintaining their aspect ratios. + """ import numpy as np import matplotlib.pyplot as plt -from mpl_toolkits.axes_grid1.axes_divider import HBoxDivider +from mpl_toolkits.axes_grid1.axes_divider import HBoxDivider, VBoxDivider import mpl_toolkits.axes_grid1.axes_size as Size -def make_heights_equal(fig, rect, ax1, ax2, pad): - # pad in inches - divider = HBoxDivider( - fig, rect, - horizontal=[Size.AxesX(ax1), Size.Fixed(pad), Size.AxesX(ax2)], - vertical=[Size.AxesY(ax1), Size.Scaled(1), Size.AxesY(ax2)]) - ax1.set_axes_locator(divider.new_locator(0)) - ax2.set_axes_locator(divider.new_locator(2)) +arr1 = np.arange(20).reshape((4, 5)) +arr2 = np.arange(20).reshape((5, 4)) + +fig, (ax1, ax2) = plt.subplots(1, 2) +ax1.imshow(arr1) +ax2.imshow(arr2) +pad = 0.5 # pad in inches +divider = HBoxDivider( + fig, 111, + horizontal=[Size.AxesX(ax1), Size.Fixed(pad), Size.AxesX(ax2)], + vertical=[Size.AxesY(ax1), Size.Scaled(1), Size.AxesY(ax2)]) +ax1.set_axes_locator(divider.new_locator(0)) +ax2.set_axes_locator(divider.new_locator(2)) -if __name__ == "__main__": +plt.show() - arr1 = np.arange(20).reshape((4, 5)) - arr2 = np.arange(20).reshape((5, 4)) +############################################################################### +# Using a `.VBoxDivider` to arrange subplots. +# +# Note that both axes' location are adjusted so that they have +# equal widths while maintaining their aspect ratios. - fig, (ax1, ax2) = plt.subplots(1, 2) - ax1.imshow(arr1) - ax2.imshow(arr2) +fig, (ax1, ax2) = plt.subplots(2, 1) +ax1.imshow(arr1) +ax2.imshow(arr2) - make_heights_equal(fig, 111, ax1, ax2, pad=0.5) +divider = VBoxDivider( + fig, 111, + horizontal=[Size.AxesX(ax1), Size.Scaled(1), Size.AxesX(ax2)], + vertical=[Size.AxesY(ax1), Size.Fixed(pad), Size.AxesY(ax2)]) - fig.text(.5, .5, - "Both axes' location are adjusted\n" - "so that they have equal heights\n" - "while maintaining their aspect ratios", - va="center", ha="center", - bbox=dict(boxstyle="round, pad=1", facecolor="w")) +ax1.set_axes_locator(divider.new_locator(0)) +ax2.set_axes_locator(divider.new_locator(2)) - plt.show() +plt.show() diff --git a/examples/axes_grid1/demo_axes_rgb.py b/examples/axes_grid1/demo_axes_rgb.py index 9b1ccb431d77..fa6ffdb5df3e 100644 --- a/examples/axes_grid1/demo_axes_rgb.py +++ b/examples/axes_grid1/demo_axes_rgb.py @@ -59,11 +59,8 @@ def demo_rgb2(): ax_b.imshow(im_b) for ax in fig.axes: - ax.tick_params(axis='both', direction='in') + ax.tick_params(direction='in', color='w') ax.spines[:].set_color("w") - for tick in ax.xaxis.get_major_ticks() + ax.yaxis.get_major_ticks(): - tick.tick1line.set_markeredgecolor("w") - tick.tick2line.set_markeredgecolor("w") demo_rgb1() diff --git a/examples/axes_grid1/demo_colorbar_with_axes_divider.py b/examples/axes_grid1/demo_colorbar_with_axes_divider.py index 9ab4640ee8b0..920db5703b98 100644 --- a/examples/axes_grid1/demo_colorbar_with_axes_divider.py +++ b/examples/axes_grid1/demo_colorbar_with_axes_divider.py @@ -18,13 +18,13 @@ im1 = ax1.imshow([[1, 2], [3, 4]]) ax1_divider = make_axes_locatable(ax1) -# Add an axes to the right of the main axes. +# Add an Axes to the right of the main Axes. cax1 = ax1_divider.append_axes("right", size="7%", pad="2%") cb1 = fig.colorbar(im1, cax=cax1) im2 = ax2.imshow([[1, 2], [3, 4]]) ax2_divider = make_axes_locatable(ax2) -# Add an axes above the main axes. +# Add an Axes above the main Axes. cax2 = ax2_divider.append_axes("top", size="7%", pad="2%") cb2 = fig.colorbar(im2, cax=cax2, orientation="horizontal") # Change tick position to top (with the default tick position "bottom", ticks diff --git a/examples/axes_grid1/demo_colorbar_with_inset_locator.py b/examples/axes_grid1/demo_colorbar_with_inset_locator.py index 94d435d6b288..87939800021d 100644 --- a/examples/axes_grid1/demo_colorbar_with_inset_locator.py +++ b/examples/axes_grid1/demo_colorbar_with_inset_locator.py @@ -6,40 +6,37 @@ This example shows how to control the position, height, and width of colorbars using `~mpl_toolkits.axes_grid1.inset_locator.inset_axes`. -Controlling the placement of the inset axes is done similarly as that of the -legend: either by providing a location option ("upper right", "best", ...), or -by providing a locator with respect to the parent bbox. - +Inset axes placement is controlled as for legends: either by providing a *loc* +option ("upper right", "best", ...), or by providing a locator with respect to +the parent bbox. Parameters such as *bbox_to_anchor* and *borderpad* likewise +work in the same way, and are also demonstrated here. """ -import matplotlib.pyplot as plt +import matplotlib.pyplot as plt from mpl_toolkits.axes_grid1.inset_locator import inset_axes fig, (ax1, ax2) = plt.subplots(1, 2, figsize=[6, 3]) -axins1 = inset_axes(ax1, - width="50%", # width = 50% of parent_bbox width - height="5%", # height : 5% - loc='upper right') - im1 = ax1.imshow([[1, 2], [2, 3]]) -fig.colorbar(im1, cax=axins1, orientation="horizontal", ticks=[1, 2, 3]) +axins1 = inset_axes( + ax1, + width="50%", # width: 50% of parent_bbox width + height="5%", # height: 5% + loc="upper right", +) axins1.xaxis.set_ticks_position("bottom") - -axins = inset_axes(ax2, - width="5%", # width = 5% of parent_bbox width - height="50%", # height : 50% - loc='lower left', - bbox_to_anchor=(1.05, 0., 1, 1), - bbox_transform=ax2.transAxes, - borderpad=0, - ) - -# Controlling the placement of the inset axes is basically same as that -# of the legend. you may want to play with the borderpad value and -# the bbox_to_anchor coordinate. +fig.colorbar(im1, cax=axins1, orientation="horizontal", ticks=[1, 2, 3]) im = ax2.imshow([[1, 2], [2, 3]]) +axins = inset_axes( + ax2, + width="5%", # width: 5% of parent_bbox width + height="50%", # height: 50% + loc="lower left", + bbox_to_anchor=(1.05, 0., 1, 1), + bbox_transform=ax2.transAxes, + borderpad=0, +) fig.colorbar(im, cax=axins, ticks=[1, 2, 3]) plt.show() diff --git a/examples/axes_grid1/inset_locator_demo.py b/examples/axes_grid1/inset_locator_demo.py index 974783c04309..ae1d56cda667 100644 --- a/examples/axes_grid1/inset_locator_demo.py +++ b/examples/axes_grid1/inset_locator_demo.py @@ -1,6 +1,6 @@ """ ================== -Inset Locator Demo +Inset locator demo ================== """ @@ -45,7 +45,7 @@ ############################################################################### # The parameters *bbox_to_anchor* and *bbox_transform* can be used for a more -# fine grained control over the inset position and size or even to position +# fine-grained control over the inset position and size or even to position # the inset at completely arbitrary positions. # The *bbox_to_anchor* sets the bounding box in coordinates according to the # *bbox_transform*. @@ -54,12 +54,12 @@ fig = plt.figure(figsize=[5.5, 2.8]) ax = fig.add_subplot(121) -# We use the axes transform as bbox_transform. Therefore the bounding box +# We use the axes transform as bbox_transform. Therefore, the bounding box # needs to be specified in axes coordinates ((0, 0) is the lower left corner # of the axes, (1, 1) is the upper right corner). # The bounding box (.2, .4, .6, .5) starts at (.2, .4) and ranges to (.8, .9) # in those coordinates. -# Inside of this bounding box an inset of half the bounding box' width and +# Inside this bounding box an inset of half the bounding box' width and # three quarters of the bounding box' height is created. The lower left corner # of the inset is aligned to the lower left corner of the bounding box (loc=3). # The inset is then offset by the default 0.5 in units of the font size. @@ -103,7 +103,7 @@ ############################################################################### # In the above the axes transform together with 4-tuple bounding boxes has been # used as it mostly is useful to specify an inset relative to the axes it is -# an inset to. However other use cases are equally possible. The following +# an inset to. However, other use cases are equally possible. The following # example examines some of those. # diff --git a/examples/axes_grid1/inset_locator_demo2.py b/examples/axes_grid1/inset_locator_demo2.py index 795cb9471bdb..11d80702c094 100644 --- a/examples/axes_grid1/inset_locator_demo2.py +++ b/examples/axes_grid1/inset_locator_demo2.py @@ -1,12 +1,15 @@ """ -=================== -Inset Locator Demo2 -=================== +==================== +Inset locator demo 2 +==================== -This Demo shows how to create a zoomed inset via `.zoomed_inset_axes`. +This demo shows how to create a zoomed inset via `.zoomed_inset_axes`. In the first subplot an `.AnchoredSizeBar` shows the zoom effect. In the second subplot a connection to the region of interest is created via `.mark_inset`. + +A version of the second subplot, not using the toolkit, is available in +:doc:`/gallery/subplots_axes_and_figures/zoom_inset_axes`. """ from matplotlib import cbook @@ -60,7 +63,7 @@ def add_sizebar(ax, size): axins2 = zoomed_inset_axes(ax2, zoom=6, loc=1) axins2.imshow(Z2, extent=extent, origin="lower") -# sub region of the original image +# subregion of the original image x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 axins2.set_xlim(x1, x2) axins2.set_ylim(y1, y2) diff --git a/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py b/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py index b6ce60531741..802af8739b97 100644 --- a/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py +++ b/examples/axes_grid1/make_room_for_ylabel_using_axesgrid.py @@ -1,8 +1,7 @@ """ -=================================== -Make Room For Ylabel Using Axesgrid -=================================== - +==================================== +Make room for ylabel using axes_grid +==================================== """ import matplotlib.pyplot as plt @@ -11,8 +10,8 @@ from mpl_toolkits.axes_grid1.axes_divider import make_axes_area_auto_adjustable -plt.figure() -ax = plt.axes([0, 0, 1, 1]) +fig = plt.figure() +ax = fig.add_axes([0, 0, 1, 1]) ax.set_yticks([0.5], labels=["very long label"]) @@ -20,10 +19,9 @@ ############################################################################### - -plt.figure() -ax1 = plt.axes([0, 0, 1, 0.5]) -ax2 = plt.axes([0, 0.5, 1, 0.5]) +fig = plt.figure() +ax1 = fig.add_axes([0, 0, 1, 0.5]) +ax2 = fig.add_axes([0, 0.5, 1, 0.5]) ax1.set_yticks([0.5], labels=["very long label"]) ax1.set_ylabel("Y label") @@ -35,12 +33,11 @@ ############################################################################### - fig = plt.figure() -ax1 = plt.axes([0, 0, 1, 1]) +ax1 = fig.add_axes([0, 0, 1, 1]) divider = make_axes_locatable(ax1) -ax2 = divider.new_horizontal("100%", pad=0.3, sharey=ax1) +ax2 = divider.append_axes("right", "100%", pad=0.3, sharey=ax1) ax2.tick_params(labelleft=False) fig.add_axes(ax2) diff --git a/examples/axes_grid1/parasite_simple.py b/examples/axes_grid1/parasite_simple.py index c76f70ee9d74..36f36f9c997e 100644 --- a/examples/axes_grid1/parasite_simple.py +++ b/examples/axes_grid1/parasite_simple.py @@ -2,13 +2,12 @@ =============== Parasite Simple =============== - """ -from mpl_toolkits.axes_grid1 import host_subplot + import matplotlib.pyplot as plt +from mpl_toolkits.axes_grid1 import host_subplot host = host_subplot(111) - par = host.twinx() host.set_xlabel("Distance") @@ -18,12 +17,9 @@ p1, = host.plot([0, 1, 2], [0, 1, 2], label="Density") p2, = par.plot([0, 1, 2], [0, 3, 2], label="Temperature") -leg = plt.legend() +host.legend(labelcolor="linecolor") host.yaxis.get_label().set_color(p1.get_color()) -leg.texts[0].set_color(p1.get_color()) - par.yaxis.get_label().set_color(p2.get_color()) -leg.texts[1].set_color(p2.get_color()) plt.show() diff --git a/examples/axes_grid1/scatter_hist_locatable_axes.py b/examples/axes_grid1/scatter_hist_locatable_axes.py index 8d2ee72b9c6b..c605e9e81258 100644 --- a/examples/axes_grid1/scatter_hist_locatable_axes.py +++ b/examples/axes_grid1/scatter_hist_locatable_axes.py @@ -3,16 +3,16 @@ Scatter Histogram (Locatable Axes) ================================== -Show the marginal distributions of a scatter as histograms at the sides of +Show the marginal distributions of a scatter plot as histograms at the sides of the plot. For a nice alignment of the main axes with the marginals, the axes positions -are defined by a ``Divider``, produced via `.make_axes_locatable`. +are defined by a ``Divider``, produced via `.make_axes_locatable`. Note that +the ``Divider`` API allows setting axes sizes and pads in inches, which is its +main feature. -An alternative method to produce a similar figure is shown in the -:doc:`/gallery/lines_bars_and_markers/scatter_hist` example. The advantage of -the locatable axes method shown below is that the marginal axes follow the -fixed aspect ratio of the main axes. +If one wants to set axes sizes and pads relative to the main Figure, see the +:doc:`/gallery/lines_bars_and_markers/scatter_hist` example. """ import numpy as np diff --git a/examples/axes_grid1/simple_axes_divider1.py b/examples/axes_grid1/simple_axes_divider1.py index 9d96f2a3bcd5..0ead9c27ae9f 100644 --- a/examples/axes_grid1/simple_axes_divider1.py +++ b/examples/axes_grid1/simple_axes_divider1.py @@ -11,7 +11,7 @@ def label_axes(ax, text): - """Place a label at the center of an axes, and remove the axis ticks.""" + """Place a label at the center of an Axes, and remove the axis ticks.""" ax.text(.5, .5, text, transform=ax.transAxes, horizontalalignment="center", verticalalignment="center") ax.tick_params(bottom=False, labelbottom=False, diff --git a/examples/axes_grid1/simple_axes_divider3.py b/examples/axes_grid1/simple_axes_divider3.py index 624a3be359f8..7f88936c2deb 100644 --- a/examples/axes_grid1/simple_axes_divider3.py +++ b/examples/axes_grid1/simple_axes_divider3.py @@ -1,6 +1,6 @@ """ ===================== -Simple Axes Divider 3 +Simple axes divider 3 ===================== See also :doc:`/tutorials/toolkits/axes_grid`. @@ -13,7 +13,7 @@ fig = plt.figure(figsize=(5.5, 4)) -# the rect parameter will be ignore as we will set axes_locator +# the rect parameter will be ignored as we will set axes_locator rect = (0.1, 0.1, 0.8, 0.8) ax = [fig.add_axes(rect, label="%d" % i) for i in range(4)] diff --git a/examples/axes_grid1/simple_colorbar.py b/examples/axes_grid1/simple_colorbar.py index d7ca58046a53..41a34eefcab1 100644 --- a/examples/axes_grid1/simple_colorbar.py +++ b/examples/axes_grid1/simple_colorbar.py @@ -11,7 +11,7 @@ ax = plt.subplot() im = ax.imshow(np.arange(100).reshape((10, 10))) -# create an axes on the right side of ax. The width of cax will be 5% +# create an Axes on the right side of ax. The width of cax will be 5% # of ax and the padding between cax and ax will be fixed at 0.05 inch. divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad=0.05) diff --git a/examples/axisartist/README.txt b/examples/axisartist/README.txt index dc7c87d3a608..6cc9325d1027 100644 --- a/examples/axisartist/README.txt +++ b/examples/axisartist/README.txt @@ -2,5 +2,5 @@ .. _axisartist-examples-index: -axisartist -========== +The axisartist module +===================== diff --git a/examples/axisartist/demo_curvelinear_grid.py b/examples/axisartist/demo_curvelinear_grid.py index 31f6f514a623..3efe83a43c70 100644 --- a/examples/axisartist/demo_curvelinear_grid.py +++ b/examples/axisartist/demo_curvelinear_grid.py @@ -95,7 +95,6 @@ def curvelinear_test2(fig): ax2 = ax1.get_aux_axes(tr) # note that ax2.transData == tr + ax1.transData # Anything you draw in ax2 will match the ticks and grids of ax1. - ax1.parasites.append(ax2) ax2.plot(np.linspace(0, 30, 51), np.linspace(10, 10, 51), linewidth=2) ax2.pcolor(np.linspace(0, 90, 4), np.linspace(0, 10, 4), diff --git a/examples/axisartist/demo_floating_axes.py b/examples/axisartist/demo_floating_axes.py index 6920ddca5233..c87b44818ff7 100644 --- a/examples/axisartist/demo_floating_axes.py +++ b/examples/axisartist/demo_floating_axes.py @@ -1,7 +1,7 @@ """ -===================================================== -:mod:`mpl_toolkits.axisartist.floating_axes` features -===================================================== +========================== +``floating_axes`` features +========================== Demonstration of features of the :mod:`.floating_axes` module: @@ -9,8 +9,8 @@ the plot. * Using `~.floating_axes.GridHelperCurveLinear` to rotate the plot and set the plot boundary. -* Using `~.floating_axes.FloatingSubplot` to create a subplot using the return - value from `~.floating_axes.GridHelperCurveLinear`. +* Using `~.Figure.add_subplot` to create a subplot using the return value from + `~.floating_axes.GridHelperCurveLinear`. * Making a sector plot by adding more features to `~.floating_axes.GridHelperCurveLinear`. """ @@ -41,6 +41,7 @@ def setup_axes1(fig, rect): ax1 = fig.add_subplot( rect, axes_class=floating_axes.FloatingAxes, grid_helper=grid_helper) + ax1.grid() aux_ax = ax1.get_aux_axes(tr) @@ -72,6 +73,7 @@ def setup_axes2(fig, rect): ax1 = fig.add_subplot( rect, axes_class=floating_axes.FloatingAxes, grid_helper=grid_helper) + ax1.grid() # create a parasite axes whose transData in RA, cz aux_ax = ax1.get_aux_axes(tr) @@ -129,6 +131,7 @@ def setup_axes3(fig, rect): ax1.axis["left"].label.set_text(r"cz [km$^{-1}$]") ax1.axis["top"].label.set_text(r"$\alpha_{1950}$") + ax1.grid() # create a parasite axes whose transData in RA, cz aux_ax = ax1.get_aux_axes(tr) diff --git a/examples/axisartist/demo_floating_axis.py b/examples/axisartist/demo_floating_axis.py index b0d8be51c248..f607907c2654 100644 --- a/examples/axisartist/demo_floating_axis.py +++ b/examples/axisartist/demo_floating_axis.py @@ -29,8 +29,7 @@ def curvelinear_test2(fig): lon_cycle=360, lat_cycle=None, lon_minmax=None, - lat_minmax=(0, - np.inf), + lat_minmax=(0, np.inf), ) grid_locator1 = angle_helper.LocatorDMS(12) diff --git a/examples/axisartist/demo_parasite_axes.py b/examples/axisartist/demo_parasite_axes.py index 0b7858f645f3..b1bdcbaba091 100644 --- a/examples/axisartist/demo_parasite_axes.py +++ b/examples/axisartist/demo_parasite_axes.py @@ -9,25 +9,23 @@ This approach uses `mpl_toolkits.axes_grid1.parasite_axes.HostAxes` and `mpl_toolkits.axes_grid1.parasite_axes.ParasiteAxes`. -An alternative approach using standard Matplotlib subplots is shown in the -:doc:`/gallery/spines/multiple_yaxis_with_spines` example. +The standard and recommended approach is to use instead standard Matplotlib +axes, as shown in the :doc:`/gallery/spines/multiple_yaxis_with_spines` +example. -An alternative approach using :mod:`mpl_toolkits.axes_grid1` -and :mod:`mpl_toolkits.axisartist` is found in the +An alternative approach using `mpl_toolkits.axes_grid1` and +`mpl_toolkits.axisartist` is shown in the :doc:`/gallery/axisartist/demo_parasite_axes2` example. """ -from mpl_toolkits.axisartist.parasite_axes import HostAxes, ParasiteAxes +from mpl_toolkits.axisartist.parasite_axes import HostAxes import matplotlib.pyplot as plt - fig = plt.figure() host = fig.add_axes([0.15, 0.1, 0.65, 0.8], axes_class=HostAxes) -par1 = ParasiteAxes(host, sharex=host) -par2 = ParasiteAxes(host, sharex=host) -host.parasites.append(par1) -host.parasites.append(par2) +par1 = host.get_aux_axes(viewlim_mode=None, sharex=host) +par2 = host.get_aux_axes(viewlim_mode=None, sharex=host) host.axis["right"].set_visible(False) @@ -41,15 +39,9 @@ p2, = par1.plot([0, 1, 2], [0, 3, 2], label="Temperature") p3, = par2.plot([0, 1, 2], [50, 30, 15], label="Velocity") -host.set_xlim(0, 2) -host.set_ylim(0, 2) -par1.set_ylim(0, 4) -par2.set_ylim(1, 65) - -host.set_xlabel("Distance") -host.set_ylabel("Density") -par1.set_ylabel("Temperature") -par2.set_ylabel("Velocity") +host.set(xlim=(0, 2), ylim=(0, 2), xlabel="Distance", ylabel="Density") +par1.set(ylim=(0, 4), ylabel="Temperature") +par2.set(ylim=(1, 65), ylabel="Velocity") host.legend() diff --git a/examples/axisartist/demo_parasite_axes2.py b/examples/axisartist/demo_parasite_axes2.py index 651cdd032ae5..d72611c88cd5 100644 --- a/examples/axisartist/demo_parasite_axes2.py +++ b/examples/axisartist/demo_parasite_axes2.py @@ -11,15 +11,16 @@ of those two axis behave separately from each other: different datasets can be plotted, and the y-limits are adjusted separately. -Note that this approach uses the `mpl_toolkits.axes_grid1.parasite_axes`' -`~mpl_toolkits.axes_grid1.parasite_axes.host_subplot` and -`mpl_toolkits.axisartist.axislines.Axes`. An alternative approach using the -`~mpl_toolkits.axes_grid1.parasite_axes`'s -`~.mpl_toolkits.axes_grid1.parasite_axes.HostAxes` and -`~.mpl_toolkits.axes_grid1.parasite_axes.ParasiteAxes` is the +This approach uses `mpl_toolkits.axes_grid1.parasite_axes.host_subplot` and +`mpl_toolkits.axisartist.axislines.Axes`. + +The standard and recommended approach is to use instead standard Matplotlib +axes, as shown in the :doc:`/gallery/spines/multiple_yaxis_with_spines` +example. + +An alternative approach using `mpl_toolkits.axes_grid1.parasite_axes.HostAxes` +and `mpl_toolkits.axes_grid1.parasite_axes.ParasiteAxes` is shown in the :doc:`/gallery/axisartist/demo_parasite_axes` example. -An alternative approach using the usual Matplotlib subplots is shown in -the :doc:`/gallery/spines/multiple_yaxis_with_spines` example. """ from mpl_toolkits.axes_grid1 import host_subplot @@ -41,15 +42,9 @@ p2, = par1.plot([0, 1, 2], [0, 3, 2], label="Temperature") p3, = par2.plot([0, 1, 2], [50, 30, 15], label="Velocity") -host.set_xlim(0, 2) -host.set_ylim(0, 2) -par1.set_ylim(0, 4) -par2.set_ylim(1, 65) - -host.set_xlabel("Distance") -host.set_ylabel("Density") -par1.set_ylabel("Temperature") -par2.set_ylabel("Velocity") +host.set(xlim=(0, 2), ylim=(0, 2), xlabel="Distance", ylabel="Density") +par1.set(ylim=(0, 4), ylabel="Temperature") +par2.set(ylim=(1, 65), ylabel="Velocity") host.legend() diff --git a/examples/color/color_demo.py b/examples/color/color_demo.py index f46f52507352..8a161442184b 100644 --- a/examples/color/color_demo.py +++ b/examples/color/color_demo.py @@ -48,9 +48,9 @@ # 3) gray level string: ax.set_title('Voltage vs. time chart', color='0.7') # 4) single letter color string -ax.set_xlabel('time (s)', color='c') +ax.set_xlabel('Time [s]', color='c') # 5) a named color: -ax.set_ylabel('voltage (mV)', color='peachpuff') +ax.set_ylabel('Voltage [mV]', color='peachpuff') # 6) a named xkcd color: ax.plot(t, s, 'xkcd:crimson') # 7) Cn notation: diff --git a/examples/color/colorbar_basics.py b/examples/color/colorbar_basics.py index a80b7caf77df..f31ded0100c7 100644 --- a/examples/color/colorbar_basics.py +++ b/examples/color/colorbar_basics.py @@ -3,8 +3,8 @@ Colorbar ======== -Use `~.figure.Figure.colorbar` by specifying the mappable object (here -the `~.matplotlib.image.AxesImage` returned by `~.axes.Axes.imshow`) +Use `~.Figure.colorbar` by specifying the mappable object (here +the `.AxesImage` returned by `~.axes.Axes.imshow`) and the axes to attach the colorbar to. """ diff --git a/examples/color/colormap_reference.py b/examples/color/colormap_reference.py index d5320f94f0e6..549345edffab 100644 --- a/examples/color/colormap_reference.py +++ b/examples/color/colormap_reference.py @@ -6,16 +6,17 @@ Reference for colormaps included with Matplotlib. A reversed version of each of these colormaps is available by appending -``_r`` to the name, e.g., ``viridis_r``. +``_r`` to the name, as shown in :ref:`reverse-cmap`. See :doc:`/tutorials/colors/colormaps` for an in-depth discussion about -colormaps, including colorblind-friendliness. +colormaps, including colorblind-friendliness, and +:doc:`/tutorials/colors/colormap-manipulation` for a guide to creating +colormaps. """ import numpy as np import matplotlib.pyplot as plt - cmaps = [('Perceptually Uniform Sequential', [ 'viridis', 'plasma', 'inferno', 'magma', 'cividis']), ('Sequential', [ @@ -40,7 +41,6 @@ 'gist_rainbow', 'rainbow', 'jet', 'turbo', 'nipy_spectral', 'gist_ncar'])] - gradient = np.linspace(0, 1, 256) gradient = np.vstack((gradient, gradient)) @@ -52,7 +52,7 @@ def plot_color_gradients(cmap_category, cmap_list): fig, axs = plt.subplots(nrows=nrows, figsize=(6.4, figh)) fig.subplots_adjust(top=1-.35/figh, bottom=.15/figh, left=0.2, right=0.99) - axs[0].set_title(cmap_category + ' colormaps', fontsize=14) + axs[0].set_title(f"{cmap_category} colormaps", fontsize=14) for ax, cmap_name in zip(axs, cmap_list): ax.imshow(gradient, aspect='auto', cmap=cmap_name) @@ -67,7 +67,21 @@ def plot_color_gradients(cmap_category, cmap_list): for cmap_category, cmap_list in cmaps: plot_color_gradients(cmap_category, cmap_list) -plt.show() + +############################################################################### +# .. _reverse-cmap: +# +# Reversed colormaps +# ------------------ +# +# Append ``_r`` to the name of any built-in colormap to get the reversed +# version: + +plot_color_gradients("Original and reversed ", ['viridis', 'viridis_r']) + +# %% +# The built-in reversed colormaps are generated using `.Colormap.reversed`. +# For an example, see :ref:`reversing-colormap` ############################################################################# # diff --git a/examples/color/custom_cmap.py b/examples/color/custom_cmap.py index 0fd27a201c57..a99127d972e6 100644 --- a/examples/color/custom_cmap.py +++ b/examples/color/custom_cmap.py @@ -12,7 +12,7 @@ Creating custom colormaps -------------------------- +========================= It is also possible to create a custom mapping for a colormap. This is accomplished by creating dictionary that specifies how the RGB channels change from one end of the cmap to the other. @@ -21,51 +21,78 @@ half, green to do the same over the middle half, and blue over the top half. Then you would use:: - cdict = {'red': ((0.0, 0.0, 0.0), - (0.5, 1.0, 1.0), - (1.0, 1.0, 1.0)), - - 'green': ((0.0, 0.0, 0.0), - (0.25, 0.0, 0.0), - (0.75, 1.0, 1.0), - (1.0, 1.0, 1.0)), - - 'blue': ((0.0, 0.0, 0.0), - (0.5, 0.0, 0.0), - (1.0, 1.0, 1.0))} + cdict = { + 'red': ( + (0.0, 0.0, 0.0), + (0.5, 1.0, 1.0), + (1.0, 1.0, 1.0), + ), + 'green': ( + (0.0, 0.0, 0.0), + (0.25, 0.0, 0.0), + (0.75, 1.0, 1.0), + (1.0, 1.0, 1.0), + ), + 'blue': ( + (0.0, 0.0, 0.0), + (0.5, 0.0, 0.0), + (1.0, 1.0, 1.0), + ) + } If, as in this example, there are no discontinuities in the r, g, and b components, then it is quite simple: the second and third element of -each tuple, above, is the same--call it "y". The first element ("x") +each tuple, above, is the same -- call it "``y``". The first element ("``x``") defines interpolation intervals over the full range of 0 to 1, and it -must span that whole range. In other words, the values of x divide the -0-to-1 range into a set of segments, and y gives the end-point color +must span that whole range. In other words, the values of ``x`` divide the +0-to-1 range into a set of segments, and ``y`` gives the end-point color values for each segment. -Now consider the green. cdict['green'] is saying that for -0 <= x <= 0.25, y is zero; no green. -0.25 < x <= 0.75, y varies linearly from 0 to 1. -x > 0.75, y remains at 1, full green. - -If there are discontinuities, then it is a little more complicated. -Label the 3 elements in each row in the cdict entry for a given color as -(x, y0, y1). Then for values of x between x[i] and x[i+1] the color -value is interpolated between y1[i] and y0[i+1]. - -Going back to the cookbook example, look at cdict['red']; because y0 != -y1, it is saying that for x from 0 to 0.5, red increases from 0 to 1, -but then it jumps down, so that for x from 0.5 to 1, red increases from -0.7 to 1. Green ramps from 0 to 1 as x goes from 0 to 0.5, then jumps -back to 0, and ramps back to 1 as x goes from 0.5 to 1.:: +Now consider the green, ``cdict['green']`` is saying that for: + +- 0 <= ``x`` <= 0.25, ``y`` is zero; no green. +- 0.25 < ``x`` <= 0.75, ``y`` varies linearly from 0 to 1. +- 0.75 < ``x`` <= 1, ``y`` remains at 1, full green. + +If there are discontinuities, then it is a little more complicated. Label the 3 +elements in each row in the ``cdict`` entry for a given color as ``(x, y0, +y1)``. Then for values of ``x`` between ``x[i]`` and ``x[i+1]`` the color value +is interpolated between ``y1[i]`` and ``y0[i+1]``. + +Going back to a cookbook example:: + + cdict = { + 'red': ( + (0.0, 0.0, 0.0), + (0.5, 1.0, 0.7), + (1.0, 1.0, 1.0), + ), + 'green': ( + (0.0, 0.0, 0.0), + (0.5, 1.0, 0.0), + (1.0, 1.0, 1.0), + ), + 'blue': ( + (0.0, 0.0, 0.0), + (0.5, 0.0, 0.0), + (1.0, 1.0, 1.0), + ) + } + +and look at ``cdict['red'][1]``; because ``y0 != y1``, it is saying that for +``x`` from 0 to 0.5, red increases from 0 to 1, but then it jumps down, so that +for ``x`` from 0.5 to 1, red increases from 0.7 to 1. Green ramps from 0 to 1 +as ``x`` goes from 0 to 0.5, then jumps back to 0, and ramps back to 1 as ``x`` +goes from 0.5 to 1. :: row i: x y0 y1 - / / + / row i+1: x y0 y1 -Above is an attempt to show that for x in the range x[i] to x[i+1], the -interpolation is between y1[i] and y0[i+1]. So, y0[0] and y1[-1] are -never used. +Above is an attempt to show that for ``x`` in the range ``x[i]`` to ``x[i+1]``, +the interpolation is between ``y1[i]`` and ``y0[i+1]``. So, ``y0[0]`` and +``y1[-1]`` are never used. """ import numpy as np @@ -82,7 +109,8 @@ ############################################################################### -# --- Colormaps from a list --- +# Colormaps from a list +# --------------------- colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1)] # R -> G -> B n_bins = [3, 6, 10, 100] # Discretizes the interpolation into bins @@ -99,60 +127,79 @@ ############################################################################### -# --- Custom colormaps --- - -cdict1 = {'red': ((0.0, 0.0, 0.0), - (0.5, 0.0, 0.1), - (1.0, 1.0, 1.0)), - - 'green': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0)), - - 'blue': ((0.0, 0.0, 1.0), - (0.5, 0.1, 0.0), - (1.0, 0.0, 0.0)) - } - -cdict2 = {'red': ((0.0, 0.0, 0.0), - (0.5, 0.0, 1.0), - (1.0, 0.1, 1.0)), - - 'green': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0)), - - 'blue': ((0.0, 0.0, 0.1), - (0.5, 1.0, 0.0), - (1.0, 0.0, 0.0)) - } - -cdict3 = {'red': ((0.0, 0.0, 0.0), - (0.25, 0.0, 0.0), - (0.5, 0.8, 1.0), - (0.75, 1.0, 1.0), - (1.0, 0.4, 1.0)), - - 'green': ((0.0, 0.0, 0.0), - (0.25, 0.0, 0.0), - (0.5, 0.9, 0.9), - (0.75, 0.0, 0.0), - (1.0, 0.0, 0.0)), - - 'blue': ((0.0, 0.0, 0.4), - (0.25, 1.0, 1.0), - (0.5, 1.0, 0.8), - (0.75, 0.0, 0.0), - (1.0, 0.0, 0.0)) - } +# Custom colormaps +# ---------------- + +cdict1 = { + 'red': ( + (0.0, 0.0, 0.0), + (0.5, 0.0, 0.1), + (1.0, 1.0, 1.0), + ), + 'green': ( + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + ), + 'blue': ( + (0.0, 0.0, 1.0), + (0.5, 0.1, 0.0), + (1.0, 0.0, 0.0), + ) +} + +cdict2 = { + 'red': ( + (0.0, 0.0, 0.0), + (0.5, 0.0, 1.0), + (1.0, 0.1, 1.0), + ), + 'green': ( + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + ), + 'blue': ( + (0.0, 0.0, 0.1), + (0.5, 1.0, 0.0), + (1.0, 0.0, 0.0), + ) +} + +cdict3 = { + 'red': ( + (0.0, 0.0, 0.0), + (0.25, 0.0, 0.0), + (0.5, 0.8, 1.0), + (0.75, 1.0, 1.0), + (1.0, 0.4, 1.0), + ), + 'green': ( + (0.0, 0.0, 0.0), + (0.25, 0.0, 0.0), + (0.5, 0.9, 0.9), + (0.75, 0.0, 0.0), + (1.0, 0.0, 0.0), + ), + 'blue': ( + (0.0, 0.0, 0.4), + (0.25, 1.0, 1.0), + (0.5, 1.0, 0.8), + (0.75, 0.0, 0.0), + (1.0, 0.0, 0.0), + ) +} # Make a modified version of cdict3 with some transparency # in the middle of the range. -cdict4 = {**cdict3, - 'alpha': ((0.0, 1.0, 1.0), - # (0.25, 1.0, 1.0), - (0.5, 0.3, 0.3), - # (0.75, 1.0, 1.0), - (1.0, 1.0, 1.0)), - } +cdict4 = { + **cdict3, + 'alpha': ( + (0.0, 1.0, 1.0), + # (0.25, 1.0, 1.0), + (0.5, 0.3, 0.3), + # (0.75, 1.0, 1.0), + (1.0, 1.0, 1.0), + ), +} ############################################################################### @@ -173,13 +220,11 @@ mpl.colormaps.register(LinearSegmentedColormap('BlueRedAlpha', cdict4)) ############################################################################### -# Make the figure: +# Make the figure, with 4 subplots: fig, axs = plt.subplots(2, 2, figsize=(6, 9)) fig.subplots_adjust(left=0.02, bottom=0.06, right=0.95, top=0.94, wspace=0.05) -# Make 4 subplots: - im1 = axs[0, 0].imshow(Z, cmap=blue_red1) fig.colorbar(im1, ax=axs[0, 0]) @@ -213,7 +258,6 @@ # colorbar after they have been plotted. im4.set_cmap('BlueRedAlpha') axs[1, 1].set_title("Varying alpha") -# fig.suptitle('Custom Blue-Red colormaps', fontsize=16) fig.subplots_adjust(top=0.9) diff --git a/examples/color/named_colors.py b/examples/color/named_colors.py index 83206d8e7990..5a8e3667689a 100644 --- a/examples/color/named_colors.py +++ b/examples/color/named_colors.py @@ -3,56 +3,56 @@ List of named colors ==================== -This plots a list of the named colors supported in matplotlib. Note that -:ref:`xkcd colors ` are supported as well, but are not listed here -for brevity. - +This plots a list of the named colors supported in matplotlib. For more information on colors in matplotlib see * the :doc:`/tutorials/colors/colors` tutorial; * the `matplotlib.colors` API; * the :doc:`/gallery/color/color_demo`. + +---------------------------- +Helper Function for Plotting +---------------------------- +First we define a helper function for making a table of colors, then we use it +on some common color categories. """ +import math + from matplotlib.patches import Rectangle import matplotlib.pyplot as plt import matplotlib.colors as mcolors -def plot_colortable(colors, title, sort_colors=True, emptycols=0): +def plot_colortable(colors, *, ncols=4, sort_colors=True): cell_width = 212 cell_height = 22 swatch_width = 48 margin = 12 - topmargin = 40 # Sort colors by hue, saturation, value and name. if sort_colors is True: - by_hsv = sorted((tuple(mcolors.rgb_to_hsv(mcolors.to_rgb(color))), - name) - for name, color in colors.items()) - names = [name for hsv, name in by_hsv] + names = sorted( + colors, key=lambda c: tuple(mcolors.rgb_to_hsv(mcolors.to_rgb(c)))) else: names = list(colors) n = len(names) - ncols = 4 - emptycols - nrows = n // ncols + int(n % ncols > 0) + nrows = math.ceil(n / ncols) width = cell_width * 4 + 2 * margin - height = cell_height * nrows + margin + topmargin + height = cell_height * nrows + 2 * margin dpi = 72 fig, ax = plt.subplots(figsize=(width / dpi, height / dpi), dpi=dpi) fig.subplots_adjust(margin/width, margin/height, - (width-margin)/width, (height-topmargin)/height) + (width-margin)/width, (height-margin)/height) ax.set_xlim(0, cell_width * 4) ax.set_ylim(cell_height * (nrows-0.5), -cell_height/2.) ax.yaxis.set_visible(False) ax.xaxis.set_visible(False) ax.set_axis_off() - ax.set_title(title, fontsize=24, loc="left", pad=10) for i, name in enumerate(names): row = i % nrows @@ -73,22 +73,38 @@ def plot_colortable(colors, title, sort_colors=True, emptycols=0): return fig -plot_colortable(mcolors.BASE_COLORS, "Base Colors", - sort_colors=False, emptycols=1) -plot_colortable(mcolors.TABLEAU_COLORS, "Tableau Palette", - sort_colors=False, emptycols=2) +############################################################################# +# ----------- +# Base colors +# ----------- -# sphinx_gallery_thumbnail_number = 3 -plot_colortable(mcolors.CSS4_COLORS, "CSS Colors") +plot_colortable(mcolors.BASE_COLORS, ncols=3, sort_colors=False) + +############################################################################# +# --------------- +# Tableau Palette +# --------------- -# Optionally plot the XKCD colors (Caution: will produce large figure) -# xkcd_fig = plot_colortable(mcolors.XKCD_COLORS, "XKCD Colors") -# xkcd_fig.savefig("XKCD_Colors.png") +plot_colortable(mcolors.TABLEAU_COLORS, ncols=2, sort_colors=False) -plt.show() +############################################################################# +# ---------- +# CSS Colors +# ---------- +# sphinx_gallery_thumbnail_number = 3 +plot_colortable(mcolors.CSS4_COLORS) +plt.show() ############################################################################# +# ----------- +# XKCD Colors +# ----------- +# XKCD colors are supported, but they produce a large figure, so we skip them +# for now. You can use the following code if desired:: +# +# xkcd_fig = plot_colortable(mcolors.XKCD_COLORS) +# xkcd_fig.savefig("XKCD_Colors.png") # # .. admonition:: References # diff --git a/examples/event_handling/README.txt b/examples/event_handling/README.txt index a4c4b6524658..46aed29b56bc 100644 --- a/examples/event_handling/README.txt +++ b/examples/event_handling/README.txt @@ -3,11 +3,11 @@ Event handling ============== -Matplotlib supports :doc:`event handling` with -a GUI neutral event model, so you can connect to Matplotlib events without -knowledge of what user interface Matplotlib will ultimately be plugged in to. -This has two advantages: the code you write will be more portable, and -Matplotlib events are aware of things like data coordinate space and which -axes the event occurs in so you don't have to mess with low level -transformation details to go from canvas space to data space. Object picking +Matplotlib supports :doc:`event handling` with +a GUI neutral event model, so you can connect to Matplotlib events without +knowledge of what user interface Matplotlib will ultimately be plugged in to. +This has two advantages: the code you write will be more portable, and +Matplotlib events are aware of things like data coordinate space and which +axes the event occurs in so you don't have to mess with low level +transformation details to go from canvas space to data space. Object picking examples are also included. diff --git a/examples/event_handling/close_event.py b/examples/event_handling/close_event.py index 9566167bdc6c..24b45b74ea48 100644 --- a/examples/event_handling/close_event.py +++ b/examples/event_handling/close_event.py @@ -4,6 +4,14 @@ =========== Example to show connecting events that occur when the figure closes. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import matplotlib.pyplot as plt diff --git a/examples/event_handling/coords_demo.py b/examples/event_handling/coords_demo.py index 4bfb49518ef5..38a5b77f68e4 100644 --- a/examples/event_handling/coords_demo.py +++ b/examples/event_handling/coords_demo.py @@ -5,6 +5,14 @@ An example of how to interact with the plotting canvas by connecting to move and click events. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ from matplotlib.backend_bases import MouseButton @@ -18,11 +26,9 @@ def on_move(event): - # get the x and y pixel coords - x, y = event.x, event.y if event.inaxes: - ax = event.inaxes # the axes instance - print('data coords %f %f' % (event.xdata, event.ydata)) + print(f'data coords {event.xdata} {event.ydata},', + f'pixel coords {event.x} {event.y}') def on_click(event): diff --git a/examples/misc/cursor_demo.py b/examples/event_handling/cursor_demo.py similarity index 84% rename from examples/misc/cursor_demo.py rename to examples/event_handling/cursor_demo.py index 5cbdecda82cc..e4acd77bce2f 100644 --- a/examples/misc/cursor_demo.py +++ b/examples/event_handling/cursor_demo.py @@ -1,15 +1,15 @@ """ ================= -Cross hair cursor +Cross-hair cursor ================= -This example adds a cross hair as a data cursor. The cross hair is +This example adds a cross-hair as a data cursor. The cross-hair is implemented as regular line objects that are updated on mouse move. We show three implementations: 1) A simple cursor implementation that redraws the figure on every mouse move. - This is a bit slow and you may notice some lag of the cross hair movement. + This is a bit slow, and you may notice some lag of the cross-hair movement. 2) A cursor that uses blitting for speedup of the rendering. 3) A cursor that snaps to data points. @@ -21,11 +21,15 @@ __ https://github.com/joferkington/mpldatacursor __ https://github.com/anntzer/mplcursors + +.. redirect-from:: /gallery/misc/cursor_demo """ import matplotlib.pyplot as plt import numpy as np +from matplotlib.backend_bases import MouseEvent + class Cursor: """ @@ -54,8 +58,8 @@ def on_mouse_move(self, event): self.set_cross_hair_visible(True) x, y = event.xdata, event.ydata # update the line positions - self.horizontal_line.set_ydata(y) - self.vertical_line.set_xdata(x) + self.horizontal_line.set_ydata([y]) + self.vertical_line.set_xdata([x]) self.text.set_text('x=%1.2f, y=%1.2f' % (x, y)) self.ax.figure.canvas.draw() @@ -69,23 +73,29 @@ def on_mouse_move(self, event): cursor = Cursor(ax) fig.canvas.mpl_connect('motion_notify_event', cursor.on_mouse_move) +# Simulate a mouse move to (0.5, 0.5), needed for online docs +t = ax.transData +MouseEvent( + "motion_notify_event", ax.figure.canvas, *t.transform((0.5, 0.5)) +)._process() ############################################################################## # Faster redrawing using blitting # """"""""""""""""""""""""""""""" # This technique stores the rendered plot as a background image. Only the -# changed artists (cross hair lines and text) are rendered anew. They are +# changed artists (cross-hair lines and text) are rendered anew. They are # combined with the background using blitting. # # This technique is significantly faster. It requires a bit more setup because -# the background has to be stored without the cross hair lines (see +# the background has to be stored without the cross-hair lines (see # ``create_new_background()``). Additionally, a new background has to be # created whenever the figure changes. This is achieved by connecting to the # ``'draw_event'``. + class BlittedCursor: """ - A cross hair cursor using blitting for faster redraw. + A cross-hair cursor using blitting for faster redraw. """ def __init__(self, ax): self.ax = ax @@ -130,8 +140,8 @@ def on_mouse_move(self, event): self.set_cross_hair_visible(True) # update the line positions x, y = event.xdata, event.ydata - self.horizontal_line.set_ydata(y) - self.vertical_line.set_xdata(x) + self.horizontal_line.set_ydata([y]) + self.vertical_line.set_xdata([x]) self.text.set_text('x=%1.2f, y=%1.2f' % (x, y)) self.ax.figure.canvas.restore_region(self.background) @@ -150,6 +160,11 @@ def on_mouse_move(self, event): blitted_cursor = BlittedCursor(ax) fig.canvas.mpl_connect('motion_notify_event', blitted_cursor.on_mouse_move) +# Simulate a mouse move to (0.5, 0.5), needed for online docs +t = ax.transData +MouseEvent( + "motion_notify_event", ax.figure.canvas, *t.transform((0.5, 0.5)) +)._process() ############################################################################## # Snapping to data points @@ -163,9 +178,10 @@ def on_mouse_move(self, event): # the lag due to many redraws. Of course, blitting could still be added on top # for additional speedup. + class SnappingCursor: """ - A cross hair cursor that snaps to the data point of a line, which is + A cross-hair cursor that snaps to the data point of a line, which is closest to the *x* position of the cursor. For simplicity, this assumes that *x* values of the data are sorted. @@ -202,8 +218,8 @@ def on_mouse_move(self, event): x = self.x[index] y = self.y[index] # update the line positions - self.horizontal_line.set_ydata(y) - self.vertical_line.set_xdata(x) + self.horizontal_line.set_ydata([y]) + self.vertical_line.set_xdata([x]) self.text.set_text('x=%1.2f, y=%1.2f' % (x, y)) self.ax.figure.canvas.draw() @@ -216,4 +232,11 @@ def on_mouse_move(self, event): line, = ax.plot(x, y, 'o') snap_cursor = SnappingCursor(ax, line) fig.canvas.mpl_connect('motion_notify_event', snap_cursor.on_mouse_move) + +# Simulate a mouse move to (0.5, 0.5), needed for online docs +t = ax.transData +MouseEvent( + "motion_notify_event", ax.figure.canvas, *t.transform((0.5, 0.5)) +)._process() + plt.show() diff --git a/examples/event_handling/data_browser.py b/examples/event_handling/data_browser.py index 6d2c68a3741d..7e1a551ba8a8 100644 --- a/examples/event_handling/data_browser.py +++ b/examples/event_handling/data_browser.py @@ -1,13 +1,21 @@ """ ============ -Data Browser +Data browser ============ Connecting data between multiple canvases. This example covers how to interact data with multiple canvases. This -let's you select and highlight a point on one axis, and generating the +lets you select and highlight a point on one axis, and generating the data of that point on the other axis. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np @@ -67,7 +75,7 @@ def update(self): dataind = self.lastind - ax2.cla() + ax2.clear() ax2.plot(X[dataind]) ax2.text(0.05, 0.9, f'mu={xs[dataind]:1.3f}\nsigma={ys[dataind]:1.3f}', diff --git a/examples/event_handling/figure_axes_enter_leave.py b/examples/event_handling/figure_axes_enter_leave.py index 8526346610f8..d55fb6e69d6a 100644 --- a/examples/event_handling/figure_axes_enter_leave.py +++ b/examples/event_handling/figure_axes_enter_leave.py @@ -5,6 +5,14 @@ Illustrate the figure and Axes enter and leave events by changing the frame colors on enter and leave. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import matplotlib.pyplot as plt diff --git a/examples/event_handling/ginput_manual_clabel_sgskip.py b/examples/event_handling/ginput_manual_clabel_sgskip.py index 72140fddc142..00e548bfccb4 100644 --- a/examples/event_handling/ginput_manual_clabel_sgskip.py +++ b/examples/event_handling/ginput_manual_clabel_sgskip.py @@ -6,10 +6,13 @@ This provides examples of uses of interactive functions, such as ginput, waitforbuttonpress and manual clabel placement. -This script must be run interactively using a backend that has a -graphical user interface (for example, using GTK3Agg backend, but not -PS backend). +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import time diff --git a/examples/event_handling/image_slices_viewer.py b/examples/event_handling/image_slices_viewer.py index 96cf8f64a3a0..8a2860902bce 100644 --- a/examples/event_handling/image_slices_viewer.py +++ b/examples/event_handling/image_slices_viewer.py @@ -1,51 +1,53 @@ """ -=================== -Image Slices Viewer -=================== +============ +Scroll event +============ -Scroll through 2D image slices of a 3D array. +In this example a scroll wheel event is used to scroll through 2D slices of +3D data. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np import matplotlib.pyplot as plt -# Fixing random state for reproducibility -np.random.seed(19680801) - - class IndexTracker: def __init__(self, ax, X): - self.ax = ax - ax.set_title('use scroll wheel to navigate images') - + self.index = 0 self.X = X - rows, cols, self.slices = X.shape - self.ind = self.slices//2 - - self.im = ax.imshow(self.X[:, :, self.ind]) + self.ax = ax + self.im = ax.imshow(self.X[:, :, self.index]) self.update() def on_scroll(self, event): - print("%s %s" % (event.button, event.step)) - if event.button == 'up': - self.ind = (self.ind + 1) % self.slices - else: - self.ind = (self.ind - 1) % self.slices + print(event.button, event.step) + increment = 1 if event.button == 'up' else -1 + max_index = self.X.shape[-1] - 1 + self.index = np.clip(self.index + increment, 0, max_index) self.update() def update(self): - self.im.set_data(self.X[:, :, self.ind]) - self.ax.set_ylabel('slice %s' % self.ind) + self.im.set_data(self.X[:, :, self.index]) + self.ax.set_title( + f'Use scroll wheel to navigate\nindex {self.index}') self.im.axes.figure.canvas.draw() -fig, ax = plt.subplots(1, 1) - -X = np.random.rand(20, 20, 40) +x, y, z = np.ogrid[-10:10:100j, -10:10:100j, 1:10:20j] +X = np.sin(x * y * z) / (x * y * z) +fig, ax = plt.subplots() +# create an IndexTracker and make sure it lives during the whole +# lifetime of the figure by assigning it to a variable tracker = IndexTracker(ax, X) - fig.canvas.mpl_connect('scroll_event', tracker.on_scroll) plt.show() diff --git a/examples/event_handling/keypress_demo.py b/examples/event_handling/keypress_demo.py index 6aff3b617ca3..e71e7ad3dfc4 100644 --- a/examples/event_handling/keypress_demo.py +++ b/examples/event_handling/keypress_demo.py @@ -4,6 +4,14 @@ ============== Show how to connect to keypress events. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import sys import numpy as np diff --git a/examples/event_handling/lasso_demo.py b/examples/event_handling/lasso_demo.py index b5f29b2fe3a6..cb9412079330 100644 --- a/examples/event_handling/lasso_demo.py +++ b/examples/event_handling/lasso_demo.py @@ -9,6 +9,14 @@ This is currently a proof-of-concept implementation (though it is usable as is). There will be some refinement of the API. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ from matplotlib import colors as mcolors, path @@ -45,7 +53,7 @@ def __init__(self, ax, data): 6, sizes=(100,), facecolors=facecolors, offsets=self.xys, - transOffset=ax.transData) + offset_transform=ax.transData) ax.add_collection(self.collection) diff --git a/examples/event_handling/legend_picking.py b/examples/event_handling/legend_picking.py index 5f8a3d1bb779..a33b9ba2088a 100644 --- a/examples/event_handling/legend_picking.py +++ b/examples/event_handling/legend_picking.py @@ -1,9 +1,17 @@ """ ============== -Legend Picking +Legend picking ============== Enable picking on the legend to toggle the original line on and off + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np @@ -34,7 +42,7 @@ def on_pick(event): origline = lined[legline] visible = not origline.get_visible() origline.set_visible(visible) - # Change the alpha on the line in the legend so we can see what lines + # Change the alpha on the line in the legend, so we can see what lines # have been toggled. legline.set_alpha(1.0 if visible else 0.2) fig.canvas.draw() diff --git a/examples/event_handling/looking_glass.py b/examples/event_handling/looking_glass.py index 139afab106f3..70fe4651f79d 100644 --- a/examples/event_handling/looking_glass.py +++ b/examples/event_handling/looking_glass.py @@ -4,6 +4,14 @@ ============= Example using mouse events to simulate a looking glass for inspecting data. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np import matplotlib.pyplot as plt diff --git a/examples/event_handling/path_editor.py b/examples/event_handling/path_editor.py index 9fe1a1470e64..972f581bf47b 100644 --- a/examples/event_handling/path_editor.py +++ b/examples/event_handling/path_editor.py @@ -1,12 +1,20 @@ """ =========== -Path Editor +Path editor =========== Sharing events across GUIs. This example demonstrates a cross-GUI application using Matplotlib event handling to interact with and modify objects on the canvas. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np @@ -39,7 +47,7 @@ class PathInteractor: """ - An path editor. + A path editor. Press 't' to toggle vertex markers on and off. When vertex markers are on, they can be dragged with the mouse. @@ -74,17 +82,12 @@ def get_ind_under_point(self, event): Return the index of the point closest to the event position or *None* if no point is within ``self.epsilon`` to the event position. """ - # display coords - xy = np.asarray(self.pathpatch.get_path().vertices) - xyt = self.pathpatch.get_transform().transform(xy) + xy = self.pathpatch.get_path().vertices + xyt = self.pathpatch.get_transform().transform(xy) # to display coords xt, yt = xyt[:, 0], xyt[:, 1] d = np.sqrt((xt - event.x)**2 + (yt - event.y)**2) ind = d.argmin() - - if d[ind] >= self.epsilon: - ind = None - - return ind + return ind if d[ind] < self.epsilon else None def on_draw(self, event): """Callback for draws.""" diff --git a/examples/event_handling/pick_event_demo.py b/examples/event_handling/pick_event_demo.py index 84c635c27ca8..63f2bd130f17 100644 --- a/examples/event_handling/pick_event_demo.py +++ b/examples/event_handling/pick_event_demo.py @@ -1,12 +1,11 @@ """ =============== -Pick Event Demo +Pick event demo =============== - You can enable picking by setting the "picker" property of an artist -(for example, a matplotlib Line2D, Text, Patch, Polygon, AxesImage, -etc...) +(for example, a Matplotlib Line2D, Text, Patch, Polygon, AxesImage, +etc.) There are a variety of meanings of the picker property: @@ -22,7 +21,7 @@ of the data within epsilon of the pick event * function - if picker is callable, it is a user supplied function which - determines whether the artist is hit by the mouse event. + determines whether the artist is hit by the mouse event. :: hit, props = picker(artist, mouseevent) @@ -32,7 +31,7 @@ After you have enabled an artist for picking by setting the "picker" property, you need to connect to the figure canvas pick_event to get -pick callbacks on mouse press events. For example, +pick callbacks on mouse press events. For example, :: def pick_handler(event): mouseevent = event.mouseevent @@ -43,22 +42,33 @@ def pick_handler(event): The pick event (matplotlib.backend_bases.PickEvent) which is passed to your callback is always fired with two attributes: - mouseevent - the mouse event that generate the pick event. The - mouse event in turn has attributes like x and y (the coordinates in - display space, such as pixels from left, bottom) and xdata, ydata (the - coords in data space). Additionally, you can get information about - which buttons were pressed, which keys were pressed, which Axes - the mouse is over, etc. See matplotlib.backend_bases.MouseEvent - for details. +mouseevent + the mouse event that generate the pick event. + + The mouse event in turn has attributes like x and y (the coordinates in + display space, such as pixels from left, bottom) and xdata, ydata (the + coords in data space). Additionally, you can get information about + which buttons were pressed, which keys were pressed, which Axes + the mouse is over, etc. See matplotlib.backend_bases.MouseEvent + for details. - artist - the matplotlib.artist that generated the pick event. +artist + the matplotlib.artist that generated the pick event. Additionally, certain artists like Line2D and PatchCollection may -attach additional meta data like the indices into the data that meet +attach additional metadata like the indices into the data that meet the picker criteria (for example, all the points in the line that are within the specified epsilon tolerance) The examples below illustrate each of these methods. + +.. note:: + These examples exercises the interactive capabilities of Matplotlib, and + this will not appear in the static documentation. Please run this code on + your machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import matplotlib.pyplot as plt @@ -74,114 +84,125 @@ def pick_handler(event): np.random.seed(19680801) -def pick_simple(): - # simple picking, lines, rectangles and text - fig, (ax1, ax2) = plt.subplots(2, 1) - ax1.set_title('click on points, rectangles or text', picker=True) - ax1.set_ylabel('ylabel', picker=True, bbox=dict(facecolor='red')) - line, = ax1.plot(rand(100), 'o', picker=True, pickradius=5) - - # pick the rectangle - ax2.bar(range(10), rand(10), picker=True) - for label in ax2.get_xticklabels(): # make the xtick labels pickable - label.set_picker(True) - - def onpick1(event): - if isinstance(event.artist, Line2D): - thisline = event.artist - xdata = thisline.get_xdata() - ydata = thisline.get_ydata() - ind = event.ind - print('onpick1 line:', np.column_stack([xdata[ind], ydata[ind]])) - elif isinstance(event.artist, Rectangle): - patch = event.artist - print('onpick1 patch:', patch.get_path()) - elif isinstance(event.artist, Text): - text = event.artist - print('onpick1 text:', text.get_text()) - - fig.canvas.mpl_connect('pick_event', onpick1) - - -def pick_custom_hit(): - # picking with a custom hit test function - # you can define custom pickers by setting picker to a callable - # function. The function has the signature - # - # hit, props = func(artist, mouseevent) - # - # to determine the hit test. if the mouse event is over the artist, - # return hit=True and props is a dictionary of - # properties you want added to the PickEvent attributes - - def line_picker(line, mouseevent): - """ - Find the points within a certain distance from the mouseclick in - data coords and attach some extra attributes, pickx and picky - which are the data points that were picked. - """ - if mouseevent.xdata is None: - return False, dict() - xdata = line.get_xdata() - ydata = line.get_ydata() - maxd = 0.05 - d = np.sqrt( - (xdata - mouseevent.xdata)**2 + (ydata - mouseevent.ydata)**2) - - ind, = np.nonzero(d <= maxd) - if len(ind): - pickx = xdata[ind] - picky = ydata[ind] - props = dict(ind=ind, pickx=pickx, picky=picky) - return True, props - else: - return False, dict() - - def onpick2(event): - print('onpick2 line:', event.pickx, event.picky) - - fig, ax = plt.subplots() - ax.set_title('custom picker for line data') - line, = ax.plot(rand(100), rand(100), 'o', picker=line_picker) - fig.canvas.mpl_connect('pick_event', onpick2) - - -def pick_scatter_plot(): - # picking on a scatter plot (matplotlib.collections.RegularPolyCollection) - - x, y, c, s = rand(4, 100) - - def onpick3(event): +############################################################################# +# Simple picking, lines, rectangles and text +# ------------------------------------------ + +fig, (ax1, ax2) = plt.subplots(2, 1) +ax1.set_title('click on points, rectangles or text', picker=True) +ax1.set_ylabel('ylabel', picker=True, bbox=dict(facecolor='red')) +line, = ax1.plot(rand(100), 'o', picker=True, pickradius=5) + +# Pick the rectangle. +ax2.bar(range(10), rand(10), picker=True) +for label in ax2.get_xticklabels(): # Make the xtick labels pickable. + label.set_picker(True) + + +def onpick1(event): + if isinstance(event.artist, Line2D): + thisline = event.artist + xdata = thisline.get_xdata() + ydata = thisline.get_ydata() ind = event.ind - print('onpick3 scatter:', ind, x[ind], y[ind]) - - fig, ax = plt.subplots() - ax.scatter(x, y, 100*s, c, picker=True) - fig.canvas.mpl_connect('pick_event', onpick3) - - -def pick_image(): - # picking images (matplotlib.image.AxesImage) - fig, ax = plt.subplots() - ax.imshow(rand(10, 5), extent=(1, 2, 1, 2), picker=True) - ax.imshow(rand(5, 10), extent=(3, 4, 1, 2), picker=True) - ax.imshow(rand(20, 25), extent=(1, 2, 3, 4), picker=True) - ax.imshow(rand(30, 12), extent=(3, 4, 3, 4), picker=True) - ax.set(xlim=(0, 5), ylim=(0, 5)) - - def onpick4(event): - artist = event.artist - if isinstance(artist, AxesImage): - im = artist - A = im.get_array() - print('onpick4 image', A.shape) - - fig.canvas.mpl_connect('pick_event', onpick4) - - -if __name__ == '__main__': - pick_simple() - pick_custom_hit() - pick_scatter_plot() - pick_image() - plt.show() + print('onpick1 line:', np.column_stack([xdata[ind], ydata[ind]])) + elif isinstance(event.artist, Rectangle): + patch = event.artist + print('onpick1 patch:', patch.get_path()) + elif isinstance(event.artist, Text): + text = event.artist + print('onpick1 text:', text.get_text()) + + +fig.canvas.mpl_connect('pick_event', onpick1) + + +############################################################################# +# Picking with a custom hit test function +# --------------------------------------- +# You can define custom pickers by setting picker to a callable function. The +# function has the signature:: +# +# hit, props = func(artist, mouseevent) +# +# to determine the hit test. If the mouse event is over the artist, return +# ``hit=True`` and ``props`` is a dictionary of properties you want added to +# the `.PickEvent` attributes. + +def line_picker(line, mouseevent): + """ + Find the points within a certain distance from the mouseclick in + data coords and attach some extra attributes, pickx and picky + which are the data points that were picked. + """ + if mouseevent.xdata is None: + return False, dict() + xdata = line.get_xdata() + ydata = line.get_ydata() + maxd = 0.05 + d = np.sqrt( + (xdata - mouseevent.xdata)**2 + (ydata - mouseevent.ydata)**2) + + ind, = np.nonzero(d <= maxd) + if len(ind): + pickx = xdata[ind] + picky = ydata[ind] + props = dict(ind=ind, pickx=pickx, picky=picky) + return True, props + else: + return False, dict() + + +def onpick2(event): + print('onpick2 line:', event.pickx, event.picky) + + +fig, ax = plt.subplots() +ax.set_title('custom picker for line data') +line, = ax.plot(rand(100), rand(100), 'o', picker=line_picker) +fig.canvas.mpl_connect('pick_event', onpick2) + + +############################################################################# +# Picking on a scatter plot +# ------------------------- +# A scatter plot is backed by a `~matplotlib.collections.PathCollection`. + +x, y, c, s = rand(4, 100) + + +def onpick3(event): + ind = event.ind + print('onpick3 scatter:', ind, x[ind], y[ind]) + + +fig, ax = plt.subplots() +ax.scatter(x, y, 100*s, c, picker=True) +fig.canvas.mpl_connect('pick_event', onpick3) + + +############################################################################# +# Picking images +# -------------- +# Images plotted using `.Axes.imshow` are `~matplotlib.image.AxesImage` +# objects. + +fig, ax = plt.subplots() +ax.imshow(rand(10, 5), extent=(1, 2, 1, 2), picker=True) +ax.imshow(rand(5, 10), extent=(3, 4, 1, 2), picker=True) +ax.imshow(rand(20, 25), extent=(1, 2, 3, 4), picker=True) +ax.imshow(rand(30, 12), extent=(3, 4, 3, 4), picker=True) +ax.set(xlim=(0, 5), ylim=(0, 5)) + + +def onpick4(event): + artist = event.artist + if isinstance(artist, AxesImage): + im = artist + A = im.get_array() + print('onpick4 image', A.shape) + + +fig.canvas.mpl_connect('pick_event', onpick4) + +plt.show() diff --git a/examples/event_handling/pick_event_demo2.py b/examples/event_handling/pick_event_demo2.py index 162fbdf65ade..01622cb87e76 100644 --- a/examples/event_handling/pick_event_demo2.py +++ b/examples/event_handling/pick_event_demo2.py @@ -1,11 +1,19 @@ """ -================ -Pick Event Demo2 -================ +================= +Pick event demo 2 +================= Compute the mean (mu) and standard deviation (sigma) of 100 data sets and plot mu vs. sigma. When you click on one of the (mu, sigma) points, plot the raw data from the dataset that generated this point. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np import matplotlib.pyplot as plt @@ -26,11 +34,11 @@ def onpick(event): if event.artist != line: - return True + return N = len(event.ind) if not N: - return True + return figi, axs = plt.subplots(N, squeeze=False) for ax, dataind in zip(axs.flat, event.ind): @@ -39,7 +47,7 @@ def onpick(event): transform=ax.transAxes, va='top') ax.set_ylim(-0.5, 1.5) figi.show() - return True + fig.canvas.mpl_connect('pick_event', onpick) diff --git a/examples/event_handling/pipong.py b/examples/event_handling/pipong.py deleted file mode 100644 index b3064d0c84ee..000000000000 --- a/examples/event_handling/pipong.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -====== -Pipong -====== - -A Matplotlib based game of Pong illustrating one way to write interactive -animation which are easily ported to multiple backends -pipong.py was written by Paul Ivanov -""" - - -import numpy as np -import matplotlib.pyplot as plt -from numpy.random import randn, randint -from matplotlib.font_manager import FontProperties - -instructions = """ -Player A: Player B: - 'e' up 'i' - 'd' down 'k' - -press 't' -- close these instructions - (animation will be much faster) -press 'a' -- add a puck -press 'A' -- remove a puck -press '1' -- slow down all pucks -press '2' -- speed up all pucks -press '3' -- slow down distractors -press '4' -- speed up distractors -press ' ' -- reset the first puck -press 'n' -- toggle distractors on/off -press 'g' -- toggle the game on/off - - """ - - -class Pad: - def __init__(self, disp, x, y, type='l'): - self.disp = disp - self.x = x - self.y = y - self.w = .3 - self.score = 0 - self.xoffset = 0.3 - self.yoffset = 0.1 - if type == 'r': - self.xoffset *= -1.0 - - if type == 'l' or type == 'r': - self.signx = -1.0 - self.signy = 1.0 - else: - self.signx = 1.0 - self.signy = -1.0 - - def contains(self, loc): - return self.disp.get_bbox().contains(loc.x, loc.y) - - -class Puck: - def __init__(self, disp, pad, field): - self.vmax = .2 - self.disp = disp - self.field = field - self._reset(pad) - - def _reset(self, pad): - self.x = pad.x + pad.xoffset - if pad.y < 0: - self.y = pad.y + pad.yoffset - else: - self.y = pad.y - pad.yoffset - self.vx = pad.x - self.x - self.vy = pad.y + pad.w/2 - self.y - self._speedlimit() - self._slower() - self._slower() - - def update(self, pads): - self.x += self.vx - self.y += self.vy - for pad in pads: - if pad.contains(self): - self.vx *= 1.2 * pad.signx - self.vy *= 1.2 * pad.signy - fudge = .001 - # probably cleaner with something like... - if self.x < fudge: - pads[1].score += 1 - self._reset(pads[0]) - return True - if self.x > 7 - fudge: - pads[0].score += 1 - self._reset(pads[1]) - return True - if self.y < -1 + fudge or self.y > 1 - fudge: - self.vy *= -1.0 - # add some randomness, just to make it interesting - self.vy -= (randn()/300.0 + 1/300.0) * np.sign(self.vy) - self._speedlimit() - return False - - def _slower(self): - self.vx /= 5.0 - self.vy /= 5.0 - - def _faster(self): - self.vx *= 5.0 - self.vy *= 5.0 - - def _speedlimit(self): - if self.vx > self.vmax: - self.vx = self.vmax - if self.vx < -self.vmax: - self.vx = -self.vmax - - if self.vy > self.vmax: - self.vy = self.vmax - if self.vy < -self.vmax: - self.vy = -self.vmax - - -class Game: - def __init__(self, ax): - # create the initial line - self.ax = ax - ax.xaxis.set_visible(False) - ax.set_xlim([0, 7]) - ax.yaxis.set_visible(False) - ax.set_ylim([-1, 1]) - pad_a_x = 0 - pad_b_x = .50 - pad_a_y = pad_b_y = .30 - pad_b_x += 6.3 - - # pads - pA, = self.ax.barh(pad_a_y, .2, - height=.3, color='k', alpha=.5, edgecolor='b', - lw=2, label="Player B", - animated=True) - pB, = self.ax.barh(pad_b_y, .2, - height=.3, left=pad_b_x, color='k', alpha=.5, - edgecolor='r', lw=2, label="Player A", - animated=True) - - # distractors - self.x = np.arange(0, 2.22*np.pi, 0.01) - self.line, = self.ax.plot(self.x, np.sin(self.x), "r", - animated=True, lw=4) - self.line2, = self.ax.plot(self.x, np.cos(self.x), "g", - animated=True, lw=4) - self.line3, = self.ax.plot(self.x, np.cos(self.x), "g", - animated=True, lw=4) - self.line4, = self.ax.plot(self.x, np.cos(self.x), "r", - animated=True, lw=4) - - # center line - self.centerline, = self.ax.plot([3.5, 3.5], [1, -1], 'k', - alpha=.5, animated=True, lw=8) - - # puck (s) - self.puckdisp = self.ax.scatter([1], [1], label='_nolegend_', - s=200, c='g', - alpha=.9, animated=True) - - self.canvas = self.ax.figure.canvas - self.background = None - self.cnt = 0 - self.distract = True - self.res = 100.0 - self.on = False - self.inst = True # show instructions from the beginning - self.pads = [Pad(pA, pad_a_x, pad_a_y), - Pad(pB, pad_b_x, pad_b_y, 'r')] - self.pucks = [] - self.i = self.ax.annotate(instructions, (.5, 0.5), - name='monospace', - verticalalignment='center', - horizontalalignment='center', - multialignment='left', - xycoords='axes fraction', - animated=False) - self.canvas.mpl_connect('key_press_event', self.on_key_press) - - def draw(self, event): - draw_artist = self.ax.draw_artist - if self.background is None: - self.background = self.canvas.copy_from_bbox(self.ax.bbox) - - # restore the clean slate background - self.canvas.restore_region(self.background) - - # show the distractors - if self.distract: - self.line.set_ydata(np.sin(self.x + self.cnt/self.res)) - self.line2.set_ydata(np.cos(self.x - self.cnt/self.res)) - self.line3.set_ydata(np.tan(self.x + self.cnt/self.res)) - self.line4.set_ydata(np.tan(self.x - self.cnt/self.res)) - draw_artist(self.line) - draw_artist(self.line2) - draw_artist(self.line3) - draw_artist(self.line4) - - # pucks and pads - if self.on: - self.ax.draw_artist(self.centerline) - for pad in self.pads: - pad.disp.set_y(pad.y) - pad.disp.set_x(pad.x) - self.ax.draw_artist(pad.disp) - - for puck in self.pucks: - if puck.update(self.pads): - # we only get here if someone scored - self.pads[0].disp.set_label( - " " + str(self.pads[0].score)) - self.pads[1].disp.set_label( - " " + str(self.pads[1].score)) - self.ax.legend(loc='center', framealpha=.2, - facecolor='0.5', - prop=FontProperties(size='xx-large', - weight='bold')) - - self.background = None - self.ax.figure.canvas.draw_idle() - return True - puck.disp.set_offsets([[puck.x, puck.y]]) - self.ax.draw_artist(puck.disp) - - # just redraw the axes rectangle - self.canvas.blit(self.ax.bbox) - self.canvas.flush_events() - if self.cnt == 50000: - # just so we don't get carried away - print("...and you've been playing for too long!!!") - plt.close() - - self.cnt += 1 - return True - - def on_key_press(self, event): - if event.key == '3': - self.res *= 5.0 - if event.key == '4': - self.res /= 5.0 - - if event.key == 'e': - self.pads[0].y += .1 - if self.pads[0].y > 1 - .3: - self.pads[0].y = 1 - .3 - if event.key == 'd': - self.pads[0].y -= .1 - if self.pads[0].y < -1: - self.pads[0].y = -1 - - if event.key == 'i': - self.pads[1].y += .1 - if self.pads[1].y > 1 - .3: - self.pads[1].y = 1 - .3 - if event.key == 'k': - self.pads[1].y -= .1 - if self.pads[1].y < -1: - self.pads[1].y = -1 - - if event.key == 'a': - self.pucks.append(Puck(self.puckdisp, - self.pads[randint(2)], - self.ax.bbox)) - if event.key == 'A' and len(self.pucks): - self.pucks.pop() - if event.key == ' ' and len(self.pucks): - self.pucks[0]._reset(self.pads[randint(2)]) - if event.key == '1': - for p in self.pucks: - p._slower() - if event.key == '2': - for p in self.pucks: - p._faster() - - if event.key == 'n': - self.distract = not self.distract - - if event.key == 'g': - self.on = not self.on - if event.key == 't': - self.inst = not self.inst - self.i.set_visible(not self.i.get_visible()) - self.background = None - self.canvas.draw_idle() - if event.key == 'q': - plt.close() diff --git a/examples/event_handling/poly_editor.py b/examples/event_handling/poly_editor.py index 2d7051685f43..8779a243b925 100644 --- a/examples/event_handling/poly_editor.py +++ b/examples/event_handling/poly_editor.py @@ -5,38 +5,33 @@ This is an example to show how to build cross-GUI applications using Matplotlib event handling to interact with objects on the canvas. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ + import numpy as np from matplotlib.lines import Line2D from matplotlib.artist import Artist -def dist(x, y): - """ - Return the distance between two points. - """ - d = x - y - return np.sqrt(np.dot(d, d)) - - def dist_point_to_segment(p, s0, s1): """ - Get the distance of a point to a segment. - *p*, *s0*, *s1* are *xy* sequences - This algorithm from - http://www.geomalgorithms.com/algorithms.html + Get the distance from the point *p* to the segment (*s0*, *s1*), where + *p*, *s0*, *s1* are ``[x, y]`` arrays. """ - v = s1 - s0 - w = p - s0 - c1 = np.dot(w, v) - if c1 <= 0: - return dist(p, s0) - c2 = np.dot(v, v) - if c2 <= c1: - return dist(p, s1) - b = c1 / c2 - pb = s0 + b * v - return dist(p, pb) + s01 = s1 - s0 + s0p = p - s0 + if (s01 == 0).all(): + return np.hypot(*s0p) + # Project onto segment, without going past segment ends. + p1 = s0 + np.clip((s0p @ s01) / (s01 @ s01), 0, 1) * s01 + return np.hypot(*(p - p1)) class PolygonInteractor: diff --git a/examples/event_handling/pong_sgskip.py b/examples/event_handling/pong_sgskip.py index 416bdf048dbe..23b330165b36 100644 --- a/examples/event_handling/pong_sgskip.py +++ b/examples/event_handling/pong_sgskip.py @@ -3,23 +3,302 @@ Pong ==== -A small game demo using Matplotlib. +A Matplotlib based game of Pong illustrating one way to write interactive +animations that are easily ported to multiple backends. -.. only:: builder_html - - This example requires :download:`pipong.py ` +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ -import time +import time +import numpy as np import matplotlib.pyplot as plt -import pipong +from numpy.random import randn, randint +from matplotlib.font_manager import FontProperties + +instructions = """ +Player A: Player B: + 'e' up 'i' + 'd' down 'k' + +press 't' -- close these instructions + (animation will be much faster) +press 'a' -- add a puck +press 'A' -- remove a puck +press '1' -- slow down all pucks +press '2' -- speed up all pucks +press '3' -- slow down distractors +press '4' -- speed up distractors +press ' ' -- reset the first puck +press 'n' -- toggle distractors on/off +press 'g' -- toggle the game on/off + + """ + + +class Pad: + def __init__(self, disp, x, y, type='l'): + self.disp = disp + self.x = x + self.y = y + self.w = .3 + self.score = 0 + self.xoffset = 0.3 + self.yoffset = 0.1 + if type == 'r': + self.xoffset *= -1.0 + + if type == 'l' or type == 'r': + self.signx = -1.0 + self.signy = 1.0 + else: + self.signx = 1.0 + self.signy = -1.0 + + def contains(self, loc): + return self.disp.get_bbox().contains(loc.x, loc.y) + + +class Puck: + def __init__(self, disp, pad, field): + self.vmax = .2 + self.disp = disp + self.field = field + self._reset(pad) + + def _reset(self, pad): + self.x = pad.x + pad.xoffset + if pad.y < 0: + self.y = pad.y + pad.yoffset + else: + self.y = pad.y - pad.yoffset + self.vx = pad.x - self.x + self.vy = pad.y + pad.w/2 - self.y + self._speedlimit() + self._slower() + self._slower() + + def update(self, pads): + self.x += self.vx + self.y += self.vy + for pad in pads: + if pad.contains(self): + self.vx *= 1.2 * pad.signx + self.vy *= 1.2 * pad.signy + fudge = .001 + # probably cleaner with something like... + if self.x < fudge: + pads[1].score += 1 + self._reset(pads[0]) + return True + if self.x > 7 - fudge: + pads[0].score += 1 + self._reset(pads[1]) + return True + if self.y < -1 + fudge or self.y > 1 - fudge: + self.vy *= -1.0 + # add some randomness, just to make it interesting + self.vy -= (randn()/300.0 + 1/300.0) * np.sign(self.vy) + self._speedlimit() + return False + + def _slower(self): + self.vx /= 5.0 + self.vy /= 5.0 + + def _faster(self): + self.vx *= 5.0 + self.vy *= 5.0 + + def _speedlimit(self): + if self.vx > self.vmax: + self.vx = self.vmax + if self.vx < -self.vmax: + self.vx = -self.vmax + + if self.vy > self.vmax: + self.vy = self.vmax + if self.vy < -self.vmax: + self.vy = -self.vmax + + +class Game: + def __init__(self, ax): + # create the initial line + self.ax = ax + ax.xaxis.set_visible(False) + ax.set_xlim([0, 7]) + ax.yaxis.set_visible(False) + ax.set_ylim([-1, 1]) + pad_a_x = 0 + pad_b_x = .50 + pad_a_y = pad_b_y = .30 + pad_b_x += 6.3 + + # pads + pA, = self.ax.barh(pad_a_y, .2, + height=.3, color='k', alpha=.5, edgecolor='b', + lw=2, label="Player B", + animated=True) + pB, = self.ax.barh(pad_b_y, .2, + height=.3, left=pad_b_x, color='k', alpha=.5, + edgecolor='r', lw=2, label="Player A", + animated=True) + + # distractors + self.x = np.arange(0, 2.22*np.pi, 0.01) + self.line, = self.ax.plot(self.x, np.sin(self.x), "r", + animated=True, lw=4) + self.line2, = self.ax.plot(self.x, np.cos(self.x), "g", + animated=True, lw=4) + self.line3, = self.ax.plot(self.x, np.cos(self.x), "g", + animated=True, lw=4) + self.line4, = self.ax.plot(self.x, np.cos(self.x), "r", + animated=True, lw=4) + + # center line + self.centerline, = self.ax.plot([3.5, 3.5], [1, -1], 'k', + alpha=.5, animated=True, lw=8) + + # puck (s) + self.puckdisp = self.ax.scatter([1], [1], label='_nolegend_', + s=200, c='g', + alpha=.9, animated=True) + + self.canvas = self.ax.figure.canvas + self.background = None + self.cnt = 0 + self.distract = True + self.res = 100.0 + self.on = False + self.inst = True # show instructions from the beginning + self.pads = [Pad(pA, pad_a_x, pad_a_y), + Pad(pB, pad_b_x, pad_b_y, 'r')] + self.pucks = [] + self.i = self.ax.annotate(instructions, (.5, 0.5), + name='monospace', + verticalalignment='center', + horizontalalignment='center', + multialignment='left', + xycoords='axes fraction', + animated=False) + self.canvas.mpl_connect('key_press_event', self.on_key_press) + + def draw(self): + draw_artist = self.ax.draw_artist + if self.background is None: + self.background = self.canvas.copy_from_bbox(self.ax.bbox) + + # restore the clean slate background + self.canvas.restore_region(self.background) + + # show the distractors + if self.distract: + self.line.set_ydata(np.sin(self.x + self.cnt/self.res)) + self.line2.set_ydata(np.cos(self.x - self.cnt/self.res)) + self.line3.set_ydata(np.tan(self.x + self.cnt/self.res)) + self.line4.set_ydata(np.tan(self.x - self.cnt/self.res)) + draw_artist(self.line) + draw_artist(self.line2) + draw_artist(self.line3) + draw_artist(self.line4) + + # pucks and pads + if self.on: + self.ax.draw_artist(self.centerline) + for pad in self.pads: + pad.disp.set_y(pad.y) + pad.disp.set_x(pad.x) + self.ax.draw_artist(pad.disp) + + for puck in self.pucks: + if puck.update(self.pads): + # we only get here if someone scored + self.pads[0].disp.set_label(f" {self.pads[0].score}") + self.pads[1].disp.set_label(f" {self.pads[1].score}") + self.ax.legend(loc='center', framealpha=.2, + facecolor='0.5', + prop=FontProperties(size='xx-large', + weight='bold')) + + self.background = None + self.ax.figure.canvas.draw_idle() + return + puck.disp.set_offsets([[puck.x, puck.y]]) + self.ax.draw_artist(puck.disp) + + # just redraw the axes rectangle + self.canvas.blit(self.ax.bbox) + self.canvas.flush_events() + if self.cnt == 50000: + # just so we don't get carried away + print("...and you've been playing for too long!!!") + plt.close() + + self.cnt += 1 + + def on_key_press(self, event): + if event.key == '3': + self.res *= 5.0 + if event.key == '4': + self.res /= 5.0 + + if event.key == 'e': + self.pads[0].y += .1 + if self.pads[0].y > 1 - .3: + self.pads[0].y = 1 - .3 + if event.key == 'd': + self.pads[0].y -= .1 + if self.pads[0].y < -1: + self.pads[0].y = -1 + + if event.key == 'i': + self.pads[1].y += .1 + if self.pads[1].y > 1 - .3: + self.pads[1].y = 1 - .3 + if event.key == 'k': + self.pads[1].y -= .1 + if self.pads[1].y < -1: + self.pads[1].y = -1 + + if event.key == 'a': + self.pucks.append(Puck(self.puckdisp, + self.pads[randint(2)], + self.ax.bbox)) + if event.key == 'A' and len(self.pucks): + self.pucks.pop() + if event.key == ' ' and len(self.pucks): + self.pucks[0]._reset(self.pads[randint(2)]) + if event.key == '1': + for p in self.pucks: + p._slower() + if event.key == '2': + for p in self.pucks: + p._faster() + + if event.key == 'n': + self.distract = not self.distract + + if event.key == 'g': + self.on = not self.on + if event.key == 't': + self.inst = not self.inst + self.i.set_visible(not self.i.get_visible()) + self.background = None + self.canvas.draw_idle() + if event.key == 'q': + plt.close() fig, ax = plt.subplots() canvas = ax.figure.canvas -animation = pipong.Game(ax) +animation = Game(ax) # disable the default key bindings if fig.canvas.manager.key_press_handler_id is not None: @@ -35,10 +314,7 @@ def on_redraw(event): def start_anim(event): canvas.mpl_disconnect(start_anim.cid) - def local_draw(): - if animation.ax.get_renderer_cache(): - animation.draw(None) - start_anim.timer.add_callback(local_draw) + start_anim.timer.add_callback(animation.draw) start_anim.timer.start() canvas.mpl_connect('draw_event', on_redraw) diff --git a/examples/event_handling/resample.py b/examples/event_handling/resample.py index 542e84f13c71..c7f94f1b7629 100644 --- a/examples/event_handling/resample.py +++ b/examples/event_handling/resample.py @@ -6,6 +6,14 @@ Downsampling lowers the sample rate or sample size of a signal. In this tutorial, the signal is downsampled when the plot is adjusted through dragging and zooming. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np @@ -37,7 +45,7 @@ def downsample(self, xstart, xend): xdata = xdata[::ratio] ydata = ydata[::ratio] - print("using {} of {} visible points".format(len(ydata), np.sum(mask))) + print(f"using {len(ydata)} of {np.sum(mask)} visible points") return xdata, ydata diff --git a/examples/event_handling/timers.py b/examples/event_handling/timers.py index ac064b261bcb..43330097cf87 100644 --- a/examples/event_handling/timers.py +++ b/examples/event_handling/timers.py @@ -5,6 +5,14 @@ Simple example of using general timer objects. This is used to update the time placed in the title of the figure. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import matplotlib.pyplot as plt import numpy as np diff --git a/examples/event_handling/trifinder_event_demo.py b/examples/event_handling/trifinder_event_demo.py index 6678003ab189..991c5c9a3a82 100644 --- a/examples/event_handling/trifinder_event_demo.py +++ b/examples/event_handling/trifinder_event_demo.py @@ -6,6 +6,14 @@ Example showing the use of a TriFinder object. As the mouse is moved over the triangulation, the triangle under the cursor is highlighted and the index of the triangle is displayed in the plot title. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import matplotlib.pyplot as plt from matplotlib.tri import Triangulation diff --git a/examples/event_handling/viewlims.py b/examples/event_handling/viewlims.py index d6bdf99787d3..134419300a68 100644 --- a/examples/event_handling/viewlims.py +++ b/examples/event_handling/viewlims.py @@ -5,6 +5,14 @@ Creates two identical panels. Zooming in on the right panel will show a rectangle in the first panel, denoting the zoomed region. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import numpy as np import matplotlib.pyplot as plt diff --git a/examples/event_handling/zoom_window.py b/examples/event_handling/zoom_window.py index 9afe60ce935c..c10cd820aa47 100644 --- a/examples/event_handling/zoom_window.py +++ b/examples/event_handling/zoom_window.py @@ -12,6 +12,14 @@ Note the diameter of the circles in the scatter are defined in points**2, so their size is independent of the zoom. + +.. note:: + This example exercises the interactive capabilities of Matplotlib, and this + will not appear in the static documentation. Please run this code on your + machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example + using the link at the bottom of the page. """ import matplotlib.pyplot as plt @@ -21,8 +29,8 @@ # Fixing random state for reproducibility np.random.seed(19680801) -figsrc, axsrc = plt.subplots() -figzoom, axzoom = plt.subplots() +figsrc, axsrc = plt.subplots(figsize=(3.7, 3.7)) +figzoom, axzoom = plt.subplots(figsize=(3.7, 3.7)) axsrc.set(xlim=(0, 1), ylim=(0, 1), autoscale_on=False, title='Click to zoom') axzoom.set(xlim=(0.45, 0.55), ylim=(0.4, 0.6), autoscale_on=False, diff --git a/examples/frontpage/3D.py b/examples/frontpage/3D.py deleted file mode 100644 index 49ebcc008923..000000000000 --- a/examples/frontpage/3D.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -==================== -Frontpage 3D example -==================== - -This example reproduces the frontpage 3D example. -""" - -from matplotlib import cbook -from matplotlib import cm -from matplotlib.colors import LightSource -import matplotlib.pyplot as plt -import numpy as np - -dem = cbook.get_sample_data('jacksboro_fault_dem.npz', np_load=True) -z = dem['elevation'] -nrows, ncols = z.shape -x = np.linspace(dem['xmin'], dem['xmax'], ncols) -y = np.linspace(dem['ymin'], dem['ymax'], nrows) -x, y = np.meshgrid(x, y) - -region = np.s_[5:50, 5:50] -x, y, z = x[region], y[region], z[region] - -fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) - -ls = LightSource(270, 45) -# To use a custom hillshading mode, override the built-in shading and pass -# in the rgb colors of the shaded surface calculated from "shade". -rgb = ls.shade(z, cmap=cm.gist_earth, vert_exag=0.1, blend_mode='soft') -surf = ax.plot_surface(x, y, z, rstride=1, cstride=1, facecolors=rgb, - linewidth=0, antialiased=False, shade=False) -ax.set_xticks([]) -ax.set_yticks([]) -ax.set_zticks([]) -fig.savefig("surface3d_frontpage.png", dpi=25) # results in 160x120 px image diff --git a/examples/frontpage/README.txt b/examples/frontpage/README.txt deleted file mode 100644 index 1f70a8a9fda2..000000000000 --- a/examples/frontpage/README.txt +++ /dev/null @@ -1,4 +0,0 @@ -.. _front_page_examples: - -Front Page -========== diff --git a/examples/frontpage/contour_frontpage.py b/examples/frontpage/contour_frontpage.py deleted file mode 100644 index ddb0721c1203..000000000000 --- a/examples/frontpage/contour_frontpage.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -========================= -Frontpage contour example -========================= - -This example reproduces the frontpage contour example. -""" - -import matplotlib.pyplot as plt -import numpy as np -from matplotlib import cm - -extent = (-3, 3, -3, 3) - -delta = 0.5 -x = np.arange(-3.0, 4.001, delta) -y = np.arange(-4.0, 3.001, delta) -X, Y = np.meshgrid(x, y) -Z1 = np.exp(-X**2 - Y**2) -Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) -Z = Z1 - Z2 - -norm = cm.colors.Normalize(vmax=abs(Z).max(), vmin=-abs(Z).max()) - -fig, ax = plt.subplots() -cset1 = ax.contourf( - X, Y, Z, 40, - norm=norm) -ax.set_xlim(-2, 2) -ax.set_ylim(-2, 2) -ax.set_xticks([]) -ax.set_yticks([]) -fig.savefig("contour_frontpage.png", dpi=25) # results in 160x120 px image -plt.show() diff --git a/examples/frontpage/histogram.py b/examples/frontpage/histogram.py deleted file mode 100644 index a0938bdb8916..000000000000 --- a/examples/frontpage/histogram.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -=========================== -Frontpage histogram example -=========================== - -This example reproduces the frontpage histogram example. -""" - -import matplotlib.pyplot as plt -import numpy as np - - -random_state = np.random.RandomState(19680801) -X = random_state.randn(10000) - -fig, ax = plt.subplots() -ax.hist(X, bins=25, density=True) -x = np.linspace(-5, 5, 1000) -ax.plot(x, 1 / np.sqrt(2*np.pi) * np.exp(-(x**2)/2), linewidth=4) -ax.set_xticks([]) -ax.set_yticks([]) -fig.savefig("histogram_frontpage.png", dpi=25) # results in 160x120 px image diff --git a/examples/frontpage/membrane.py b/examples/frontpage/membrane.py deleted file mode 100644 index 4e126eceda5f..000000000000 --- a/examples/frontpage/membrane.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -====================== -Frontpage plot example -====================== - -This example reproduces the frontpage simple plot example. -""" - -import matplotlib.pyplot as plt -import matplotlib.cbook as cbook -import numpy as np - - -with cbook.get_sample_data('membrane.dat') as datafile: - x = np.fromfile(datafile, np.float32) -# 0.0005 is the sample interval - -fig, ax = plt.subplots() -ax.plot(x, linewidth=4) -ax.set_xlim(5000, 6000) -ax.set_ylim(-0.6, 0.1) -ax.set_xticks([]) -ax.set_yticks([]) -fig.savefig("membrane_frontpage.png", dpi=25) # results in 160x120 px image diff --git a/examples/userdemo/colormap_interactive_adjustment.py b/examples/images_contours_and_fields/colormap_interactive_adjustment.py similarity index 94% rename from examples/userdemo/colormap_interactive_adjustment.py rename to examples/images_contours_and_fields/colormap_interactive_adjustment.py index bdea2966c431..3ab9074fd1b6 100644 --- a/examples/userdemo/colormap_interactive_adjustment.py +++ b/examples/images_contours_and_fields/colormap_interactive_adjustment.py @@ -14,6 +14,8 @@ one can zoom out on an axis. When panning, the vmin and vmax of the norm are both shifted according to the direction of movement. The Home/Back/Forward buttons can also be used to get back to a previous state. + +.. redirect-from:: /gallery/userdemo/colormap_interactive_adjustment """ import matplotlib.pyplot as plt import numpy as np diff --git a/examples/userdemo/colormap_normalizations.py b/examples/images_contours_and_fields/colormap_normalizations.py similarity index 93% rename from examples/userdemo/colormap_normalizations.py rename to examples/images_contours_and_fields/colormap_normalizations.py index febccf35a449..7ad3423cce68 100644 --- a/examples/userdemo/colormap_normalizations.py +++ b/examples/images_contours_and_fields/colormap_normalizations.py @@ -1,9 +1,11 @@ """ ======================= -Colormap Normalizations +Colormap normalizations ======================= Demonstration of using norm to map colormaps onto data in non-linear ways. + +.. redirect-from:: /gallery/userdemo/colormap_normalizations """ import numpy as np @@ -18,8 +20,8 @@ X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)] # A low hump with a spike coming out of the top. Needs to have -# z/colour axis on a log scale so we see both hump and spike. linear -# scale only shows the spike. +# z/colour axis on a log scale, so we see both hump and spike. +# A linear scale only shows the spike. Z1 = np.exp(-X**2 - Y**2) Z2 = np.exp(-(X * 10)**2 - (Y * 10)**2) @@ -61,19 +63,17 @@ # Note that colorbar labels do not come out looking very good. X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)] -Z1 = 5 * np.exp(-X**2 - Y**2) -Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) -Z = (Z1 - Z2) * 2 +Z = 5 * np.exp(-X**2 - Y**2) fig, ax = plt.subplots(2, 1) -pcm = ax[0].pcolormesh(X, Y, Z1, +pcm = ax[0].pcolormesh(X, Y, Z, norm=colors.SymLogNorm(linthresh=0.03, linscale=0.03, vmin=-1.0, vmax=1.0, base=10), cmap='RdBu_r', shading='nearest') fig.colorbar(pcm, ax=ax[0], extend='both') -pcm = ax[1].pcolormesh(X, Y, Z1, cmap='RdBu_r', vmin=-np.max(Z1), +pcm = ax[1].pcolormesh(X, Y, Z, cmap='RdBu_r', vmin=-np.max(Z), shading='nearest') fig.colorbar(pcm, ax=ax[1], extend='both') diff --git a/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py b/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py new file mode 100644 index 000000000000..c5bd9d05f4dc --- /dev/null +++ b/examples/images_contours_and_fields/colormap_normalizations_symlognorm.py @@ -0,0 +1,84 @@ +""" +================================== +Colormap normalizations SymLogNorm +================================== + +Demonstration of using norm to map colormaps onto data in non-linear ways. + +.. redirect-from:: /gallery/userdemo/colormap_normalization_symlognorm +""" + +############################################################################### +# Synthetic dataset consisting of two humps, one negative and one positive, +# the positive with 8-times the amplitude. +# Linearly, the negative hump is almost invisible, +# and it is very difficult to see any detail of its profile. +# With the logarithmic scaling applied to both positive and negative values, +# it is much easier to see the shape of each hump. +# +# See `~.colors.SymLogNorm`. + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.colors as colors + + +def rbf(x, y): + return 1.0 / (1 + 5 * ((x ** 2) + (y ** 2))) + +N = 200 +gain = 8 +X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)] +Z1 = rbf(X + 0.5, Y + 0.5) +Z2 = rbf(X - 0.5, Y - 0.5) +Z = gain * Z1 - Z2 + +shadeopts = {'cmap': 'PRGn', 'shading': 'gouraud'} +colormap = 'PRGn' +lnrwidth = 0.5 + +fig, ax = plt.subplots(2, 1, sharex=True, sharey=True) + +pcm = ax[0].pcolormesh(X, Y, Z, + norm=colors.SymLogNorm(linthresh=lnrwidth, linscale=1, + vmin=-gain, vmax=gain, base=10), + **shadeopts) +fig.colorbar(pcm, ax=ax[0], extend='both') +ax[0].text(-2.5, 1.5, 'symlog') + +pcm = ax[1].pcolormesh(X, Y, Z, vmin=-gain, vmax=gain, + **shadeopts) +fig.colorbar(pcm, ax=ax[1], extend='both') +ax[1].text(-2.5, 1.5, 'linear') + + +############################################################################### +# In order to find the best visualization for any particular dataset, +# it may be necessary to experiment with multiple different color scales. +# As well as the `~.colors.SymLogNorm` scaling, there is also +# the option of using `~.colors.AsinhNorm` (experimental), which has a smoother +# transition between the linear and logarithmic regions of the transformation +# applied to the data values, "Z". +# In the plots below, it may be possible to see contour-like artifacts +# around each hump despite there being no sharp features +# in the dataset itself. The ``asinh`` scaling shows a smoother shading +# of each hump. + +fig, ax = plt.subplots(2, 1, sharex=True, sharey=True) + +pcm = ax[0].pcolormesh(X, Y, Z, + norm=colors.SymLogNorm(linthresh=lnrwidth, linscale=1, + vmin=-gain, vmax=gain, base=10), + **shadeopts) +fig.colorbar(pcm, ax=ax[0], extend='both') +ax[0].text(-2.5, 1.5, 'symlog') + +pcm = ax[1].pcolormesh(X, Y, Z, + norm=colors.AsinhNorm(linear_width=lnrwidth, + vmin=-gain, vmax=gain), + **shadeopts) +fig.colorbar(pcm, ax=ax[1], extend='both') +ax[1].text(-2.5, 1.5, 'asinh') + + +plt.show() diff --git a/examples/images_contours_and_fields/contour_corner_mask.py b/examples/images_contours_and_fields/contour_corner_mask.py index 280231acb950..c0854675c6d2 100644 --- a/examples/images_contours_and_fields/contour_corner_mask.py +++ b/examples/images_contours_and_fields/contour_corner_mask.py @@ -27,7 +27,7 @@ for ax, corner_mask in zip(axs, corner_masks): cs = ax.contourf(x, y, z, corner_mask=corner_mask) ax.contour(cs, colors='k') - ax.set_title('corner_mask = {0}'.format(corner_mask)) + ax.set_title(f'{corner_mask=}') # Plot grid. ax.grid(c='k', ls='-', alpha=0.3) diff --git a/examples/images_contours_and_fields/contour_image.py b/examples/images_contours_and_fields/contour_image.py index 444d4df4a78f..a2a07f20f273 100644 --- a/examples/images_contours_and_fields/contour_image.py +++ b/examples/images_contours_and_fields/contour_image.py @@ -41,7 +41,7 @@ axs = _axs.flatten() cset1 = axs[0].contourf(X, Y, Z, levels, norm=norm, - cmap=cm.get_cmap(cmap, len(levels) - 1)) + cmap=cmap.resampled(len(levels) - 1)) # It is not necessary, but for the colormap, we need only the # number of levels minus 1. To avoid discretization error, use # either this number or a large number such as the default (256). diff --git a/examples/images_contours_and_fields/contourf_demo.py b/examples/images_contours_and_fields/contourf_demo.py index e51e2e6001dd..d7b1bbe9c284 100644 --- a/examples/images_contours_and_fields/contourf_demo.py +++ b/examples/images_contours_and_fields/contourf_demo.py @@ -1,6 +1,6 @@ """ ============= -Contourf Demo +Contourf demo ============= How to use the `.axes.Axes.contourf` method to create filled contour plots. @@ -97,7 +97,7 @@ cmap = plt.colormaps["winter"].with_extremes(under="magenta", over="yellow") # Note: contouring simply excludes masked or nan regions, so # instead of using the "bad" colormap value for them, it draws -# nothing at all in them. Therefore the following would have +# nothing at all in them. Therefore, the following would have # no effect: # cmap.set_bad("red") diff --git a/examples/images_contours_and_fields/contourf_log.py b/examples/images_contours_and_fields/contourf_log.py index 2f3976207b72..69b25d53df70 100644 --- a/examples/images_contours_and_fields/contourf_log.py +++ b/examples/images_contours_and_fields/contourf_log.py @@ -18,8 +18,8 @@ X, Y = np.meshgrid(x, y) # A low hump with a spike coming out. -# Needs to have z/colour axis on a log scale so we see both hump and spike. -# linear scale only shows the spike. +# Needs to have z/colour axis on a log scale, so we see both hump and spike. +# A linear scale only shows the spike. Z1 = np.exp(-X**2 - Y**2) Z2 = np.exp(-(X * 10)**2 - (Y * 10)**2) z = Z1 + 50 * Z2 diff --git a/examples/images_contours_and_fields/demo_bboximage.py b/examples/images_contours_and_fields/demo_bboximage.py index 0d43e328cb38..050d661a47ff 100644 --- a/examples/images_contours_and_fields/demo_bboximage.py +++ b/examples/images_contours_and_fields/demo_bboximage.py @@ -7,8 +7,10 @@ a bounding box. This demo shows how to show an image inside a `.text.Text`'s bounding box as well as how to manually create a bounding box for the image. """ -import matplotlib.pyplot as plt + import numpy as np + +import matplotlib.pyplot as plt from matplotlib.image import BboxImage from matplotlib.transforms import Bbox, TransformedBbox @@ -19,24 +21,12 @@ # Create a BboxImage with Text # ---------------------------- txt = ax1.text(0.5, 0.5, "test", size=30, ha="center", color="w") -kwargs = dict() - -bbox_image = BboxImage(txt.get_window_extent, - norm=None, - origin=None, - clip_on=False, - **kwargs - ) -a = np.arange(256).reshape(1, 256)/256. -bbox_image.set_data(a) -ax1.add_artist(bbox_image) +ax1.add_artist( + BboxImage(txt.get_window_extent, data=np.arange(256).reshape((1, -1)))) # ------------------------------------ # Create a BboxImage for each colormap # ------------------------------------ -a = np.linspace(0, 1, 256).reshape(1, -1) -a = np.vstack((a, a)) - # List of all colormaps; skip reversed colormaps. cmap_names = sorted(m for m in plt.colormaps if not m.endswith("_r")) @@ -51,21 +41,12 @@ for i, cmap_name in enumerate(cmap_names): ix, iy = divmod(i, nrow) - - bbox0 = Bbox.from_bounds(ix*dx*(1 + xpad_fraction), - 1. - iy*dy*(1 + ypad_fraction) - dy, + bbox0 = Bbox.from_bounds(ix*dx*(1+xpad_fraction), + 1 - iy*dy*(1+ypad_fraction) - dy, dx, dy) bbox = TransformedBbox(bbox0, ax2.transAxes) - - bbox_image = BboxImage(bbox, - cmap=cmap_name, - norm=None, - origin=None, - **kwargs - ) - - bbox_image.set_data(a) - ax2.add_artist(bbox_image) + ax2.add_artist( + BboxImage(bbox, cmap=cmap_name, data=np.arange(256).reshape((1, -1)))) plt.show() diff --git a/examples/images_contours_and_fields/image_annotated_heatmap.py b/examples/images_contours_and_fields/image_annotated_heatmap.py index 5bcbb57a5e8f..f873423f50b0 100644 --- a/examples/images_contours_and_fields/image_annotated_heatmap.py +++ b/examples/images_contours_and_fields/image_annotated_heatmap.py @@ -33,12 +33,13 @@ # tick labels (`~matplotlib.axes.Axes.set_xticklabels`), # otherwise they would become out of sync. The locations are just # the ascending integer numbers, while the ticklabels are the labels to show. -# Finally we can label the data itself by creating a `~matplotlib.text.Text` +# Finally, we can label the data itself by creating a `~matplotlib.text.Text` # within each cell showing the value of that cell. import numpy as np import matplotlib +import matplotlib as mpl import matplotlib.pyplot as plt # sphinx_gallery_thumbnail_number = 2 @@ -97,7 +98,7 @@ def heatmap(data, row_labels, col_labels, ax=None, - cbar_kw={}, cbarlabel="", **kwargs): + cbar_kw=None, cbarlabel="", **kwargs): """ Create a heatmap from a numpy array and two lists of labels. @@ -120,9 +121,12 @@ def heatmap(data, row_labels, col_labels, ax=None, All other arguments are forwarded to `imshow`. """ - if not ax: + if ax is None: ax = plt.gca() + if cbar_kw is None: + cbar_kw = {} + # Plot the heatmap im = ax.imshow(data, **kwargs) @@ -248,8 +252,8 @@ def annotate_heatmap(im, data=None, valfmt="{x:.2f}", # use an integer format on the annotations and provide some colors. data = np.random.randint(2, 100, size=(7, 7)) -y = ["Book {}".format(i) for i in range(1, 8)] -x = ["Store {}".format(i) for i in list("ABCDEFG")] +y = [f"Book {i}" for i in range(1, 8)] +x = [f"Store {i}" for i in list("ABCDEFG")] im, _ = heatmap(data, y, x, ax=ax2, vmin=0, cmap="magma_r", cbarlabel="weekly sold copies") annotate_heatmap(im, valfmt="{x:d}", size=7, threshold=20, @@ -261,15 +265,15 @@ def annotate_heatmap(im, data=None, valfmt="{x:.2f}", # labels from an array of classes. data = np.random.randn(6, 6) -y = ["Prod. {}".format(i) for i in range(10, 70, 10)] -x = ["Cycle {}".format(i) for i in range(1, 7)] +y = [f"Prod. {i}" for i in range(10, 70, 10)] +x = [f"Cycle {i}" for i in range(1, 7)] qrates = list("ABCDEFG") norm = matplotlib.colors.BoundaryNorm(np.linspace(-3.5, 3.5, 8), 7) fmt = matplotlib.ticker.FuncFormatter(lambda x, pos: qrates[::-1][norm(x)]) im, _ = heatmap(data, y, x, ax=ax3, - cmap=plt.get_cmap("PiYG", 7), norm=norm, + cmap=mpl.colormaps["PiYG"].resampled(7), norm=norm, cbar_kw=dict(ticks=np.arange(-3, 4), format=fmt), cbarlabel="Quality Rating") @@ -288,7 +292,7 @@ def annotate_heatmap(im, data=None, valfmt="{x:.2f}", def func(x, pos): - return "{:.2f}".format(x).replace("0.", ".").replace("1.00", "") + return f"{x:.2f}".replace("0.", ".").replace("1.00", "") annotate_heatmap(im, valfmt=matplotlib.ticker.FuncFormatter(func), size=7) diff --git a/examples/images_contours_and_fields/image_antialiasing.py b/examples/images_contours_and_fields/image_antialiasing.py index cb12bc3d9319..0ada6251b653 100644 --- a/examples/images_contours_and_fields/image_antialiasing.py +++ b/examples/images_contours_and_fields/image_antialiasing.py @@ -12,7 +12,7 @@ When subsampling data, aliasing is reduced by smoothing first and then subsampling the smoothed data. In Matplotlib, we can do that smoothing before mapping the data to colors, or we can do the smoothing -on the RGB(A) data in the final image. The difference between these is +on the RGB(A) data in the final image. The differences between these are shown below, and controlled with the *interpolation_stage* keyword argument. The default image interpolation in Matplotlib is 'antialiased', and @@ -49,9 +49,9 @@ ############################################################################### # The following images are subsampled from 450 data pixels to either # 125 pixels or 250 pixels (depending on your display). -# The Moire patterns in the 'nearest' interpolation are caused by the +# The Moiré patterns in the 'nearest' interpolation are caused by the # high-frequency data being subsampled. The 'antialiased' imaged -# still has some Moire patterns as well, but they are greatly reduced. +# still has some Moiré patterns as well, but they are greatly reduced. # # There are substantial differences between the 'data' interpolation and # the 'rgba' interpolation. The alternating bands of red and blue on the @@ -81,7 +81,7 @@ plt.show() ############################################################################### -# Even up-sampling an image with 'nearest' interpolation will lead to Moire +# Even up-sampling an image with 'nearest' interpolation will lead to Moiré # patterns when the upsampling factor is not integer. The following image # upsamples 500 data pixels to 530 rendered pixels. You may note a grid of # 30 line-like artifacts which stem from the 524 - 500 = 24 extra pixels that diff --git a/examples/images_contours_and_fields/image_demo.py b/examples/images_contours_and_fields/image_demo.py index c929d7869950..7484f858427c 100644 --- a/examples/images_contours_and_fields/image_demo.py +++ b/examples/images_contours_and_fields/image_demo.py @@ -1,6 +1,6 @@ """ ========== -Image Demo +Image demo ========== Many ways to plot images in Matplotlib. diff --git a/examples/images_contours_and_fields/image_masked.py b/examples/images_contours_and_fields/image_masked.py index b6dacd7114e4..fa5f8047a8ff 100644 --- a/examples/images_contours_and_fields/image_masked.py +++ b/examples/images_contours_and_fields/image_masked.py @@ -50,8 +50,7 @@ ax1.set_title('Green=low, Red=high, Blue=masked') cbar = fig.colorbar(im, extend='both', shrink=0.9, ax=ax1) cbar.set_label('uniform') -for ticklabel in ax1.xaxis.get_ticklabels(): - ticklabel.set_visible(False) +ax1.tick_params(axis='x', labelbottom=False) # Plot using a small number of colors, with unevenly spaced boundaries. im = ax2.imshow(Zm, interpolation='nearest', diff --git a/examples/images_contours_and_fields/image_nonuniform.py b/examples/images_contours_and_fields/image_nonuniform.py index 9c1012310b63..2b0c4fb8a798 100644 --- a/examples/images_contours_and_fields/image_nonuniform.py +++ b/examples/images_contours_and_fields/image_nonuniform.py @@ -1,10 +1,10 @@ """ ================ -Image Nonuniform +Image nonuniform ================ This illustrates the NonUniformImage class. It is not -available via an Axes method but it is easily added to an +available via an Axes method, but it is easily added to an Axes instance as shown here. """ diff --git a/examples/images_contours_and_fields/image_zcoord.py b/examples/images_contours_and_fields/image_zcoord.py index 7fc57ca10710..8e210eb7c603 100644 --- a/examples/images_contours_and_fields/image_zcoord.py +++ b/examples/images_contours_and_fields/image_zcoord.py @@ -3,12 +3,11 @@ Modifying the coordinate formatter ================================== -Modify the coordinate formatter to report the image "z" -value of the nearest pixel given x and y. -This functionality is built in by default, but it -is still useful to show how to customize the -`~.axes.Axes.format_coord` function. +Modify the coordinate formatter to report the image "z" value of the nearest +pixel given x and y. This functionality is built in by default; this example +just showcases how to customize the `~.axes.Axes.format_coord` function. """ + import numpy as np import matplotlib.pyplot as plt @@ -21,17 +20,17 @@ fig, ax = plt.subplots() ax.imshow(X) -numrows, numcols = X.shape - def format_coord(x, y): - col = int(x + 0.5) - row = int(y + 0.5) - if 0 <= col < numcols and 0 <= row < numrows: + col = round(x) + row = round(y) + nrows, ncols = X.shape + if 0 <= col < ncols and 0 <= row < nrows: z = X[row, col] - return 'x=%1.4f, y=%1.4f, z=%1.4f' % (x, y, z) + return f'x={x:1.4f}, y={y:1.4f}, z={z:1.4f}' else: - return 'x=%1.4f, y=%1.4f' % (x, y) + return f'x={x:1.4f}, y={y:1.4f}' + ax.format_coord = format_coord plt.show() diff --git a/examples/images_contours_and_fields/interpolation_methods.py b/examples/images_contours_and_fields/interpolation_methods.py index 1e59e721d9fa..4d3151696dcd 100644 --- a/examples/images_contours_and_fields/interpolation_methods.py +++ b/examples/images_contours_and_fields/interpolation_methods.py @@ -10,12 +10,12 @@ If the interpolation is ``'none'``, then no interpolation is performed for the Agg, ps and pdf backends. Other backends will default to ``'antialiased'``. -For the Agg, ps and pdf backends, ``interpolation = 'none'`` works well when a -big image is scaled down, while ``interpolation = 'nearest'`` works well when +For the Agg, ps and pdf backends, ``interpolation='none'`` works well when a +big image is scaled down, while ``interpolation='nearest'`` works well when a small image is scaled up. See :doc:`/gallery/images_contours_and_fields/image_antialiasing` for a -discussion on the default ``interpolation="antialiased"`` option. +discussion on the default ``interpolation='antialiased'`` option. """ import matplotlib.pyplot as plt diff --git a/examples/images_contours_and_fields/matshow.py b/examples/images_contours_and_fields/matshow.py index 43a06bda9353..0add6bebf9a4 100644 --- a/examples/images_contours_and_fields/matshow.py +++ b/examples/images_contours_and_fields/matshow.py @@ -1,7 +1,7 @@ """ -======= -Matshow -======= +=============================== +Visualize matrices with matshow +=============================== `~.axes.Axes.matshow` visualizes a 2D matrix or array as color-coded image. """ diff --git a/examples/images_contours_and_fields/multi_image.py b/examples/images_contours_and_fields/multi_image.py index e517e3f326ca..5ca598a7e48c 100644 --- a/examples/images_contours_and_fields/multi_image.py +++ b/examples/images_contours_and_fields/multi_image.py @@ -1,7 +1,7 @@ """ -=========== -Multi Image -=========== +=============== +Multiple images +=============== Make a set of images with a single colormap, norm, and colorbar. """ diff --git a/examples/images_contours_and_fields/pcolor_demo.py b/examples/images_contours_and_fields/pcolor_demo.py index bc578f25cf09..2e17cd7db90b 100644 --- a/examples/images_contours_and_fields/pcolor_demo.py +++ b/examples/images_contours_and_fields/pcolor_demo.py @@ -1,6 +1,6 @@ """ =========== -Pcolor Demo +Pcolor demo =========== Generating images with `~.axes.Axes.pcolor`. @@ -92,8 +92,8 @@ X, Y = np.meshgrid(np.linspace(-3, 3, N), np.linspace(-2, 2, N)) # A low hump with a spike coming out. -# Needs to have z/colour axis on a log scale so we see both hump and spike. -# linear scale only shows the spike. +# Needs to have z/colour axis on a log scale, so we see both hump and spike. +# A linear scale only shows the spike. Z1 = np.exp(-X**2 - Y**2) Z2 = np.exp(-(X * 10)**2 - (Y * 10)**2) Z = Z1 + 50 * Z2 diff --git a/examples/images_contours_and_fields/pcolormesh_grids.py b/examples/images_contours_and_fields/pcolormesh_grids.py index 3b628efe58bd..7fc75f8b6dd9 100644 --- a/examples/images_contours_and_fields/pcolormesh_grids.py +++ b/examples/images_contours_and_fields/pcolormesh_grids.py @@ -54,11 +54,10 @@ def _annotate(ax, x, y, title): # ----------------------------- # # Often, however, data is provided where *X* and *Y* match the shape of *Z*. -# While this makes sense for other ``shading`` types, it is no longer permitted -# when ``shading='flat'`` (and will raise a MatplotlibDeprecationWarning as of -# Matplotlib v3.3). Historically, Matplotlib silently dropped the last row and -# column of *Z* in this case, to match Matlab's behavior. If this behavior is -# still desired, simply drop the last row and column manually: +# While this makes sense for other ``shading`` types, it is not permitted +# when ``shading='flat'``. Historically, Matplotlib silently dropped the last +# row and column of *Z* in this case, to match Matlab's behavior. If this +# behavior is still desired, simply drop the last row and column manually: x = np.arange(ncols) # note *not* ncols + 1 as before y = np.arange(nrows) diff --git a/examples/images_contours_and_fields/pcolormesh_levels.py b/examples/images_contours_and_fields/pcolormesh_levels.py index d42643f15574..d462304ea4c5 100644 --- a/examples/images_contours_and_fields/pcolormesh_levels.py +++ b/examples/images_contours_and_fields/pcolormesh_levels.py @@ -3,8 +3,8 @@ pcolormesh ========== -`.axes.Axes.pcolormesh` allows you to generate 2D image-style plots. Note it -is faster than the similar `~.axes.Axes.pcolor`. +`.axes.Axes.pcolormesh` allows you to generate 2D image-style plots. +Note that it is faster than the similar `~.axes.Axes.pcolor`. """ @@ -52,9 +52,8 @@ # Often a user wants to pass *X* and *Y* with the same sizes as *Z* to # `.axes.Axes.pcolormesh`. This is also allowed if ``shading='auto'`` is # passed (default set by :rc:`pcolor.shading`). Pre Matplotlib 3.3, -# ``shading='flat'`` would drop the last column and row of *Z*; while that -# is still allowed for back compatibility purposes, a DeprecationWarning is -# raised. If this is really what you want, then simply drop the last row and +# ``shading='flat'`` would drop the last column and row of *Z*, but now gives +# an error. If this is really what you want, then simply drop the last row and # column of Z manually: x = np.arange(10) # len = 10 diff --git a/examples/images_contours_and_fields/plot_streamplot.py b/examples/images_contours_and_fields/plot_streamplot.py index fca97d304207..fd587527fbc5 100644 --- a/examples/images_contours_and_fields/plot_streamplot.py +++ b/examples/images_contours_and_fields/plot_streamplot.py @@ -11,10 +11,12 @@ * Varying the line width along a streamline. * Controlling the starting points of streamlines. * Streamlines skipping masked regions and NaN values. +* Unbroken streamlines even when exceeding the limit of lines within a single + grid cell. """ import numpy as np import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec + w = 3 Y, X = np.mgrid[-w:w:100j, -w:w:100j] @@ -22,38 +24,34 @@ V = 1 + X - Y**2 speed = np.sqrt(U**2 + V**2) -fig = plt.figure(figsize=(7, 9)) -gs = gridspec.GridSpec(nrows=3, ncols=2, height_ratios=[1, 1, 2]) +fig, axs = plt.subplots(3, 2, figsize=(7, 9), height_ratios=[1, 1, 2]) +axs = axs.flat # Varying density along a streamline -ax0 = fig.add_subplot(gs[0, 0]) -ax0.streamplot(X, Y, U, V, density=[0.5, 1]) -ax0.set_title('Varying Density') +axs[0].streamplot(X, Y, U, V, density=[0.5, 1]) +axs[0].set_title('Varying Density') # Varying color along a streamline -ax1 = fig.add_subplot(gs[0, 1]) -strm = ax1.streamplot(X, Y, U, V, color=U, linewidth=2, cmap='autumn') +strm = axs[1].streamplot(X, Y, U, V, color=U, linewidth=2, cmap='autumn') fig.colorbar(strm.lines) -ax1.set_title('Varying Color') +axs[1].set_title('Varying Color') # Varying line width along a streamline -ax2 = fig.add_subplot(gs[1, 0]) lw = 5*speed / speed.max() -ax2.streamplot(X, Y, U, V, density=0.6, color='k', linewidth=lw) -ax2.set_title('Varying Line Width') +axs[2].streamplot(X, Y, U, V, density=0.6, color='k', linewidth=lw) +axs[2].set_title('Varying Line Width') # Controlling the starting points of the streamlines seed_points = np.array([[-2, -1, 0, 1, 2, -1], [-2, -1, 0, 1, 2, 2]]) -ax3 = fig.add_subplot(gs[1, 1]) -strm = ax3.streamplot(X, Y, U, V, color=U, linewidth=2, - cmap='autumn', start_points=seed_points.T) +strm = axs[3].streamplot(X, Y, U, V, color=U, linewidth=2, + cmap='autumn', start_points=seed_points.T) fig.colorbar(strm.lines) -ax3.set_title('Controlling Starting Points') +axs[3].set_title('Controlling Starting Points') # Displaying the starting points with blue symbols. -ax3.plot(seed_points[0], seed_points[1], 'bo') -ax3.set(xlim=(-w, w), ylim=(-w, w)) +axs[3].plot(seed_points[0], seed_points[1], 'bo') +axs[3].set(xlim=(-w, w), ylim=(-w, w)) # Create a mask mask = np.zeros(U.shape, dtype=bool) @@ -61,12 +59,15 @@ U[:20, :20] = np.nan U = np.ma.array(U, mask=mask) -ax4 = fig.add_subplot(gs[2:, :]) -ax4.streamplot(X, Y, U, V, color='r') -ax4.set_title('Streamplot with Masking') +axs[4].streamplot(X, Y, U, V, color='r') +axs[4].set_title('Streamplot with Masking') + +axs[4].imshow(~mask, extent=(-w, w, -w, w), alpha=0.5, cmap='gray', + aspect='auto') +axs[4].set_aspect('equal') -ax4.imshow(~mask, extent=(-w, w, -w, w), alpha=0.5, cmap='gray', aspect='auto') -ax4.set_aspect('equal') +axs[5].streamplot(X, Y, U, V, broken_streamlines=False) +axs[5].set_title('Streamplot with unbroken streamlines') plt.tight_layout() plt.show() diff --git a/examples/images_contours_and_fields/shading_example.py b/examples/images_contours_and_fields/shading_example.py index 63858a8fb19e..c3b969dcff8b 100644 --- a/examples/images_contours_and_fields/shading_example.py +++ b/examples/images_contours_and_fields/shading_example.py @@ -7,7 +7,7 @@ `Generic Mapping Tools`_. .. _Mathematica: http://reference.wolfram.com/mathematica/ref/ReliefPlot.html -.. _Generic Mapping Tools: https://gmt.soest.hawaii.edu/ +.. _Generic Mapping Tools: https://www.generic-mapping-tools.org/ """ import numpy as np diff --git a/examples/lines_bars_and_markers/bar_colors.py b/examples/lines_bars_and_markers/bar_colors.py new file mode 100644 index 000000000000..35e7a64ef605 --- /dev/null +++ b/examples/lines_bars_and_markers/bar_colors.py @@ -0,0 +1,26 @@ +""" +============== +Bar color demo +============== + +This is an example showing how to control bar color and legend entries +using the *color* and *label* parameters of `~matplotlib.pyplot.bar`. +Note that labels with a preceding underscore won't show up in the legend. +""" + +import matplotlib.pyplot as plt + +fig, ax = plt.subplots() + +fruits = ['apple', 'blueberry', 'cherry', 'orange'] +counts = [40, 100, 30, 55] +bar_labels = ['red', 'blue', '_red', 'orange'] +bar_colors = ['tab:red', 'tab:blue', 'tab:red', 'tab:orange'] + +ax.bar(fruits, counts, label=bar_labels, color=bar_colors) + +ax.set_ylabel('fruit supply') +ax.set_title('Fruit supply by kind and color') +ax.legend(title='Fruit color') + +plt.show() diff --git a/examples/lines_bars_and_markers/bar_label_demo.py b/examples/lines_bars_and_markers/bar_label_demo.py index 3ed3f79018cc..146b6934bb48 100644 --- a/examples/lines_bars_and_markers/bar_label_demo.py +++ b/examples/lines_bars_and_markers/bar_label_demo.py @@ -18,35 +18,27 @@ import numpy as np ############################################################################### -# Define the data +# data from https://allisonhorst.github.io/palmerpenguins/ -N = 5 -menMeans = (20, 35, 30, 35, -27) -womenMeans = (25, 32, 34, 20, -25) -menStd = (2, 3, 4, 1, 2) -womenStd = (3, 5, 2, 3, 3) -ind = np.arange(N) # the x locations for the groups -width = 0.35 # the width of the bars: can also be len(x) sequence +species = ('Adelie', 'Chinstrap', 'Gentoo') +sex_counts = { + 'Male': np.array([73, 34, 61]), + 'Female': np.array([73, 34, 58]), +} +width = 0.6 # the width of the bars: can also be len(x) sequence -############################################################################### -# Stacked bar plot with error bars fig, ax = plt.subplots() +bottom = np.zeros(3) -p1 = ax.bar(ind, menMeans, width, yerr=menStd, label='Men') -p2 = ax.bar(ind, womenMeans, width, - bottom=menMeans, yerr=womenStd, label='Women') +for sex, sex_count in sex_counts.items(): + p = ax.bar(species, sex_count, width, label=sex, bottom=bottom) + bottom += sex_count -ax.axhline(0, color='grey', linewidth=0.8) -ax.set_ylabel('Scores') -ax.set_title('Scores by group and gender') -ax.set_xticks(ind, labels=['G1', 'G2', 'G3', 'G4', 'G5']) -ax.legend() + ax.bar_label(p, label_type='center') -# Label with label_type 'center' instead of the default 'edge' -ax.bar_label(p1, label_type='center') -ax.bar_label(p2, label_type='center') -ax.bar_label(p2) +ax.set_title('Number of penguins by sex') +ax.legend() plt.show() @@ -94,6 +86,30 @@ plt.show() +############################################################################### +# Bar labels using {}-style format string + +fruit_names = ['Coffee', 'Salted Caramel', 'Pistachio'] +fruit_counts = [4000, 2000, 7000] + +fig, ax = plt.subplots() +bar_container = ax.bar(fruit_names, fruit_counts) +ax.set(ylabel='pints sold', title='Gelato sales by flavor', ylim=(0, 8000)) +ax.bar_label(bar_container, fmt='{:,.0f}') + +############################################################################### +# Bar labels using a callable + +animal_names = ['Lion', 'Gazelle', 'Cheetah'] +mph_speed = [50, 60, 75] + +fig, ax = plt.subplots() +bar_container = ax.bar(animal_names, mph_speed) +ax.set(ylabel='speed in MPH', title='Running speeds', ylim=(0, 80)) +ax.bar_label( + bar_container, fmt=lambda x: '{:.1f} km/h'.format(x * 1.61) +) + ############################################################################# # # .. admonition:: References diff --git a/examples/lines_bars_and_markers/bar_stacked.py b/examples/lines_bars_and_markers/bar_stacked.py index 90e59f987249..81ee305e7072 100644 --- a/examples/lines_bars_and_markers/bar_stacked.py +++ b/examples/lines_bars_and_markers/bar_stacked.py @@ -3,30 +3,34 @@ Stacked bar chart ================= -This is an example of creating a stacked bar plot with error bars -using `~matplotlib.pyplot.bar`. Note the parameters *yerr* used for -error bars, and *bottom* to stack the women's bars on top of the men's -bars. +This is an example of creating a stacked bar plot +using `~matplotlib.pyplot.bar`. """ import matplotlib.pyplot as plt +import numpy as np +# data from https://allisonhorst.github.io/palmerpenguins/ -labels = ['G1', 'G2', 'G3', 'G4', 'G5'] -men_means = [20, 35, 30, 35, 27] -women_means = [25, 32, 34, 20, 25] -men_std = [2, 3, 4, 1, 2] -women_std = [3, 5, 2, 3, 3] -width = 0.35 # the width of the bars: can also be len(x) sequence +species = ( + "Adelie\n $\\mu=$3700.66g", + "Chinstrap\n $\\mu=$3733.09g", + "Gentoo\n $\\mu=5076.02g$", +) +weight_counts = { + "Below": np.array([70, 31, 58]), + "Above": np.array([82, 37, 66]), +} +width = 0.5 fig, ax = plt.subplots() +bottom = np.zeros(3) -ax.bar(labels, men_means, width, yerr=men_std, label='Men') -ax.bar(labels, women_means, width, yerr=women_std, bottom=men_means, - label='Women') +for boolean, weight_count in weight_counts.items(): + p = ax.bar(species, weight_count, width, label=boolean, bottom=bottom) + bottom += weight_count -ax.set_ylabel('Scores') -ax.set_title('Scores by group and gender') -ax.legend() +ax.set_title("Number of penguins with above average body mass") +ax.legend(loc="upper right") plt.show() diff --git a/examples/lines_bars_and_markers/barchart.py b/examples/lines_bars_and_markers/barchart.py index 8e2dc9f1ed44..f5ee6833a881 100644 --- a/examples/lines_bars_and_markers/barchart.py +++ b/examples/lines_bars_and_markers/barchart.py @@ -7,31 +7,36 @@ bars with labels. """ +# data from https://allisonhorst.github.io/palmerpenguins/ + import matplotlib.pyplot as plt import numpy as np +species = ("Adelie", "Chinstrap", "Gentoo") +penguin_means = { + 'Bill Depth': (18.35, 18.43, 14.98), + 'Bill Length': (38.79, 48.83, 47.50), + 'Flipper Length': (189.95, 195.82, 217.19), +} -labels = ['G1', 'G2', 'G3', 'G4', 'G5'] -men_means = [20, 34, 30, 35, 27] -women_means = [25, 32, 34, 20, 25] +x = np.arange(len(species)) # the label locations +width = 0.25 # the width of the bars +multiplier = 0 -x = np.arange(len(labels)) # the label locations -width = 0.35 # the width of the bars +fig, ax = plt.subplots(constrained_layout=True) -fig, ax = plt.subplots() -rects1 = ax.bar(x - width/2, men_means, width, label='Men') -rects2 = ax.bar(x + width/2, women_means, width, label='Women') +for attribute, measurement in penguin_means.items(): + offset = width * multiplier + rects = ax.bar(x + offset, measurement, width, label=attribute) + ax.bar_label(rects, padding=3) + multiplier += 1 # Add some text for labels, title and custom x-axis tick labels, etc. -ax.set_ylabel('Scores') -ax.set_title('Scores by group and gender') -ax.set_xticks(x, labels) -ax.legend() - -ax.bar_label(rects1, padding=3) -ax.bar_label(rects2, padding=3) - -fig.tight_layout() +ax.set_ylabel('Length (mm)') +ax.set_title('Penguin attributes by species') +ax.set_xticks(x + width, species) +ax.legend(loc='upper left', ncols=3) +ax.set_ylim(0, 250) plt.show() diff --git a/examples/lines_bars_and_markers/broken_barh.py b/examples/lines_bars_and_markers/broken_barh.py index 084e0aae9ecb..6da38f1e465f 100644 --- a/examples/lines_bars_and_markers/broken_barh.py +++ b/examples/lines_bars_and_markers/broken_barh.py @@ -7,6 +7,7 @@ """ import matplotlib.pyplot as plt +# Horizontal bar plot with gaps fig, ax = plt.subplots() ax.broken_barh([(110, 30), (150, 10)], (10, 9), facecolors='tab:blue') ax.broken_barh([(10, 50), (100, 20), (130, 10)], (20, 9), @@ -14,8 +15,8 @@ ax.set_ylim(5, 35) ax.set_xlim(0, 200) ax.set_xlabel('seconds since start') -ax.set_yticks([15, 25], labels=['Bill', 'Jim']) -ax.grid(True) +ax.set_yticks([15, 25], labels=['Bill', 'Jim']) # Modify y-axis tick labels +ax.grid(True) # Make grid lines visible ax.annotate('race interrupted', (61, 25), xytext=(0.8, 0.9), textcoords='axes fraction', arrowprops=dict(facecolor='black', shrink=0.05), diff --git a/examples/lines_bars_and_markers/cohere.py b/examples/lines_bars_and_markers/cohere.py index 370149695398..7881a0a31b1e 100644 --- a/examples/lines_bars_and_markers/cohere.py +++ b/examples/lines_bars_and_markers/cohere.py @@ -16,19 +16,19 @@ nse1 = np.random.randn(len(t)) # white noise 1 nse2 = np.random.randn(len(t)) # white noise 2 -# Two signals with a coherent part at 10Hz and a random part +# Two signals with a coherent part at 10 Hz and a random part s1 = np.sin(2 * np.pi * 10 * t) + nse1 s2 = np.sin(2 * np.pi * 10 * t) + nse2 fig, axs = plt.subplots(2, 1) axs[0].plot(t, s1, t, s2) axs[0].set_xlim(0, 2) -axs[0].set_xlabel('time') +axs[0].set_xlabel('Time') axs[0].set_ylabel('s1 and s2') axs[0].grid(True) cxy, f = axs[1].cohere(s1, s2, 256, 1. / dt) -axs[1].set_ylabel('coherence') +axs[1].set_ylabel('Coherence') fig.tight_layout() plt.show() diff --git a/examples/lines_bars_and_markers/csd_demo.py b/examples/lines_bars_and_markers/csd_demo.py index 1eacc649b204..8894333f94d0 100644 --- a/examples/lines_bars_and_markers/csd_demo.py +++ b/examples/lines_bars_and_markers/csd_demo.py @@ -33,10 +33,10 @@ ax1.plot(t, s1, t, s2) ax1.set_xlim(0, 5) -ax1.set_xlabel('time') +ax1.set_xlabel('Time') ax1.set_ylabel('s1 and s2') ax1.grid(True) cxy, f = ax2.csd(s1, s2, 256, 1. / dt) -ax2.set_ylabel('CSD (db)') +ax2.set_ylabel('CSD (dB)') plt.show() diff --git a/examples/lines_bars_and_markers/eventcollection_demo.py b/examples/lines_bars_and_markers/eventcollection_demo.py index abdb6e6c05f5..e4fe6f61bdab 100644 --- a/examples/lines_bars_and_markers/eventcollection_demo.py +++ b/examples/lines_bars_and_markers/eventcollection_demo.py @@ -1,10 +1,10 @@ -""" +r""" ==================== EventCollection Demo ==================== -Plot two curves, then use EventCollections to mark the locations of the x -and y data points on the respective axes for each curve +Plot two curves, then use `.EventCollection`\s to mark the locations of the x +and y data points on the respective axes for each curve. """ import matplotlib.pyplot as plt diff --git a/examples/lines_bars_and_markers/eventplot_demo.py b/examples/lines_bars_and_markers/eventplot_demo.py index 48ea3a6a4fdf..44e3c183bb51 100644 --- a/examples/lines_bars_and_markers/eventplot_demo.py +++ b/examples/lines_bars_and_markers/eventplot_demo.py @@ -1,10 +1,10 @@ """ ============== -Eventplot Demo +Eventplot demo ============== -An eventplot showing sequences of events with various line properties. -The plot is shown in both horizontal and vertical orientations. +An `~.axes.Axes.eventplot` showing sequences of events with various line +properties. The plot is shown in both horizontal and vertical orientations. """ import matplotlib.pyplot as plt @@ -20,7 +20,7 @@ data1 = np.random.random([6, 50]) # set different colors for each set of positions -colors1 = ['C{}'.format(i) for i in range(6)] +colors1 = [f'C{i}' for i in range(6)] # set different line properties for each set of positions # note that some overlap diff --git a/examples/lines_bars_and_markers/fill.py b/examples/lines_bars_and_markers/fill.py index e3419e805475..79642a9e5ed5 100644 --- a/examples/lines_bars_and_markers/fill.py +++ b/examples/lines_bars_and_markers/fill.py @@ -3,7 +3,7 @@ Filled polygon ============== -`~.Axes.fill()` draws a filled polygon based based on lists of point +`~.Axes.fill()` draws a filled polygon based on lists of point coordinates *x*, *y*. This example uses the `Koch snowflake`_ as an example polygon. diff --git a/examples/lines_bars_and_markers/fill_between_alpha.py b/examples/lines_bars_and_markers/fill_between_alpha.py index 30e7f6be3dcd..9f4351b0006f 100644 --- a/examples/lines_bars_and_markers/fill_between_alpha.py +++ b/examples/lines_bars_and_markers/fill_between_alpha.py @@ -7,9 +7,9 @@ It has a very handy ``where`` argument to combine filling with logical ranges, e.g., to just fill in a curve over some threshold value. -At its most basic level, ``fill_between`` can be use to enhance a graphs visual -appearance. Let's compare two graphs of a financial times with a simple line -plot on the left and a filled line on the right. +At its most basic level, ``fill_between`` can be used to enhance a graph's +visual appearance. Let's compare two graphs of financial data with a simple +line plot on the left and a filled line on the right. """ import matplotlib.pyplot as plt @@ -30,10 +30,9 @@ for ax in ax1, ax2: ax.grid(True) + ax.label_outer() ax1.set_ylabel('price') -for label in ax2.get_yticklabels(): - label.set_visible(False) fig.suptitle('Google (GOOG) daily closing price') fig.autofmt_xdate() @@ -133,7 +132,7 @@ ############################################################################### # Another handy use of filled regions is to highlight horizontal or vertical -# spans of an axes -- for that Matplotlib has the helper functions +# spans of an Axes -- for that Matplotlib has the helper functions # `~matplotlib.axes.Axes.axhspan` and `~matplotlib.axes.Axes.axvspan`. See # :doc:`/gallery/subplots_axes_and_figures/axhspan_demo`. diff --git a/examples/lines_bars_and_markers/fill_between_demo.py b/examples/lines_bars_and_markers/fill_between_demo.py index cc9d4ddab11a..79aef67ab4d9 100644 --- a/examples/lines_bars_and_markers/fill_between_demo.py +++ b/examples/lines_bars_and_markers/fill_between_demo.py @@ -114,7 +114,7 @@ # ------------------------------------------------------------ # The same selection mechanism can be applied to fill the full vertical height # of the axes. To be independent of y-limits, we add a transform that -# interprets the x-values in data coorindates and the y-values in axes +# interprets the x-values in data coordinates and the y-values in axes # coordinates. # # The following example marks the regions in which the y-data are above a diff --git a/examples/lines_bars_and_markers/filled_step.py b/examples/lines_bars_and_markers/filled_step.py index a4185366b7a2..102f2498ca4c 100644 --- a/examples/lines_bars_and_markers/filled_step.py +++ b/examples/lines_bars_and_markers/filled_step.py @@ -49,17 +49,16 @@ def filled_hist(ax, edges, values, bottoms=None, orientation='v', """ print(orientation) if orientation not in 'hv': - raise ValueError("orientation must be in {{'h', 'v'}} " - "not {o}".format(o=orientation)) + raise ValueError(f"orientation must be in {{'h', 'v'}} " + f"not {orientation}") kwargs.setdefault('step', 'post') kwargs.setdefault('alpha', 0.7) edges = np.asarray(edges) values = np.asarray(values) if len(edges) - 1 != len(values): - raise ValueError('Must provide one more bin edge than value not: ' - 'len(edges): {lb} len(values): {lv}'.format( - lb=len(edges), lv=len(values))) + raise ValueError(f'Must provide one more bin edge than value not: ' + f'{len(edges)=} {len(values)=}') if bottoms is None: bottoms = 0 @@ -159,7 +158,7 @@ def stack_hist(ax, stacked_data, sty_cycle, bottoms=None, arts = {} for j, (data, label, sty) in loop_iter: if label is None: - label = 'dflt set {n}'.format(n=j) + label = f'dflt set {j}' label = sty.pop('label', label) vals, edges = hist_func(data) if bottoms is None: @@ -182,7 +181,7 @@ def stack_hist(ax, stacked_data, sty_cycle, bottoms=None, # set up style cycles color_cycle = cycler(facecolor=plt.rcParams['axes.prop_cycle'][:4]) -label_cycle = cycler(label=['set {n}'.format(n=n) for n in range(4)]) +label_cycle = cycler(label=[f'set {n}' for n in range(4)]) hatch_cycle = cycler(hatch=['/', '*', '+', '|']) # Fixing random state for reproducibility diff --git a/examples/lines_bars_and_markers/gradient_bar.py b/examples/lines_bars_and_markers/gradient_bar.py index 2441f4d00744..93d346941f31 100644 --- a/examples/lines_bars_and_markers/gradient_bar.py +++ b/examples/lines_bars_and_markers/gradient_bar.py @@ -12,8 +12,8 @@ by a unit vector *v*. The values at the corners are then obtained by the lengths of the projections of the corner vectors on *v*. -A similar approach can be used to create a gradient background for an axes. -In that case, it is helpful to uses Axes coordinates (``extent=(0, 1, 0, 1), +A similar approach can be used to create a gradient background for an Axes. +In that case, it is helpful to use Axes coordinates (``extent=(0, 1, 0, 1), transform=ax.transAxes``) to be independent of the data coordinates. """ diff --git a/examples/lines_bars_and_markers/horizontal_barchart_distribution.py b/examples/lines_bars_and_markers/horizontal_barchart_distribution.py index 3a22ce3ed4ae..3ec12ba00e8d 100644 --- a/examples/lines_bars_and_markers/horizontal_barchart_distribution.py +++ b/examples/lines_bars_and_markers/horizontal_barchart_distribution.py @@ -60,7 +60,7 @@ def survey(results, category_names): r, g, b, _ = color text_color = 'white' if r * g * b < 0.5 else 'darkgrey' ax.bar_label(rects, label_type='center', color=text_color) - ax.legend(ncol=len(category_names), bbox_to_anchor=(0, 1), + ax.legend(ncols=len(category_names), bbox_to_anchor=(0, 1), loc='lower left', fontsize='small') return fig, ax diff --git a/examples/lines_bars_and_markers/line_demo_dash_control.py b/examples/lines_bars_and_markers/line_demo_dash_control.py index 78043dfed2ff..9d02981597c0 100644 --- a/examples/lines_bars_and_markers/line_demo_dash_control.py +++ b/examples/lines_bars_and_markers/line_demo_dash_control.py @@ -17,6 +17,11 @@ :doc:`property_cycle ` by passing a list of dash sequences using the keyword *dashes* to the cycler. This is not shown within this example. + +Other attributes of the dash may also be set either with the relevant method +(`~.Line2D.set_dash_capstyle`, `~.Line2D.set_dash_joinstyle`, +`~.Line2D.set_gapcolor`) or by passing the property through a plotting +function. """ import numpy as np import matplotlib.pyplot as plt @@ -24,14 +29,21 @@ x = np.linspace(0, 10, 500) y = np.sin(x) +plt.rc('lines', linewidth=2.5) fig, ax = plt.subplots() -# Using set_dashes() to modify dashing of an existing line -line1, = ax.plot(x, y, label='Using set_dashes()') -line1.set_dashes([2, 2, 10, 2]) # 2pt line, 2pt break, 10pt line, 2pt break +# Using set_dashes() and set_capstyle() to modify dashing of an existing line. +line1, = ax.plot(x, y, label='Using set_dashes() and set_dash_capstyle()') +line1.set_dashes([2, 2, 10, 2]) # 2pt line, 2pt break, 10pt line, 2pt break. +line1.set_dash_capstyle('round') -# Using plot(..., dashes=...) to set the dashing when creating a line +# Using plot(..., dashes=...) to set the dashing when creating a line. line2, = ax.plot(x, y - 0.2, dashes=[6, 2], label='Using the dashes parameter') -ax.legend() +# Using plot(..., dashes=..., gapcolor=...) to set the dashing and +# alternating color when creating a line. +line3, = ax.plot(x, y - 0.4, dashes=[4, 4], gapcolor='tab:pink', + label='Using the dashes and gapcolor parameters') + +ax.legend(handlelength=4) plt.show() diff --git a/examples/lines_bars_and_markers/lines_with_ticks_demo.py b/examples/lines_bars_and_markers/lines_with_ticks_demo.py index c7b902f09ddf..f7ef646c647f 100644 --- a/examples/lines_bars_and_markers/lines_with_ticks_demo.py +++ b/examples/lines_bars_and_markers/lines_with_ticks_demo.py @@ -15,10 +15,12 @@ import matplotlib.pyplot as plt from matplotlib import patheffects +# Plot a straight diagonal line with ticked style path fig, ax = plt.subplots(figsize=(6, 6)) ax.plot([0, 1], [0, 1], label="Line", path_effects=[patheffects.withTickedStroke(spacing=7, angle=135)]) +# Plot a curved line with ticked style path nx = 101 x = np.linspace(0.0, 1.0, nx) y = 0.3*np.sin(x*8) + 0.4 diff --git a/examples/lines_bars_and_markers/linestyles.py b/examples/lines_bars_and_markers/linestyles.py index 88a363898da8..96c9113ee4cc 100644 --- a/examples/lines_bars_and_markers/linestyles.py +++ b/examples/lines_bars_and_markers/linestyles.py @@ -6,8 +6,9 @@ Simple linestyles can be defined using the strings "solid", "dotted", "dashed" or "dashdot". More refined control can be achieved by providing a dash tuple ``(offset, (on_off_seq))``. For example, ``(0, (3, 10, 1, 15))`` means -(3pt line, 10pt space, 1pt line, 15pt space) with no offset. See also -`.Line2D.set_linestyle`. +(3pt line, 10pt space, 1pt line, 15pt space) with no offset, while +``(5, (10, 3))``, means (10pt line, 3pt space), but skip the first 5pt line. +See also `.Line2D.set_linestyle`. *Note*: The dash style can also be configured via `.Line2D.set_dashes` as shown in :doc:`/gallery/lines_bars_and_markers/line_demo_dash_control` @@ -27,7 +28,7 @@ ('loosely dotted', (0, (1, 10))), ('dotted', (0, (1, 1))), ('densely dotted', (0, (1, 1))), - + ('long dash with offset', (5, (10, 3))), ('loosely dashed', (0, (5, 10))), ('dashed', (0, (5, 5))), ('densely dashed', (0, (5, 1))), @@ -65,9 +66,7 @@ def plot_linestyles(ax, linestyles, title): color="blue", fontsize=8, ha="right", family="monospace") -ax0, ax1 = (plt.figure(figsize=(10, 8)) - .add_gridspec(2, 1, height_ratios=[1, 3]) - .subplots()) +fig, (ax0, ax1) = plt.subplots(2, 1, figsize=(10, 8), height_ratios=[1, 3]) plot_linestyles(ax0, linestyle_str[::-1], title='Named linestyles') plot_linestyles(ax1, linestyle_tuple[::-1], title='Parametrized linestyles') diff --git a/examples/lines_bars_and_markers/marker_reference.py b/examples/lines_bars_and_markers/marker_reference.py index 98af2519124c..0c742801998b 100644 --- a/examples/lines_bars_and_markers/marker_reference.py +++ b/examples/lines_bars_and_markers/marker_reference.py @@ -19,8 +19,10 @@ .. redirect-from:: /gallery/shapes_and_collections/marker_path """ +from matplotlib.markers import MarkerStyle import matplotlib.pyplot as plt from matplotlib.lines import Line2D +from matplotlib.transforms import Affine2D text_style = dict(horizontalalignment='right', verticalalignment='center', @@ -58,9 +60,6 @@ def split_list(a_list): ax.plot([y] * 3, marker=marker, **marker_style) format_axes(ax) -plt.show() - - ############################################################################### # Filled markers # ============== @@ -73,8 +72,6 @@ def split_list(a_list): ax.plot([y] * 3, marker=marker, **marker_style) format_axes(ax) -plt.show() - ############################################################################### # .. _marker_fill_styles: # @@ -100,9 +97,6 @@ def split_list(a_list): ax.plot([y] * 3, fillstyle=fill_style, **filled_marker_style) format_axes(ax) -plt.show() - - ############################################################################### # Markers created from TeX symbols # ================================ @@ -126,9 +120,6 @@ def split_list(a_list): ax.plot([y] * 3, marker=marker, **marker_style) format_axes(ax) -plt.show() - - ############################################################################### # Markers created from Paths # ========================== @@ -158,4 +149,93 @@ def split_list(a_list): ax.plot([y] * 3, marker=marker, **marker_style) format_axes(ax) +############################################################################### +# Advanced marker modifications with transform +# ============================================ +# +# Markers can be modified by passing a transform to the MarkerStyle +# constructor. Following example shows how a supplied rotation is applied to +# several marker shapes. + +common_style = {k: v for k, v in filled_marker_style.items() if k != 'marker'} +angles = [0, 10, 20, 30, 45, 60, 90] + +fig, ax = plt.subplots() +fig.suptitle('Rotated markers', fontsize=14) + +ax.text(-0.5, 0, 'Filled marker', **text_style) +for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + ax.plot(x, 0, marker=MarkerStyle('o', 'left', t), **common_style) + +ax.text(-0.5, 1, 'Un-filled marker', **text_style) +for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + ax.plot(x, 1, marker=MarkerStyle('1', 'left', t), **common_style) + +ax.text(-0.5, 2, 'Equation marker', **text_style) +for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + eq = r'$\frac{1}{x}$' + ax.plot(x, 2, marker=MarkerStyle(eq, 'left', t), **common_style) + +for x, theta in enumerate(angles): + ax.text(x, 2.5, f"{theta}°", horizontalalignment="center") +format_axes(ax) + +fig.tight_layout() + +############################################################################### +# Setting marker cap style and join style +# ======================================= +# +# Markers have default cap and join styles, but these can be +# customized when creating a MarkerStyle. + +from matplotlib.markers import JoinStyle, CapStyle + +marker_inner = dict(markersize=35, + markerfacecolor='tab:blue', + markerfacecoloralt='lightsteelblue', + markeredgecolor='brown', + markeredgewidth=8, + ) + +marker_outer = dict(markersize=35, + markerfacecolor='tab:blue', + markerfacecoloralt='lightsteelblue', + markeredgecolor='white', + markeredgewidth=1, + ) + +fig, ax = plt.subplots() +fig.suptitle('Marker CapStyle', fontsize=14) +fig.subplots_adjust(left=0.1) + +for y, cap_style in enumerate(CapStyle): + ax.text(-0.5, y, cap_style.name, **text_style) + for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + m = MarkerStyle('1', transform=t, capstyle=cap_style) + ax.plot(x, y, marker=m, **marker_inner) + ax.plot(x, y, marker=m, **marker_outer) + ax.text(x, len(CapStyle) - .5, f'{theta}°', ha='center') +format_axes(ax) + +############################################################################### +# Modifying the join style: + +fig, ax = plt.subplots() +fig.suptitle('Marker JoinStyle', fontsize=14) +fig.subplots_adjust(left=0.05) + +for y, join_style in enumerate(JoinStyle): + ax.text(-0.5, y, join_style.name, **text_style) + for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + m = MarkerStyle('*', transform=t, joinstyle=join_style) + ax.plot(x, y, marker=m, **marker_inner) + ax.text(x, len(JoinStyle) - .5, f'{theta}°', ha='center') +format_axes(ax) + plt.show() diff --git a/examples/lines_bars_and_markers/markevery_prop_cycle.py b/examples/lines_bars_and_markers/markevery_prop_cycle.py deleted file mode 100644 index 295de4756736..000000000000 --- a/examples/lines_bars_and_markers/markevery_prop_cycle.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -========================================= -prop_cycle property markevery in rcParams -========================================= - -This example demonstrates a working solution to issue #8576, providing full -support of the markevery property for axes.prop_cycle assignments through -rcParams. Makes use of the same list of markevery cases from the -:doc:`markevery demo -`. - -Renders a plot with shifted-sine curves along each column with -a unique markevery value for each sine curve. -""" -from cycler import cycler -import numpy as np -import matplotlib as mpl -import matplotlib.pyplot as plt - -# Define a list of markevery cases and color cases to plot -cases = [None, - 8, - (30, 8), - [16, 24, 30], - [0, -1], - slice(100, 200, 3), - 0.1, - 0.3, - 1.5, - (0.0, 0.1), - (0.45, 0.1)] - -colors = ['#1f77b4', - '#ff7f0e', - '#2ca02c', - '#d62728', - '#9467bd', - '#8c564b', - '#e377c2', - '#7f7f7f', - '#bcbd22', - '#17becf', - '#1a55FF'] - -# Configure rcParams axes.prop_cycle to simultaneously cycle cases and colors. -mpl.rcParams['axes.prop_cycle'] = cycler(markevery=cases, color=colors) - -# Create data points and offsets -x = np.linspace(0, 2 * np.pi) -offsets = np.linspace(0, 2 * np.pi, 11, endpoint=False) -yy = np.transpose([np.sin(x + phi) for phi in offsets]) - -# Set the plot curve with markers and a title -fig = plt.figure() -ax = fig.add_axes([0.1, 0.1, 0.6, 0.75]) - -for i in range(len(cases)): - ax.plot(yy[:, i], marker='o', label=str(cases[i])) - ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0.) - -plt.title('Support for axes.prop_cycle cycler with markevery') - -plt.show() diff --git a/examples/lines_bars_and_markers/multicolored_line.py b/examples/lines_bars_and_markers/multicolored_line.py index c23de022f23f..da15a570686b 100644 --- a/examples/lines_bars_and_markers/multicolored_line.py +++ b/examples/lines_bars_and_markers/multicolored_line.py @@ -3,7 +3,7 @@ Multicolored lines ================== -This example shows how to make a multi-colored line. In this example, the line +This example shows how to make a multicolored line. In this example, the line is colored based on its derivative. """ @@ -17,7 +17,7 @@ dydx = np.cos(0.5 * (x[:-1] + x[1:])) # first derivative # Create a set of line segments so that we can color them individually -# This creates the points as a N x 1 x 2 array so that we can stack points +# This creates the points as an N x 1 x 2 array so that we can stack points # together easily to get the segments. The segments array for line collection # needs to be (numlines) x (points per line) x 2 (for x and y) points = np.array([x, y]).T.reshape(-1, 1, 2) diff --git a/examples/lines_bars_and_markers/multivariate_marker_plot.py b/examples/lines_bars_and_markers/multivariate_marker_plot.py new file mode 100644 index 000000000000..7627375b99f5 --- /dev/null +++ b/examples/lines_bars_and_markers/multivariate_marker_plot.py @@ -0,0 +1,46 @@ +""" +============================================== +Mapping marker properties to multivariate data +============================================== + +This example shows how to use different properties of markers to plot +multivariate datasets. Here we represent a successful baseball throw as a +smiley face with marker size mapped to the skill of thrower, marker rotation to +the take-off angle, and thrust to the marker color. +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.markers import MarkerStyle +from matplotlib.transforms import Affine2D +from matplotlib.text import TextPath +from matplotlib.colors import Normalize + +SUCCESS_SYMBOLS = [ + TextPath((0, 0), "☹"), + TextPath((0, 0), "😒"), + TextPath((0, 0), "☺"), +] + +N = 25 +np.random.seed(42) +skills = np.random.uniform(5, 80, size=N) * 0.1 + 5 +takeoff_angles = np.random.normal(0, 90, N) +thrusts = np.random.uniform(size=N) +successful = np.random.randint(0, 3, size=N) +positions = np.random.normal(size=(N, 2)) * 5 +data = zip(skills, takeoff_angles, thrusts, successful, positions) + +cmap = plt.colormaps["plasma"] +fig, ax = plt.subplots() +fig.suptitle("Throwing success", size=14) +for skill, takeoff, thrust, mood, pos in data: + t = Affine2D().scale(skill).rotate_deg(takeoff) + m = MarkerStyle(SUCCESS_SYMBOLS[mood], transform=t) + ax.plot(pos[0], pos[1], marker=m, color=cmap(thrust)) +fig.colorbar(plt.cm.ScalarMappable(norm=Normalize(0, 1), cmap=cmap), + ax=ax, label="Normalized Thrust [a.u.]") +ax.set_xlabel("X position [m]") +ax.set_ylabel("Y position [m]") + +plt.show() diff --git a/examples/lines_bars_and_markers/psd_demo.py b/examples/lines_bars_and_markers/psd_demo.py index 43fcc9bd47b4..0ab038c2991b 100644 --- a/examples/lines_bars_and_markers/psd_demo.py +++ b/examples/lines_bars_and_markers/psd_demo.py @@ -12,7 +12,6 @@ import matplotlib.pyplot as plt import numpy as np import matplotlib.mlab as mlab -import matplotlib.gridspec as gridspec # Fixing random state for reproducibility np.random.seed(19680801) @@ -58,39 +57,44 @@ y = y + np.random.randn(*t.shape) # Plot the raw time series -fig = plt.figure(constrained_layout=True) -gs = gridspec.GridSpec(2, 3, figure=fig) -ax = fig.add_subplot(gs[0, :]) -ax.plot(t, y) -ax.set_xlabel('time [s]') -ax.set_ylabel('signal') +fig, axs = plt.subplot_mosaic([ + ['signal', 'signal', 'signal'], + ['zero padding', 'block size', 'overlap'], +], layout='constrained') + +axs['signal'].plot(t, y) +axs['signal'].set_xlabel('time [s]') +axs['signal'].set_ylabel('signal') # Plot the PSD with different amounts of zero padding. This uses the entire # time series at once -ax2 = fig.add_subplot(gs[1, 0]) -ax2.psd(y, NFFT=len(t), pad_to=len(t), Fs=fs) -ax2.psd(y, NFFT=len(t), pad_to=len(t) * 2, Fs=fs) -ax2.psd(y, NFFT=len(t), pad_to=len(t) * 4, Fs=fs) -ax2.set_title('zero padding') +axs['zero padding'].psd(y, NFFT=len(t), pad_to=len(t), Fs=fs) +axs['zero padding'].psd(y, NFFT=len(t), pad_to=len(t) * 2, Fs=fs) +axs['zero padding'].psd(y, NFFT=len(t), pad_to=len(t) * 4, Fs=fs) # Plot the PSD with different block sizes, Zero pad to the length of the # original data sequence. -ax3 = fig.add_subplot(gs[1, 1], sharex=ax2, sharey=ax2) -ax3.psd(y, NFFT=len(t), pad_to=len(t), Fs=fs) -ax3.psd(y, NFFT=len(t) // 2, pad_to=len(t), Fs=fs) -ax3.psd(y, NFFT=len(t) // 4, pad_to=len(t), Fs=fs) -ax3.set_ylabel('') -ax3.set_title('block size') +axs['block size'].psd(y, NFFT=len(t), pad_to=len(t), Fs=fs) +axs['block size'].psd(y, NFFT=len(t) // 2, pad_to=len(t), Fs=fs) +axs['block size'].psd(y, NFFT=len(t) // 4, pad_to=len(t), Fs=fs) +axs['block size'].set_ylabel('') # Plot the PSD with different amounts of overlap between blocks -ax4 = fig.add_subplot(gs[1, 2], sharex=ax2, sharey=ax2) -ax4.psd(y, NFFT=len(t) // 2, pad_to=len(t), noverlap=0, Fs=fs) -ax4.psd(y, NFFT=len(t) // 2, pad_to=len(t), - noverlap=int(0.05 * len(t) / 2.), Fs=fs) -ax4.psd(y, NFFT=len(t) // 2, pad_to=len(t), - noverlap=int(0.2 * len(t) / 2.), Fs=fs) -ax4.set_ylabel('') -ax4.set_title('overlap') +axs['overlap'].psd(y, NFFT=len(t) // 2, pad_to=len(t), noverlap=0, Fs=fs) +axs['overlap'].psd(y, NFFT=len(t) // 2, pad_to=len(t), + noverlap=int(0.025 * len(t)), Fs=fs) +axs['overlap'].psd(y, NFFT=len(t) // 2, pad_to=len(t), + noverlap=int(0.1 * len(t)), Fs=fs) +axs['overlap'].set_ylabel('') +axs['overlap'].set_title('overlap') + +for title, ax in axs.items(): + if title == 'signal': + continue + + ax.set_title(title) + ax.sharex(axs['zero padding']) + ax.sharey(axs['zero padding']) plt.show() diff --git a/examples/lines_bars_and_markers/scatter_custom_symbol.py b/examples/lines_bars_and_markers/scatter_custom_symbol.py index 428a68318671..b8e57b0e5e1f 100644 --- a/examples/lines_bars_and_markers/scatter_custom_symbol.py +++ b/examples/lines_bars_and_markers/scatter_custom_symbol.py @@ -1,18 +1,43 @@ """ -===================== -Scatter Custom Symbol -===================== - -Creating a custom ellipse symbol in scatter plot. +================================= +Scatter plots with custom symbols +================================= +.. redirect-from:: /gallery/lines_bars_and_markers/scatter_symbol +.. redirect-from:: /gallery/lines_bars_and_markers/scatter_piecharts """ + +############################################################################## +# Using TeX symbols +# ----------------- +# An easy way to customize scatter symbols is passing a TeX symbol name +# enclosed in $-signs as a marker. Below we use ``marker=r'$\clubsuit$'``. + import matplotlib.pyplot as plt import numpy as np - # Fixing random state for reproducibility np.random.seed(19680801) + +x = np.arange(0.0, 50.0, 2.0) +y = x ** 1.3 + np.random.rand(*x.shape) * 30.0 +sizes = np.random.rand(*x.shape) * 800 + 500 + +fig, ax = plt.subplots() +ax.scatter(x, y, sizes, c="green", alpha=0.5, marker=r'$\clubsuit$', + label="Luck") +ax.set_xlabel("Leprechauns") +ax.set_ylabel("Gold") +ax.legend() +plt.show() + +############################################################################## +# Using a custom path +# ------------------- +# Alternatively, one can also pass a custom path of N vertices as a Nx2 array +# of x, y values as *marker*. + # unit area ellipse rx, ry = 3., 1. area = rx * ry * np.pi diff --git a/examples/lines_bars_and_markers/scatter_demo2.py b/examples/lines_bars_and_markers/scatter_demo2.py index 7a669ff05d11..ec7e8183f8c4 100644 --- a/examples/lines_bars_and_markers/scatter_demo2.py +++ b/examples/lines_bars_and_markers/scatter_demo2.py @@ -9,9 +9,10 @@ import matplotlib.pyplot as plt import matplotlib.cbook as cbook -# Load a numpy record array from yahoo csv data with fields date, open, close, -# volume, adj_close from the mpl-data/example directory. The record array -# stores the date as an np.datetime64 with a day unit ('D') in the date column. +# Load a numpy record array from yahoo csv data with fields date, open, high, +# low, close, volume, adj_close from the mpl-data/sample_data directory. The +# record array stores the date as an np.datetime64 with a day unit ('D') in +# the date column. price_data = (cbook.get_sample_data('goog.npz', np_load=True)['price_data'] .view(np.recarray)) price_data = price_data[-250:] # get the most recent 250 trading days diff --git a/examples/lines_bars_and_markers/scatter_hist.py b/examples/lines_bars_and_markers/scatter_hist.py index 395b413ceb6a..29b2f96947a4 100644 --- a/examples/lines_bars_and_markers/scatter_hist.py +++ b/examples/lines_bars_and_markers/scatter_hist.py @@ -3,18 +3,22 @@ Scatter plot with histograms ============================ -Show the marginal distributions of a scatter as histograms at the sides of +Show the marginal distributions of a scatter plot as histograms at the sides of the plot. For a nice alignment of the main axes with the marginals, two options are shown -below. +below: -* the axes positions are defined in terms of rectangles in figure coordinates -* the axes positions are defined via a gridspec +.. contents:: + :local: + +While `.Axes.inset_axes` may be a bit more complex, it allows correct handling +of main axes with a fixed aspect ratio. An alternative method to produce a similar figure using the ``axes_grid1`` -toolkit is shown in the -:doc:`/gallery/axes_grid1/scatter_hist_locatable_axes` example. +toolkit is shown in the :doc:`/gallery/axes_grid1/scatter_hist_locatable_axes` +example. Finally, it is also possible to position all axes in absolute +coordinates using `.Figure.add_axes` (not shown here). Let us first define a function that takes x and y data as input, as well as three axes, the main axes for the scatter, and two marginal axes. It will @@ -52,60 +56,53 @@ def scatter_hist(x, y, ax, ax_histx, ax_histy): ############################################################################# # -# Axes in figure coordinates -# -------------------------- -# -# To define the axes positions, `.Figure.add_axes` is provided with a rectangle -# ``[left, bottom, width, height]`` in figure coordinates. The marginal axes -# share one dimension with the main axes. - -# definitions for the axes -left, width = 0.1, 0.65 -bottom, height = 0.1, 0.65 -spacing = 0.005 - - -rect_scatter = [left, bottom, width, height] -rect_histx = [left, bottom + height + spacing, width, 0.2] -rect_histy = [left + width + spacing, bottom, 0.2, height] - -# start with a square Figure -fig = plt.figure(figsize=(8, 8)) - -ax = fig.add_axes(rect_scatter) -ax_histx = fig.add_axes(rect_histx, sharex=ax) -ax_histy = fig.add_axes(rect_histy, sharey=ax) - -# use the previously defined function -scatter_hist(x, y, ax, ax_histx, ax_histy) - -plt.show() - - -############################################################################# -# -# Using a gridspec -# ---------------- +# Defining the axes positions using a gridspec +# -------------------------------------------- # -# We may equally define a gridspec with unequal width- and height-ratios to -# achieve desired layout. Also see the -# :doc:`/tutorials/intermediate/arranging_axes` tutorial. - -# start with a square Figure -fig = plt.figure(figsize=(8, 8)) +# We define a gridspec with unequal width- and height-ratios to achieve desired +# layout. Also see the :doc:`/tutorials/intermediate/arranging_axes` tutorial. -# Add a gridspec with two rows and two columns and a ratio of 2 to 7 between +# Start with a square Figure. +fig = plt.figure(figsize=(6, 6)) +# Add a gridspec with two rows and two columns and a ratio of 1 to 4 between # the size of the marginal axes and the main axes in both directions. # Also adjust the subplot parameters for a square plot. -gs = fig.add_gridspec(2, 2, width_ratios=(7, 2), height_ratios=(2, 7), +gs = fig.add_gridspec(2, 2, width_ratios=(4, 1), height_ratios=(1, 4), left=0.1, right=0.9, bottom=0.1, top=0.9, wspace=0.05, hspace=0.05) - +# Create the Axes. ax = fig.add_subplot(gs[1, 0]) ax_histx = fig.add_subplot(gs[0, 0], sharex=ax) ax_histy = fig.add_subplot(gs[1, 1], sharey=ax) +# Draw the scatter plot and marginals. +scatter_hist(x, y, ax, ax_histx, ax_histy) + -# use the previously defined function +############################################################################# +# +# Defining the axes positions using inset_axes +# -------------------------------------------- +# +# `~.Axes.inset_axes` can be used to position marginals *outside* the main +# axes. The advantage of doing so is that the aspect ratio of the main axes +# can be fixed, and the marginals will always be drawn relative to the position +# of the axes. + +# Create a Figure, which doesn't have to be square. +fig = plt.figure(constrained_layout=True) +# Create the main axes, leaving 25% of the figure space at the top and on the +# right to position marginals. +ax = fig.add_gridspec(top=0.75, right=0.75).subplots() +# The main axes' aspect can be fixed. +ax.set(aspect=1) +# Create marginal axes, which have 25% of the size of the main axes. Note that +# the inset axes are positioned *outside* (on the right and the top) of the +# main axes, by specifying axes coordinates greater than 1. Axes coordinates +# less than 0 would likewise specify positions on the left and the bottom of +# the main axes. +ax_histx = ax.inset_axes([0, 1.05, 1, 0.25], sharex=ax) +ax_histy = ax.inset_axes([1.05, 0, 0.25, 1], sharey=ax) +# Draw the scatter plot and marginals. scatter_hist(x, y, ax, ax_histx, ax_histy) plt.show() @@ -118,8 +115,8 @@ def scatter_hist(x, y, ax, ax_histx, ax_histy): # The use of the following functions, methods, classes and modules is shown # in this example: # -# - `matplotlib.figure.Figure.add_axes` # - `matplotlib.figure.Figure.add_subplot` # - `matplotlib.figure.Figure.add_gridspec` +# - `matplotlib.axes.Axes.inset_axes` # - `matplotlib.axes.Axes.scatter` # - `matplotlib.axes.Axes.hist` diff --git a/examples/lines_bars_and_markers/scatter_piecharts.py b/examples/lines_bars_and_markers/scatter_piecharts.py deleted file mode 100644 index 09eb1c0b6be3..000000000000 --- a/examples/lines_bars_and_markers/scatter_piecharts.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -=================================== -Scatter plot with pie chart markers -=================================== - -This example makes custom 'pie charts' as the markers for a scatter plot. - -Thanks to Manuel Metz for the example. -""" - -import numpy as np -import matplotlib.pyplot as plt - -# first define the ratios -r1 = 0.2 # 20% -r2 = r1 + 0.4 # 40% - -# define some sizes of the scatter marker -sizes = np.array([60, 80, 120]) - -# calculate the points of the first pie marker -# these are just the origin (0, 0) + some (cos, sin) points on a circle -x1 = np.cos(2 * np.pi * np.linspace(0, r1)) -y1 = np.sin(2 * np.pi * np.linspace(0, r1)) -xy1 = np.row_stack([[0, 0], np.column_stack([x1, y1])]) -s1 = np.abs(xy1).max() - -x2 = np.cos(2 * np.pi * np.linspace(r1, r2)) -y2 = np.sin(2 * np.pi * np.linspace(r1, r2)) -xy2 = np.row_stack([[0, 0], np.column_stack([x2, y2])]) -s2 = np.abs(xy2).max() - -x3 = np.cos(2 * np.pi * np.linspace(r2, 1)) -y3 = np.sin(2 * np.pi * np.linspace(r2, 1)) -xy3 = np.row_stack([[0, 0], np.column_stack([x3, y3])]) -s3 = np.abs(xy3).max() - -fig, ax = plt.subplots() -ax.scatter(range(3), range(3), marker=xy1, s=s1**2 * sizes, facecolor='blue') -ax.scatter(range(3), range(3), marker=xy2, s=s2**2 * sizes, facecolor='green') -ax.scatter(range(3), range(3), marker=xy3, s=s3**2 * sizes, facecolor='red') - -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.axes.Axes.scatter` / `matplotlib.pyplot.scatter` diff --git a/examples/lines_bars_and_markers/scatter_symbol.py b/examples/lines_bars_and_markers/scatter_symbol.py deleted file mode 100644 index d99b6a80a740..000000000000 --- a/examples/lines_bars_and_markers/scatter_symbol.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -============== -Scatter Symbol -============== - -Scatter plot with clover symbols. - -""" -import matplotlib.pyplot as plt -import numpy as np - -# Fixing random state for reproducibility -np.random.seed(19680801) - - -x = np.arange(0.0, 50.0, 2.0) -y = x ** 1.3 + np.random.rand(*x.shape) * 30.0 -s = np.random.rand(*x.shape) * 800 + 500 - -plt.scatter(x, y, s, c="g", alpha=0.5, marker=r'$\clubsuit$', - label="Luck") -plt.xlabel("Leprechauns") -plt.ylabel("Gold") -plt.legend(loc='upper left') -plt.show() diff --git a/examples/lines_bars_and_markers/scatter_with_legend.py b/examples/lines_bars_and_markers/scatter_with_legend.py index 56e539644bf9..1562092f82d7 100644 --- a/examples/lines_bars_and_markers/scatter_with_legend.py +++ b/examples/lines_bars_and_markers/scatter_with_legend.py @@ -57,7 +57,7 @@ loc="lower left", title="Classes") ax.add_artist(legend1) -# produce a legend with a cross section of sizes from the scatter +# produce a legend with a cross-section of sizes from the scatter handles, labels = scatter.legend_elements(prop="sizes", alpha=0.6) legend2 = ax.legend(handles, labels, loc="upper right", title="Sizes") diff --git a/examples/lines_bars_and_markers/span_regions.py b/examples/lines_bars_and_markers/span_regions.py index 79ebfcd008f2..f5d1cce9cf9b 100644 --- a/examples/lines_bars_and_markers/span_regions.py +++ b/examples/lines_bars_and_markers/span_regions.py @@ -1,37 +1,23 @@ """ -================ -Using span_where -================ - -Illustrate some helper functions for shading regions where a logical -mask is True. - -See `matplotlib.collections.BrokenBarHCollection.span_where`. +========================================================== +Shade regions defined by a logical mask using fill_between +========================================================== """ import numpy as np import matplotlib.pyplot as plt -import matplotlib.collections as collections t = np.arange(0.0, 2, 0.01) -s1 = np.sin(2*np.pi*t) -s2 = 1.2*np.sin(4*np.pi*t) - +s = np.sin(2*np.pi*t) fig, ax = plt.subplots() -ax.set_title('using span_where') -ax.plot(t, s1, color='black') -ax.axhline(0, color='black', lw=2) - -collection = collections.BrokenBarHCollection.span_where( - t, ymin=0, ymax=1, where=s1 > 0, facecolor='green', alpha=0.5) -ax.add_collection(collection) -collection = collections.BrokenBarHCollection.span_where( - t, ymin=-1, ymax=0, where=s1 < 0, facecolor='red', alpha=0.5) -ax.add_collection(collection) +ax.plot(t, s, color='black') +ax.axhline(0, color='black') +ax.fill_between(t, 1, where=s > 0, facecolor='green', alpha=.5) +ax.fill_between(t, -1, where=s < 0, facecolor='red', alpha=.5) plt.show() @@ -43,7 +29,4 @@ # The use of the following functions, methods, classes and modules is shown # in this example: # -# - `matplotlib.collections.BrokenBarHCollection` -# - `matplotlib.collections.BrokenBarHCollection.span_where` -# - `matplotlib.axes.Axes.add_collection` -# - `matplotlib.axes.Axes.axhline` +# - `matplotlib.axes.Axes.fill_between` diff --git a/examples/lines_bars_and_markers/timeline.py b/examples/lines_bars_and_markers/timeline.py index 087e7320f6b8..e13bab115a30 100644 --- a/examples/lines_bars_and_markers/timeline.py +++ b/examples/lines_bars_and_markers/timeline.py @@ -23,7 +23,7 @@ url = 'https://api.github.com/repos/matplotlib/matplotlib/releases' url += '?per_page=100' - data = json.loads(urllib.request.urlopen(url, timeout=.4).read().decode()) + data = json.loads(urllib.request.urlopen(url, timeout=1).read().decode()) dates = [] names = [] @@ -55,7 +55,7 @@ ############################################################################## # Next, we'll create a stem plot with some variation in levels as to # distinguish even close-by events. We add markers on the baseline for visual -# emphasis on the one-dimensional nature of the time line. +# emphasis on the one-dimensional nature of the timeline. # # For each event, we add a text label via `~.Axes.annotate`, which is offset # in units of points from the tip of the event line. @@ -82,12 +82,12 @@ horizontalalignment="right", verticalalignment="bottom" if l > 0 else "top") -# format xaxis with 4 month intervals +# format x-axis with 4-month intervals ax.xaxis.set_major_locator(mdates.MonthLocator(interval=4)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y")) plt.setp(ax.get_xticklabels(), rotation=30, ha="right") -# remove y axis and spines +# remove y-axis and spines ax.yaxis.set_visible(False) ax.spines[["left", "top", "right"]].set_visible(False) diff --git a/examples/misc/coords_report.py b/examples/misc/coords_report.py index 84ce03e09a7f..127ce712fc1e 100644 --- a/examples/misc/coords_report.py +++ b/examples/misc/coords_report.py @@ -3,7 +3,8 @@ Coords Report ============= -Override the default reporting of coords. +Override the default reporting of coords as the mouse moves over the axes +in an interactive backend. """ import matplotlib.pyplot as plt @@ -11,14 +12,14 @@ def millions(x): - return '$%1.1fM' % (x*1e-6) + return '$%1.1fM' % (x * 1e-6) # Fixing random state for reproducibility np.random.seed(19680801) x = np.random.rand(20) -y = 1e7*np.random.rand(20) +y = 1e7 * np.random.rand(20) fig, ax = plt.subplots() ax.fmt_ydata = millions diff --git a/examples/misc/custom_projection.py b/examples/misc/custom_projection.py index 7e5a4b0d405c..c5c13e174087 100644 --- a/examples/misc/custom_projection.py +++ b/examples/misc/custom_projection.py @@ -50,10 +50,10 @@ def _init_axis(self): # Do not register xaxis or yaxis with spines -- as done in # Axes._init_axis() -- until GeoAxes.xaxis.clear() works. # self.spines['geo'].register_axis(self.yaxis) - self._update_transScale() - def cla(self): - super().cla() + def clear(self): + # docstring inherited + super().clear() self.set_longitude_grid(30) self.set_latitude_grid(15) @@ -102,7 +102,7 @@ def _set_lim_and_transforms(self): # 2) The above has an output range that is not in the unit # rectangle, so scale and translate it so it fits correctly # within the axes. The peculiar calculations of xscale and - # yscale are specific to a Aitoff-Hammer projection, so don't + # yscale are specific to an Aitoff-Hammer projection, so don't # worry about them too much. self.transAffine = self._get_affine_transform() @@ -270,14 +270,8 @@ def format_coord(self, lon, lat): In this case, we want them to be displayed in degrees N/S/E/W. """ lon, lat = np.rad2deg([lon, lat]) - if lat >= 0.0: - ns = 'N' - else: - ns = 'S' - if lon >= 0.0: - ew = 'E' - else: - ew = 'W' + ns = 'N' if lat >= 0.0 else 'S' + ew = 'E' if lon >= 0.0 else 'W' return ('%f\N{DEGREE SIGN}%s, %f\N{DEGREE SIGN}%s' % (abs(lat), ns, abs(lon), ew)) @@ -338,17 +332,17 @@ def get_data_ratio(self): # so we override all of the following methods to disable it. def can_zoom(self): """ - Return whether this axes supports the zoom box button functionality. + Return whether this Axes supports the zoom box button functionality. - This axes object does not support interactive zoom box. + This Axes object does not support interactive zoom box. """ return False def can_pan(self): """ - Return whether this axes supports the pan/zoom button functionality. + Return whether this Axes supports the pan/zoom button functionality. - This axes object does not support interactive pan/zoom. + This Axes object does not support interactive pan/zoom. """ return False @@ -430,7 +424,7 @@ def __init__(self, *args, **kwargs): self._longitude_cap = np.pi / 2.0 super().__init__(*args, **kwargs) self.set_aspect(0.5, adjustable='box', anchor='C') - self.cla() + self.clear() def _get_core_transform(self, resolution): return self.HammerTransform(resolution) diff --git a/examples/misc/customize_rc.py b/examples/misc/customize_rc.py index d4149c46dd44..3cf877059151 100644 --- a/examples/misc/customize_rc.py +++ b/examples/misc/customize_rc.py @@ -3,7 +3,7 @@ Customize Rc ============ -I'm not trying to make a good looking figure here, but just to show +I'm not trying to make a good-looking figure here, but just to show some examples of customizing `.rcParams` on the fly. If you like to work interactively, and need to create different sets diff --git a/examples/misc/demo_agg_filter.py b/examples/misc/demo_agg_filter.py index f3eb95e520fd..0cdbf81f50b5 100644 --- a/examples/misc/demo_agg_filter.py +++ b/examples/misc/demo_agg_filter.py @@ -38,7 +38,7 @@ class BaseFilter: def get_pad(self, dpi): return 0 - def process_image(padded_src, dpi): + def process_image(self, padded_src, dpi): raise NotImplementedError("Should be overridden by subclasses") def __call__(self, im, dpi): @@ -99,8 +99,19 @@ def process_image(self, padded_src, dpi): class LightFilter(BaseFilter): - - def __init__(self, sigma, fraction=0.5): + """Apply LightSource filter""" + + def __init__(self, sigma, fraction=1): + """ + Parameters + ---------- + sigma : float + sigma for gaussian filter + fraction: number, default: 1 + Increases or decreases the contrast of the hillshade. + See `matplotlib.colors.LightSource` + + """ self.gauss_filter = GaussianFilter(sigma, alpha=1) self.light_source = LightSource() self.fraction = fraction @@ -114,7 +125,8 @@ def process_image(self, padded_src, dpi): rgb = padded_src[:, :, :3] alpha = padded_src[:, :, 3:] rgb2 = self.light_source.shade_rgb(rgb, elevation, - fraction=self.fraction) + fraction=self.fraction, + blend_mode="overlay") return np.concatenate([rgb2, alpha], -1) @@ -213,10 +225,9 @@ def drop_shadow_line(ax): shadow.update_from(l) # offset transform - ot = mtransforms.offset_copy(l.get_transform(), ax.figure, - x=4.0, y=-6.0, units='points') - - shadow.set_transform(ot) + transform = mtransforms.offset_copy(l.get_transform(), ax.figure, + x=4.0, y=-6.0, units='points') + shadow.set_transform(transform) # adjust zorder of the shadow lines so that it is drawn below the # original lines @@ -234,20 +245,19 @@ def drop_shadow_line(ax): def drop_shadow_patches(ax): # Copied from barchart_demo.py N = 5 - men_means = [20, 35, 30, 35, 27] + group1_means = [20, 35, 30, 35, 27] ind = np.arange(N) # the x locations for the groups width = 0.35 # the width of the bars - rects1 = ax.bar(ind, men_means, width, color='r', ec="w", lw=2) + rects1 = ax.bar(ind, group1_means, width, color='r', ec="w", lw=2) - women_means = [25, 32, 34, 20, 25] - rects2 = ax.bar(ind + width + 0.1, women_means, width, + group2_means = [25, 32, 34, 20, 25] + rects2 = ax.bar(ind + width + 0.1, group2_means, width, color='y', ec="w", lw=2) - # gauss = GaussianFilter(1.5, offsets=(1, 1)) - gauss = DropShadowFilter(5, offsets=(1, 1)) - shadow = FilteredArtistList(rects1 + rects2, gauss) + drop = DropShadowFilter(5, offsets=(1, 1)) + shadow = FilteredArtistList(rects1 + rects2, drop) ax.add_artist(shadow) shadow.set_zorder(rects1[0].get_zorder() - 0.1) @@ -259,7 +269,7 @@ def drop_shadow_patches(ax): def light_filter_pie(ax): fracs = [15, 30, 45, 10] - explode = (0, 0.05, 0, 0) + explode = (0.1, 0.2, 0.1, 0.1) pies = ax.pie(fracs, explode=explode) light_filter = LightFilter(9) @@ -269,7 +279,7 @@ def light_filter_pie(ax): p.set(ec="none", lw=2) - gauss = DropShadowFilter(9, offsets=(3, 4), alpha=0.7) + gauss = DropShadowFilter(9, offsets=(3, -4), alpha=0.7) shadow = FilteredArtistList(pies[0], gauss) ax.add_artist(shadow) shadow.set_zorder(pies[0][0].get_zorder() - 0.1) diff --git a/examples/misc/demo_ribbon_box.py b/examples/misc/demo_ribbon_box.py index 9e350182e3dd..ea4fa579e8c5 100644 --- a/examples/misc/demo_ribbon_box.py +++ b/examples/misc/demo_ribbon_box.py @@ -24,7 +24,7 @@ class RibbonBox: nx = original_image.shape[1] def __init__(self, color): - rgb = mcolors.to_rgba(color)[:3] + rgb = mcolors.to_rgb(color) self.im = np.dstack( [self.b_and_h - self.color * (1 - np.array(rgb)), self.alpha]) diff --git a/examples/pyplots/fig_x.py b/examples/misc/fig_x.py similarity index 93% rename from examples/pyplots/fig_x.py rename to examples/misc/fig_x.py index 9ca5a3b13d8a..eaa16d80fa35 100644 --- a/examples/pyplots/fig_x.py +++ b/examples/misc/fig_x.py @@ -4,6 +4,8 @@ ======================= Adding lines to a figure without any axes. + +.. redirect-from:: /gallery/pyplots/fig_x """ import matplotlib.pyplot as plt diff --git a/examples/misc/histogram_path.py b/examples/misc/histogram_path.py index 8cbb64977623..e45f990e2053 100644 --- a/examples/misc/histogram_path.py +++ b/examples/misc/histogram_path.py @@ -4,14 +4,14 @@ ======================================================== Using a path patch to draw rectangles. -The technique of using lots of Rectangle instances, or -the faster method of using PolyCollections, were implemented before we -had proper paths with moveto/lineto, closepoly etc in mpl. Now that -we have them, we can draw collections of regularly shaped objects with -homogeneous properties more efficiently with a PathCollection. This -example makes a histogram -- it's more work to set up the vertex arrays -at the outset, but it should be much faster for large numbers of -objects. + +The technique of using lots of `.Rectangle` instances, or the faster method of +using `.PolyCollection`, were implemented before we had proper paths with +moveto, lineto, closepoly, etc. in Matplotlib. Now that we have them, we can +draw collections of regularly shaped objects with homogeneous properties more +efficiently with a PathCollection. This example makes a histogram -- it's more +work to set up the vertex arrays at the outset, but it should be much faster +for large numbers of objects. """ import numpy as np @@ -19,14 +19,11 @@ import matplotlib.patches as patches import matplotlib.path as path -fig, ax = plt.subplots() - -# Fixing random state for reproducibility -np.random.seed(19680801) +fig, axs = plt.subplots(2) +np.random.seed(19680801) # Fixing random state for reproducibility # histogram our data with numpy - data = np.random.randn(1000) n, bins = np.histogram(data, 50) @@ -36,7 +33,6 @@ bottom = np.zeros(len(left)) top = bottom + n - # we need a (numrects x numsides x 2) numpy array for the path helper # function to build a compound path XY = np.array([[left, left, right, right], [bottom, top, top, bottom]]).T @@ -44,20 +40,16 @@ # get the Path object barpath = path.Path.make_compound_path_from_polys(XY) -# make a patch out of it +# make a patch out of it, don't add a margin at y=0 patch = patches.PathPatch(barpath) -ax.add_patch(patch) - -# update the view limits -ax.set_xlim(left[0], right[-1]) -ax.set_ylim(bottom.min(), top.max()) - -plt.show() +patch.sticky_edges.y[:] = [0] +axs[0].add_patch(patch) +axs[0].autoscale_view() ############################################################################# -# It should be noted that instead of creating a three-dimensional array and -# using `~.path.Path.make_compound_path_from_polys`, we could as well create -# the compound path directly using vertices and codes as shown below +# Instead of creating a three-dimensional array and using +# `~.path.Path.make_compound_path_from_polys`, we could as well create the +# compound path directly using vertices and codes as shown below nrects = len(left) nverts = nrects*(1+3+1) @@ -76,6 +68,14 @@ barpath = path.Path(verts, codes) +# make a patch out of it, don't add a margin at y=0 +patch = patches.PathPatch(barpath) +patch.sticky_edges.y[:] = [0] +axs[1].add_patch(patch) +axs[1].autoscale_view() + +plt.show() + ############################################################################# # # .. admonition:: References diff --git a/examples/misc/hyperlinks_sgskip.py b/examples/misc/hyperlinks_sgskip.py index 292de8de17fa..2d0f6819cfb8 100644 --- a/examples/misc/hyperlinks_sgskip.py +++ b/examples/misc/hyperlinks_sgskip.py @@ -18,7 +18,7 @@ fig = plt.figure() s = plt.scatter([1, 2, 3], [4, 5, 6]) -s.set_urls(['https://www.bbc.co.uk/news', 'https://www.google.com/', None]) +s.set_urls(['https://www.bbc.com/news', 'https://www.google.com/', None]) fig.savefig('scatter.svg') ############################################################################### diff --git a/examples/misc/image_thumbnail_sgskip.py b/examples/misc/image_thumbnail_sgskip.py index 97a9e9a627ea..68e3ba85ed38 100644 --- a/examples/misc/image_thumbnail_sgskip.py +++ b/examples/misc/image_thumbnail_sgskip.py @@ -20,7 +20,7 @@ description="Build thumbnails of all images in a directory.") parser.add_argument("imagedir", type=Path) args = parser.parse_args() -if not args.imagedir.isdir(): +if not args.imagedir.is_dir(): sys.exit(f"Could not find input directory {args.imagedir}") outdir = Path("thumbs") diff --git a/examples/misc/keyword_plotting.py b/examples/misc/keyword_plotting.py index 1d8e2c675148..7c8182330aed 100644 --- a/examples/misc/keyword_plotting.py +++ b/examples/misc/keyword_plotting.py @@ -7,8 +7,8 @@ access particular variables with strings: for example, with `numpy.recarray` or `pandas.DataFrame`. -Matplotlib allows you provide such an object with the ``data`` keyword -argument. If provided, then you may generate plots with the strings +Matplotlib allows you to provide such an object with the ``data`` keyword +argument. If provided, you may generate plots with the strings corresponding to these variables. """ diff --git a/examples/misc/logos2.py b/examples/misc/logos2.py index 528f09e92c18..8febb8b57d60 100644 --- a/examples/misc/logos2.py +++ b/examples/misc/logos2.py @@ -11,7 +11,7 @@ import matplotlib.cm as cm import matplotlib.font_manager from matplotlib.patches import Rectangle, PathPatch -from matplotlib.textpath import TextPath +from matplotlib.text import TextPath import matplotlib.transforms as mtrans MPL_BLUE = '#11557c' @@ -89,7 +89,7 @@ def create_icon_axes(fig, ax_position, lw_bars, lw_grid, lw_border, rgrid): def create_text_axes(fig, height_px): - """Create an axes in *fig* that contains 'matplotlib' as Text.""" + """Create an Axes in *fig* that contains 'matplotlib' as Text.""" ax = fig.add_axes((0, 0, 1, 1)) ax.set_aspect("equal") ax.set_axis_off() diff --git a/examples/misc/multiprocess_sgskip.py b/examples/misc/multiprocess_sgskip.py index d9c0ca9769d1..0ab57e6e3b7c 100644 --- a/examples/misc/multiprocess_sgskip.py +++ b/examples/misc/multiprocess_sgskip.py @@ -1,7 +1,7 @@ """ -============ -Multiprocess -============ +=============== +Multiprocessing +=============== Demo of using multiprocessing for generating data in one process and plotting in another. @@ -94,7 +94,7 @@ def plot(self, finished=False): def main(): pl = NBPlot() - for ii in range(10): + for _ in range(10): pl.plot() time.sleep(0.5) pl.plot(finished=True) diff --git a/examples/misc/pythonic_matplotlib.py b/examples/misc/pythonic_matplotlib.py deleted file mode 100644 index b04d931264f0..000000000000 --- a/examples/misc/pythonic_matplotlib.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -=================== -Pythonic Matplotlib -=================== - -Some people prefer to write more pythonic, object-oriented code -rather than use the pyplot interface to matplotlib. This example shows -you how. - -Unless you are an application developer, I recommend using part of the -pyplot interface, particularly the figure, close, subplot, axes, and -show commands. These hide a lot of complexity from you that you don't -need to see in normal figure creation, like instantiating DPI -instances, managing the bounding boxes of the figure elements, -creating and realizing GUI windows and embedding figures in them. - -If you are an application developer and want to embed matplotlib in -your application, follow the lead of examples/embedding_in_wx.py, -examples/embedding_in_gtk.py or examples/embedding_in_tk.py. In this -case you will want to control the creation of all your figures, -embedding them in application windows, etc. - -If you are a web application developer, you may want to use the -example in webapp_demo.py, which shows how to use the backend agg -figure canvas directly, with none of the globals (current figure, -current axes) that are present in the pyplot interface. Note that -there is no reason why the pyplot interface won't work for web -application developers, however. - -If you see an example in the examples dir written in pyplot interface, -and you want to emulate that using the true python method calls, there -is an easy mapping. Many of those examples use 'set' to control -figure properties. Here's how to map those commands onto instance -methods - -The syntax of set is:: - - plt.setp(object or sequence, somestring, attribute) - -if called with an object, set calls:: - - object.set_somestring(attribute) - -if called with a sequence, set does:: - - for object in sequence: - object.set_somestring(attribute) - -So for your example, if a is your axes object, you can do:: - - a.set_xticklabels([]) - a.set_yticklabels([]) - a.set_xticks([]) - a.set_yticks([]) -""" - -import matplotlib.pyplot as plt -import numpy as np - -t = np.arange(0.0, 1.0, 0.01) - -fig, (ax1, ax2) = plt.subplots(2) - -ax1.plot(t, np.sin(2*np.pi * t)) -ax1.grid(True) -ax1.set_ylim((-2, 2)) -ax1.set_ylabel('1 Hz') -ax1.set_title('A sine wave or two') - -ax1.xaxis.set_tick_params(labelcolor='r') - -ax2.plot(t, np.sin(2 * 2*np.pi * t)) -ax2.grid(True) -ax2.set_ylim((-2, 2)) -l = ax2.set_xlabel('Hi mom') -l.set_color('g') -l.set_fontsize('large') - -plt.show() diff --git a/examples/misc/rasterization_demo.py b/examples/misc/rasterization_demo.py index 824b6ffb3c97..f0188c684fc7 100644 --- a/examples/misc/rasterization_demo.py +++ b/examples/misc/rasterization_demo.py @@ -54,7 +54,7 @@ # pcolormesh with rasterization; enabled by keyword argument ax2.set_aspect(1) ax2.set_title("Rasterization") -m = ax2.pcolormesh(xx, yy, d, rasterized=True) +ax2.pcolormesh(xx, yy, d, rasterized=True) # pcolormesh with an overlaid text without rasterization ax3.set_aspect(1) diff --git a/examples/misc/svg_filter_line.py b/examples/misc/svg_filter_line.py index a64b719f74df..da67250890df 100644 --- a/examples/misc/svg_filter_line.py +++ b/examples/misc/svg_filter_line.py @@ -41,10 +41,9 @@ shadow.set_zorder(l.get_zorder() - 0.5) # offset transform - ot = mtransforms.offset_copy(l.get_transform(), fig1, - x=4.0, y=-6.0, units='points') - - shadow.set_transform(ot) + transform = mtransforms.offset_copy(l.get_transform(), fig1, + x=4.0, y=-6.0, units='points') + shadow.set_transform(transform) # set the id for a later use shadow.set_gid(l.get_label() + "_shadow") diff --git a/examples/misc/svg_filter_pie.py b/examples/misc/svg_filter_pie.py index 65aca9a1a73a..c5feee824db8 100644 --- a/examples/misc/svg_filter_pie.py +++ b/examples/misc/svg_filter_pie.py @@ -1,6 +1,6 @@ """ ============== -SVG Filter Pie +SVG filter pie ============== Demonstrate SVG filtering effects which might be used with Matplotlib. @@ -25,7 +25,7 @@ explode = (0, 0.05, 0, 0) -# We want to draw the shadow for each pie but we will not use "shadow" +# We want to draw the shadow for each pie, but we will not use "shadow" # option as it doesn't save the references to the shadow patches. pies = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') diff --git a/examples/misc/table_demo.py b/examples/misc/table_demo.py index cc23492d5536..8afc4c391841 100644 --- a/examples/misc/table_demo.py +++ b/examples/misc/table_demo.py @@ -51,7 +51,7 @@ # Adjust layout to make room for the table: plt.subplots_adjust(left=0.2, bottom=0.2) -plt.ylabel("Loss in ${0}'s".format(value_increment)) +plt.ylabel(f"Loss in ${value_increment}'s") plt.yticks(values * value_increment, ['%d' % val for val in values]) plt.xticks([]) plt.title('Loss by Disaster') diff --git a/examples/misc/transoffset.py b/examples/misc/transoffset.py index 4acbe1de8a4e..4b1929eb65d9 100644 --- a/examples/misc/transoffset.py +++ b/examples/misc/transoffset.py @@ -11,7 +11,7 @@ Every Artist (Text, Line2D, etc.) has a transform that can be set when the Artist is created, such as by the corresponding -pyplot function. By default this is usually the Axes.transData +pyplot function. By default, this is usually the Axes.transData transform, going from data units to screen pixels. We can use the `.offset_copy` function to make a modified copy of this transform, where the modification consists of an diff --git a/examples/mplot3d/2dcollections3d.py b/examples/mplot3d/2dcollections3d.py index f4d57416355d..96183ddb9912 100644 --- a/examples/mplot3d/2dcollections3d.py +++ b/examples/mplot3d/2dcollections3d.py @@ -3,7 +3,7 @@ Plot 2D data on 3D plot ======================= -Demonstrates using ax.plot's zdir keyword to plot 2D data on +Demonstrates using ax.plot's *zdir* keyword to plot 2D data on selective axes of a 3D plot. """ @@ -43,6 +43,6 @@ # Customize the view angle so it's easier to see that the scatter points lie # on the plane y=0 -ax.view_init(elev=20., azim=-35) +ax.view_init(elev=20., azim=-35, roll=0) plt.show() diff --git a/examples/mplot3d/3d_bars.py b/examples/mplot3d/3d_bars.py index ba6cc228ae48..fa3798d83b81 100644 --- a/examples/mplot3d/3d_bars.py +++ b/examples/mplot3d/3d_bars.py @@ -10,7 +10,7 @@ import matplotlib.pyplot as plt -# setup the figure and axes +# set up the figure and axes fig = plt.figure(figsize=(8, 3)) ax1 = fig.add_subplot(121, projection='3d') ax2 = fig.add_subplot(122, projection='3d') diff --git a/examples/mplot3d/bars3d.py b/examples/mplot3d/bars3d.py index e4678b351bb4..21314057311a 100644 --- a/examples/mplot3d/bars3d.py +++ b/examples/mplot3d/bars3d.py @@ -36,7 +36,7 @@ ax.set_ylabel('Y') ax.set_zlabel('Z') -# On the y axis let's only label the discrete values that we have data for. +# On the y-axis let's only label the discrete values that we have data for. ax.set_yticks(yticks) plt.show() diff --git a/examples/mplot3d/box3d.py b/examples/mplot3d/box3d.py index 7dc82248c3df..bbe4accec183 100644 --- a/examples/mplot3d/box3d.py +++ b/examples/mplot3d/box3d.py @@ -67,9 +67,9 @@ zticks=[0, -150, -300, -450], ) -# Set distance and angle view -ax.view_init(40, -30) -ax.dist = 11 +# Set zoom and angle view +ax.view_init(40, -30, 0) +ax.set_box_aspect(None, zoom=0.9) # Colorbar fig.colorbar(C, ax=ax, fraction=0.02, pad=0.1, label='Name [units]') diff --git a/examples/mplot3d/contour3d.py b/examples/mplot3d/contour3d.py index 2b0a2872d0cc..7b96980e3a73 100644 --- a/examples/mplot3d/contour3d.py +++ b/examples/mplot3d/contour3d.py @@ -1,7 +1,7 @@ """ -================================================== -Demonstrates plotting contour (level) curves in 3D -================================================== +================================= +Plot contour (level) curves in 3D +================================= This is like a contour plot in 2D except that the ``f(x, y)=c`` curve is plotted on the plane ``z=c``. diff --git a/examples/mplot3d/contour3d_2.py b/examples/mplot3d/contour3d_2.py index b6478bc79142..6dbc62eb9427 100644 --- a/examples/mplot3d/contour3d_2.py +++ b/examples/mplot3d/contour3d_2.py @@ -1,9 +1,9 @@ """ -============================================================================ -Demonstrates plotting contour (level) curves in 3D using the extend3d option -============================================================================ +=========================================================== +Plot contour (level) curves in 3D using the extend3d option +=========================================================== -This modification of the contour3d_demo example uses extend3d=True to +This modification of the :doc:`contour3d` example uses ``extend3d=True`` to extend the curves vertically into 'ribbons'. """ diff --git a/examples/mplot3d/contour3d_3.py b/examples/mplot3d/contour3d_3.py index 4b8c5ed77d71..c6a599328697 100644 --- a/examples/mplot3d/contour3d_3.py +++ b/examples/mplot3d/contour3d_3.py @@ -1,30 +1,28 @@ """ -======================================== -Projecting contour profiles onto a graph -======================================== - +===================================== +Project contour profiles onto a graph +===================================== Demonstrates displaying a 3D surface while also projecting contour 'profiles' onto the 'walls' of the graph. - -See contourf3d_demo2 for the filled version. +See :doc:`contourf3d_2` for the filled version. """ from mpl_toolkits.mplot3d import axes3d import matplotlib.pyplot as plt -from matplotlib import cm ax = plt.figure().add_subplot(projection='3d') X, Y, Z = axes3d.get_test_data(0.05) # Plot the 3D surface -ax.plot_surface(X, Y, Z, rstride=8, cstride=8, alpha=0.3) +ax.plot_surface(X, Y, Z, edgecolor='royalblue', lw=0.5, rstride=8, cstride=8, + alpha=0.3) # Plot projections of the contours for each dimension. By choosing offsets # that match the appropriate axes limits, the projected contours will sit on # the 'walls' of the graph. -ax.contour(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm) -ax.contour(X, Y, Z, zdir='x', offset=-40, cmap=cm.coolwarm) -ax.contour(X, Y, Z, zdir='y', offset=40, cmap=cm.coolwarm) +ax.contour(X, Y, Z, zdir='z', offset=-100, cmap='coolwarm') +ax.contour(X, Y, Z, zdir='x', offset=-40, cmap='coolwarm') +ax.contour(X, Y, Z, zdir='y', offset=40, cmap='coolwarm') ax.set(xlim=(-40, 40), ylim=(-40, 40), zlim=(-100, 100), xlabel='X', ylabel='Y', zlabel='Z') diff --git a/examples/mplot3d/contourf3d.py b/examples/mplot3d/contourf3d.py index c15ecdcfd6c0..6f0261ad0908 100644 --- a/examples/mplot3d/contourf3d.py +++ b/examples/mplot3d/contourf3d.py @@ -3,11 +3,11 @@ Filled contours =============== -contourf differs from contour in that it creates filled contours, ie. -a discrete number of colours are used to shade the domain. +`.Axes3D.contourf` differs from `.Axes3D.contour` in that it creates filled +contours, i.e. a discrete number of colours are used to shade the domain. -This is like a contourf plot in 2D except that the shaded region corresponding -to the level c is graphed on the plane z=c. +This is like a `.Axes.contourf` plot in 2D except that the shaded region +corresponding to the level c is graphed on the plane ``z=c``. """ from mpl_toolkits.mplot3d import axes3d diff --git a/examples/mplot3d/contourf3d_2.py b/examples/mplot3d/contourf3d_2.py index e550d0ee5933..d0cb6d4ab79c 100644 --- a/examples/mplot3d/contourf3d_2.py +++ b/examples/mplot3d/contourf3d_2.py @@ -1,30 +1,28 @@ """ -====================================== -Projecting filled contour onto a graph -====================================== - +=================================== +Project filled contour onto a graph +=================================== Demonstrates displaying a 3D surface while also projecting filled contour 'profiles' onto the 'walls' of the graph. - -See contour3d_demo2 for the unfilled version. +See :doc:`contour3d_3` for the unfilled version. """ from mpl_toolkits.mplot3d import axes3d import matplotlib.pyplot as plt -from matplotlib import cm ax = plt.figure().add_subplot(projection='3d') X, Y, Z = axes3d.get_test_data(0.05) # Plot the 3D surface -ax.plot_surface(X, Y, Z, rstride=8, cstride=8, alpha=0.3) +ax.plot_surface(X, Y, Z, edgecolor='royalblue', lw=0.5, rstride=8, cstride=8, + alpha=0.3) # Plot projections of the contours for each dimension. By choosing offsets # that match the appropriate axes limits, the projected contours will sit on # the 'walls' of the graph -ax.contourf(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm) -ax.contourf(X, Y, Z, zdir='x', offset=-40, cmap=cm.coolwarm) -ax.contourf(X, Y, Z, zdir='y', offset=40, cmap=cm.coolwarm) +ax.contourf(X, Y, Z, zdir='z', offset=-100, cmap='coolwarm') +ax.contourf(X, Y, Z, zdir='x', offset=-40, cmap='coolwarm') +ax.contourf(X, Y, Z, zdir='y', offset=40, cmap='coolwarm') ax.set(xlim=(-40, 40), ylim=(-40, 40), zlim=(-100, 100), xlabel='X', ylabel='Y', zlabel='Z') diff --git a/examples/mplot3d/hist3d.py b/examples/mplot3d/hist3d.py index 6577a010c14a..e602f7f1e6c5 100644 --- a/examples/mplot3d/hist3d.py +++ b/examples/mplot3d/hist3d.py @@ -3,7 +3,7 @@ Create 3D histogram of 2D data ============================== -Demo of a histogram for 2 dimensional data as a bar graph in 3D. +Demo of a histogram for 2D data as a bar graph in 3D. """ import matplotlib.pyplot as plt diff --git a/examples/mplot3d/lines3d.py b/examples/mplot3d/lines3d.py index f7578d8657b4..c974bcbdff8d 100644 --- a/examples/mplot3d/lines3d.py +++ b/examples/mplot3d/lines3d.py @@ -1,6 +1,6 @@ """ ================ -Parametric Curve +Parametric curve ================ This example demonstrates plotting a parametric curve in 3D. diff --git a/examples/mplot3d/lorenz_attractor.py b/examples/mplot3d/lorenz_attractor.py index 921bf5a47099..97300119aeea 100644 --- a/examples/mplot3d/lorenz_attractor.py +++ b/examples/mplot3d/lorenz_attractor.py @@ -1,6 +1,6 @@ """ ================ -Lorenz Attractor +Lorenz attractor ================ This is an example of plotting Edward Lorenz's 1963 `"Deterministic Nonperiodic @@ -18,45 +18,41 @@ import matplotlib.pyplot as plt -def lorenz(x, y, z, s=10, r=28, b=2.667): +def lorenz(xyz, *, s=10, r=28, b=2.667): """ - Given: - x, y, z: a point of interest in three dimensional space - s, r, b: parameters defining the lorenz attractor - Returns: - x_dot, y_dot, z_dot: values of the lorenz attractor's partial - derivatives at the point x, y, z + Parameters + ---------- + xyz : array-like, shape (3,) + Point of interest in three-dimensional space. + s, r, b : float + Parameters defining the Lorenz attractor. + + Returns + ------- + xyz_dot : array, shape (3,) + Values of the Lorenz attractor's partial derivatives at *xyz*. """ + x, y, z = xyz x_dot = s*(y - x) y_dot = r*x - y - x*z z_dot = x*y - b*z - return x_dot, y_dot, z_dot + return np.array([x_dot, y_dot, z_dot]) dt = 0.01 num_steps = 10000 -# Need one more for the initial values -xs = np.empty(num_steps + 1) -ys = np.empty(num_steps + 1) -zs = np.empty(num_steps + 1) - -# Set initial values -xs[0], ys[0], zs[0] = (0., 1., 1.05) - +xyzs = np.empty((num_steps + 1, 3)) # Need one more for the initial values +xyzs[0] = (0., 1., 1.05) # Set initial values # Step through "time", calculating the partial derivatives at the current point # and using them to estimate the next point for i in range(num_steps): - x_dot, y_dot, z_dot = lorenz(xs[i], ys[i], zs[i]) - xs[i + 1] = xs[i] + (x_dot * dt) - ys[i + 1] = ys[i] + (y_dot * dt) - zs[i + 1] = zs[i] + (z_dot * dt) - + xyzs[i + 1] = xyzs[i] + lorenz(xyzs[i]) * dt # Plot ax = plt.figure().add_subplot(projection='3d') -ax.plot(xs, ys, zs, lw=0.5) +ax.plot(*xyzs.T, lw=0.5) ax.set_xlabel("X Axis") ax.set_ylabel("Y Axis") ax.set_zlabel("Z Axis") diff --git a/examples/mplot3d/mixed_subplots.py b/examples/mplot3d/mixed_subplots.py index df981ceee4ea..963044020f47 100644 --- a/examples/mplot3d/mixed_subplots.py +++ b/examples/mplot3d/mixed_subplots.py @@ -1,9 +1,9 @@ """ -================================= -2D and 3D *Axes* in same *Figure* -================================= +============================= +2D and 3D axes in same figure +============================= -This example shows a how to plot a 2D and 3D plot on the same figure. +This example shows a how to plot a 2D and a 3D plot on the same figure. """ import matplotlib.pyplot as plt diff --git a/examples/mplot3d/offset.py b/examples/mplot3d/offset.py index 56a14d69fa98..db5e6c1f1842 100644 --- a/examples/mplot3d/offset.py +++ b/examples/mplot3d/offset.py @@ -1,6 +1,6 @@ """ ========================= -Automatic Text Offsetting +Automatic text offsetting ========================= This example demonstrates mplot3d's offset text display. @@ -8,8 +8,8 @@ same way as the axis label, and should also be located "away" from the center of the plot. -This demo triggers the display of the offset text for the x and -y axis by adding 1e5 to X and Y. Anything less would not +This demo triggers the display of the offset text for the x- and +y-axis by adding 1e5 to X and Y. Anything less would not automatically trigger it. """ diff --git a/examples/mplot3d/polys3d.py b/examples/mplot3d/polys3d.py index ce9905958bd6..393f5dcf6544 100644 --- a/examples/mplot3d/polys3d.py +++ b/examples/mplot3d/polys3d.py @@ -10,8 +10,8 @@ from matplotlib.collections import PolyCollection import matplotlib.pyplot as plt +import math import numpy as np -from scipy.stats import poisson # Fixing random state for reproducibility np.random.seed(19680801) @@ -31,7 +31,9 @@ def polygon_under_graph(x, y): lambdas = range(1, 9) # verts[i] is a list of (x, y) pairs defining polygon i. -verts = [polygon_under_graph(x, poisson.pmf(l, x)) for l in lambdas] +gamma = np.vectorize(math.gamma) +verts = [polygon_under_graph(x, l**x * np.exp(-l) / gamma(x + 1)) + for l in lambdas] facecolors = plt.colormaps['viridis_r'](np.linspace(0, 1, len(verts))) poly = PolyCollection(verts, facecolors=facecolors, alpha=.7) diff --git a/examples/mplot3d/projections.py b/examples/mplot3d/projections.py new file mode 100644 index 000000000000..2a1c2c15be15 --- /dev/null +++ b/examples/mplot3d/projections.py @@ -0,0 +1,55 @@ +""" +======================== +3D plot projection types +======================== + +Demonstrates the different camera projections for 3D plots, and the effects of +changing the focal length for a perspective projection. Note that Matplotlib +corrects for the 'zoom' effect of changing the focal length. + +The default focal length of 1 corresponds to a Field of View (FOV) of 90 deg. +An increased focal length between 1 and infinity "flattens" the image, while a +decreased focal length between 1 and 0 exaggerates the perspective and gives +the image more apparent depth. In the limiting case, a focal length of +infinity corresponds to an orthographic projection after correction of the +zoom effect. + +You can calculate focal length from a FOV via the equation: + +.. math:: + + 1 / \\tan (\\mathrm{FOV} / 2) + +Or vice versa: + +.. math:: + + \\mathrm{FOV} = 2 \\arctan (1 / \\mathrm{focal length}) + +""" + +from mpl_toolkits.mplot3d import axes3d +import matplotlib.pyplot as plt + + +fig, axs = plt.subplots(1, 3, subplot_kw={'projection': '3d'}) + +# Get the test data +X, Y, Z = axes3d.get_test_data(0.05) + +# Plot the data +for ax in axs: + ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10) + +# Set the orthographic projection. +axs[0].set_proj_type('ortho') # FOV = 0 deg +axs[0].set_title("'ortho'\nfocal_length = ∞", fontsize=10) + +# Set the perspective projections +axs[1].set_proj_type('persp') # FOV = 90 deg +axs[1].set_title("'persp'\nfocal_length = 1 (default)", fontsize=10) + +axs[2].set_proj_type('persp', focal_length=0.2) # FOV = 157.4 deg +axs[2].set_title("'persp'\nfocal_length = 0.2", fontsize=10) + +plt.show() diff --git a/examples/mplot3d/rotate_axes3d_sgskip.py b/examples/mplot3d/rotate_axes3d_sgskip.py index be8adf3c29a0..8967277ea5d7 100644 --- a/examples/mplot3d/rotate_axes3d_sgskip.py +++ b/examples/mplot3d/rotate_axes3d_sgskip.py @@ -3,9 +3,9 @@ Rotating a 3D plot ================== -A very simple animation of a rotating 3D plot. +A very simple animation of a rotating 3D plot about all three axes. -See wire3d_animation_demo for another simple example of animating a 3D plot. +See :doc:`wire3d_animation_sgskip` for another example of animating a 3D plot. (This example is skipped when building the documentation gallery because it intentionally takes a long time to run) @@ -17,12 +17,34 @@ fig = plt.figure() ax = fig.add_subplot(projection='3d') -# load some test data for demonstration and plot a wireframe -X, Y, Z = axes3d.get_test_data(0.1) -ax.plot_wireframe(X, Y, Z, rstride=5, cstride=5) +# Grab some example data and plot a basic wireframe. +X, Y, Z = axes3d.get_test_data(0.05) +ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10) + +# Set the axis labels +ax.set_xlabel('x') +ax.set_ylabel('y') +ax.set_zlabel('z') + +# Rotate the axes and update +for angle in range(0, 360*4 + 1): + # Normalize the angle to the range [-180, 180] for display + angle_norm = (angle + 180) % 360 - 180 + + # Cycle through a full rotation of elevation, then azimuth, roll, and all + elev = azim = roll = 0 + if angle <= 360: + elev = angle_norm + elif angle <= 360*2: + azim = angle_norm + elif angle <= 360*3: + roll = angle_norm + else: + elev = azim = roll = angle_norm + + # Update the axis view and title + ax.view_init(elev, azim, roll) + plt.title('Elevation: %d°, Azimuth: %d°, Roll: %d°' % (elev, azim, roll)) -# rotate the axes and update -for angle in range(0, 360): - ax.view_init(30, angle) plt.draw() plt.pause(.001) diff --git a/examples/mplot3d/stem3d_demo.py b/examples/mplot3d/stem3d_demo.py index fad155cf4081..0e2dac2f9b58 100644 --- a/examples/mplot3d/stem3d_demo.py +++ b/examples/mplot3d/stem3d_demo.py @@ -26,7 +26,7 @@ # *linefmt*, *markerfmt*, and *basefmt* control basic format properties of the # plot. However, in contrast to `~.axes3d.Axes3D.plot` not all properties are # configurable via keyword arguments. For more advanced control adapt the line -# objects returned by `.stem3D`. +# objects returned by `~mpl_toolkits.mplot3d.axes3d.Axes3D.stem`. fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) markerline, stemlines, baseline = ax.stem( diff --git a/examples/mplot3d/surface3d.py b/examples/mplot3d/surface3d.py index 6a82631fc1ed..07019cb15e31 100644 --- a/examples/mplot3d/surface3d.py +++ b/examples/mplot3d/surface3d.py @@ -4,9 +4,9 @@ ===================== Demonstrates plotting a 3D surface colored with the coolwarm colormap. -The surface is made opaque by using antialiased=False. +The surface is made opaque by using ``antialiased=False``. -Also demonstrates using the LinearLocator and custom formatting for the +Also demonstrates using the `.LinearLocator` and custom formatting for the z axis tick labels. """ diff --git a/examples/mplot3d/surface3d_2.py b/examples/mplot3d/surface3d_2.py index d5cfca1905a3..bca9f1ca62e8 100644 --- a/examples/mplot3d/surface3d_2.py +++ b/examples/mplot3d/surface3d_2.py @@ -23,4 +23,7 @@ # Plot the surface ax.plot_surface(x, y, z) +# Set an equal aspect ratio +ax.set_aspect('equal') + plt.show() diff --git a/examples/mplot3d/text3d.py b/examples/mplot3d/text3d.py index 7f6a2858eb1f..922d161a9f89 100644 --- a/examples/mplot3d/text3d.py +++ b/examples/mplot3d/text3d.py @@ -7,10 +7,10 @@ Functionality shown: -- Using the text function with three types of 'zdir' values: None, an axis - name (ex. 'x'), or a direction tuple (ex. (1, 1, 0)). -- Using the text function with the color keyword. -- Using the text2D function to place text on a fixed position on the ax +- Using the `~.Axes3D.text` function with three types of *zdir* values: None, + an axis name (ex. 'x'), or a direction tuple (ex. (1, 1, 0)). +- Using the `~.Axes3D.text` function with the color keyword. +- Using the `.text2D` function to place text on a fixed position on the ax object. """ diff --git a/examples/mplot3d/trisurf3d_2.py b/examples/mplot3d/trisurf3d_2.py index 728698b7af69..8ed354d5e5e6 100644 --- a/examples/mplot3d/trisurf3d_2.py +++ b/examples/mplot3d/trisurf3d_2.py @@ -6,7 +6,7 @@ Two additional examples of plotting surfaces with triangular mesh. The first demonstrates use of plot_trisurf's triangles argument, and the -second sets a Triangulation object's mask and passes the object directly +second sets a `.Triangulation` object's mask and passes the object directly to plot_trisurf. """ diff --git a/examples/mplot3d/view_planes_3d.py b/examples/mplot3d/view_planes_3d.py new file mode 100644 index 000000000000..c4322d60fe93 --- /dev/null +++ b/examples/mplot3d/view_planes_3d.py @@ -0,0 +1,57 @@ +""" +====================== +Primary 3D view planes +====================== + +This example generates an "unfolded" 3D plot that shows each of the primary 3D +view planes. The elevation, azimuth, and roll angles required for each view are +labeled. You could print out this image and fold it into a box where each plane +forms a side of the box. +""" + +import matplotlib.pyplot as plt + + +def annotate_axes(ax, text, fontsize=18): + ax.text(x=0.5, y=0.5, z=0.5, s=text, + va="center", ha="center", fontsize=fontsize, color="black") + +# (plane, (elev, azim, roll)) +views = [('XY', (90, -90, 0)), + ('XZ', (0, -90, 0)), + ('YZ', (0, 0, 0)), + ('-XY', (-90, 90, 0)), + ('-XZ', (0, 90, 0)), + ('-YZ', (0, 180, 0))] + +layout = [['XY', '.', 'L', '.'], + ['XZ', 'YZ', '-XZ', '-YZ'], + ['.', '.', '-XY', '.']] +fig, axd = plt.subplot_mosaic(layout, subplot_kw={'projection': '3d'}, + figsize=(12, 8.5)) +for plane, angles in views: + axd[plane].set_xlabel('x') + axd[plane].set_ylabel('y') + axd[plane].set_zlabel('z') + axd[plane].set_proj_type('ortho') + axd[plane].view_init(elev=angles[0], azim=angles[1], roll=angles[2]) + axd[plane].set_box_aspect(None, zoom=1.25) + + label = f'{plane}\n{angles}' + annotate_axes(axd[plane], label, fontsize=14) + +for plane in ('XY', '-XY'): + axd[plane].set_zticklabels([]) + axd[plane].set_zlabel('') +for plane in ('XZ', '-XZ'): + axd[plane].set_yticklabels([]) + axd[plane].set_ylabel('') +for plane in ('YZ', '-YZ'): + axd[plane].set_xticklabels([]) + axd[plane].set_xlabel('') + +label = 'mplot3d primary view planes\n' + 'ax.view_init(elev, azim, roll)' +annotate_axes(axd['L'], label, fontsize=18) +axd['L'].set_axis_off() + +plt.show() diff --git a/examples/mplot3d/voxels_numpy_logo.py b/examples/mplot3d/voxels_numpy_logo.py index 9aa72b31e290..34eb48dcbe8a 100644 --- a/examples/mplot3d/voxels_numpy_logo.py +++ b/examples/mplot3d/voxels_numpy_logo.py @@ -1,6 +1,6 @@ """ =============================== -3D voxel plot of the numpy logo +3D voxel plot of the NumPy logo =============================== Demonstrates using `.Axes3D.voxels` with uneven coordinates. @@ -42,5 +42,6 @@ def explode(data): ax = plt.figure().add_subplot(projection='3d') ax.voxels(x, y, z, filled_2, facecolors=fcolors_2, edgecolors=ecolors_2) +ax.set_aspect('equal') plt.show() diff --git a/examples/mplot3d/voxels_rgb.py b/examples/mplot3d/voxels_rgb.py index a06cc10a5cc1..3ee1e1eab1a6 100644 --- a/examples/mplot3d/voxels_rgb.py +++ b/examples/mplot3d/voxels_rgb.py @@ -1,6 +1,6 @@ """ ========================================== -3D voxel / volumetric plot with rgb colors +3D voxel / volumetric plot with RGB colors ========================================== Demonstrates using `.Axes3D.voxels` to visualize parts of a color space. @@ -12,7 +12,7 @@ def midpoints(x): sl = () - for i in range(x.ndim): + for _ in range(x.ndim): x = (x[sl + np.index_exp[:-1]] + x[sl + np.index_exp[1:]]) / 2.0 sl += np.index_exp[:] return x @@ -39,5 +39,6 @@ def midpoints(x): edgecolors=np.clip(2*colors - 0.5, 0, 1), # brighter linewidth=0.5) ax.set(xlabel='r', ylabel='g', zlabel='b') +ax.set_aspect('equal') plt.show() diff --git a/examples/mplot3d/wire3d_animation_sgskip.py b/examples/mplot3d/wire3d_animation_sgskip.py index 252cfffdac77..a7d77ce4e0c4 100644 --- a/examples/mplot3d/wire3d_animation_sgskip.py +++ b/examples/mplot3d/wire3d_animation_sgskip.py @@ -1,12 +1,12 @@ """ -========================== -Rotating 3D wireframe plot -========================== +=========================== +Animate a 3D wireframe plot +=========================== -A very simple 'animation' of a 3D plot. See also rotate_axes3d_demo. +A very simple "animation" of a 3D plot. See also :doc:`rotate_axes3d_sgskip`. (This example is skipped when building the documentation gallery because it -intentionally takes a long time to run) +intentionally takes a long time to run.) """ import matplotlib.pyplot as plt @@ -14,14 +14,6 @@ import time -def generate(X, Y, phi): - """ - Generates Z data for the points in the X, Y meshgrid and parameter phi. - """ - R = 1 - np.sqrt(X**2 + Y**2) - return np.cos(2 * np.pi * X + phi) * R - - fig = plt.figure() ax = fig.add_subplot(projection='3d') @@ -30,7 +22,7 @@ def generate(X, Y, phi): ys = np.linspace(-1, 1, 50) X, Y = np.meshgrid(xs, ys) -# Set the z axis limits so they aren't recalculated each frame. +# Set the z axis limits, so they aren't recalculated each frame. ax.set_zlim(-1, 1) # Begin plotting. @@ -40,9 +32,9 @@ def generate(X, Y, phi): # If a line collection is already remove it before drawing. if wframe: wframe.remove() - + # Generate data. + Z = np.cos(2 * np.pi * X + phi) * (1 - np.hypot(X, Y)) # Plot the new wireframe and pause briefly before continuing. - Z = generate(X, Y, phi) wframe = ax.plot_wireframe(X, Y, Z, rstride=2, cstride=2) plt.pause(.001) diff --git a/examples/mplot3d/wire3d_zero_stride.py b/examples/mplot3d/wire3d_zero_stride.py index 1ba406b753f4..f4c679300820 100644 --- a/examples/mplot3d/wire3d_zero_stride.py +++ b/examples/mplot3d/wire3d_zero_stride.py @@ -3,7 +3,7 @@ 3D wireframe plots in one direction =================================== -Demonstrates that setting rstride or cstride to 0 causes wires to not be +Demonstrates that setting *rstride* or *cstride* to 0 causes wires to not be generated in the corresponding direction. """ diff --git a/examples/pie_and_polar_charts/pie_and_donut_labels.py b/examples/pie_and_polar_charts/pie_and_donut_labels.py index 73e36ac03090..9ef280935c18 100644 --- a/examples/pie_and_polar_charts/pie_and_donut_labels.py +++ b/examples/pie_and_polar_charts/pie_and_donut_labels.py @@ -44,7 +44,7 @@ def func(pct, allvals): absolute = int(np.round(pct/100.*np.sum(allvals))) - return "{:.1f}%\n({:d} g)".format(pct, absolute) + return f"{pct:.1f}%\n({absolute:d} g)" wedges, texts, autotexts = ax.pie(data, autopct=lambda pct: func(pct, data), @@ -108,7 +108,7 @@ def func(pct, allvals): y = np.sin(np.deg2rad(ang)) x = np.cos(np.deg2rad(ang)) horizontalalignment = {-1: "right", 1: "left"}[int(np.sign(x))] - connectionstyle = "angle,angleA=0,angleB={}".format(ang) + connectionstyle = f"angle,angleA=0,angleB={ang}" kw["arrowprops"].update({"connectionstyle": connectionstyle}) ax.annotate(recipe[i], xy=(x, y), xytext=(1.35*np.sign(x), 1.4*y), horizontalalignment=horizontalalignment, **kw) diff --git a/examples/pie_and_polar_charts/pie_features.py b/examples/pie_and_polar_charts/pie_features.py index d25ce839bf62..d27ed6a03545 100644 --- a/examples/pie_and_polar_charts/pie_features.py +++ b/examples/pie_and_polar_charts/pie_features.py @@ -1,40 +1,100 @@ """ -=============== -Basic pie chart -=============== +========== +Pie charts +========== -Demo of a basic pie chart plus a few additional features. +Demo of plotting a pie chart. -In addition to the basic pie chart, this demo shows a few optional features: - -* slice labels -* auto-labeling the percentage -* offsetting a slice with "explode" -* drop-shadow -* custom start angle +This example illustrates various parameters of `~matplotlib.axes.Axes.pie`. +""" -Note about the custom start angle: +# %% +# Label slices +# ------------ +# +# Plot a pie chart of animals and label the slices. To add +# labels, pass a list of labels to the *labels* parameter -The default ``startangle`` is 0, which would start the "Frogs" slice on the -positive x-axis. This example sets ``startangle = 90`` such that everything is -rotated counter-clockwise by 90 degrees, and the frog slice starts on the -positive y-axis. -""" import matplotlib.pyplot as plt - -# Pie chart, where the slices will be ordered and plotted counter-clockwise: labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' sizes = [15, 30, 45, 10] + +fig, ax = plt.subplots() +ax.pie(sizes, labels=labels) + +# %% +# Each slice of the pie chart is a `.patches.Wedge` object; therefore in +# addition to the customizations shown here, each wedge can be customized using +# the *wedgeprops* argument, as demonstrated in +# :doc:`/gallery/pie_and_polar_charts/nested_pie`. +# +# Auto-label slices +# ----------------- +# +# Pass a function or format string to *autopct* to label slices. + +fig, ax = plt.subplots() +ax.pie(sizes, labels=labels, autopct='%1.1f%%') + +# %% +# By default, the label values are obtained from the percent size of the slice. +# +# Color slices +# ------------ +# +# Pass a list of colors to *colors* to set the color of each slice. + +fig, ax = plt.subplots() +ax.pie(sizes, labels=labels, + colors=['olivedrab', 'rosybrown', 'gray', 'saddlebrown']) + +# %% +# Hatch slices +# ------------ +# +# Pass a list of hatch patterns to *hatch* to set the pattern of each slice. + +fig, ax = plt.subplots() +ax.pie(sizes, labels=labels, hatch=['**O', 'oO', 'O.O', '.||.']) + +# %% +# Swap label and autopct text positions +# ------------------------------------- +# Use the *labeldistance* and *pctdistance* parameters to position the *labels* +# and *autopct* text respectively. + +fig, ax = plt.subplots() +ax.pie(sizes, labels=labels, autopct='%1.1f%%', + pctdistance=1.25, labeldistance=.6) + +# %% +# *labeldistance* and *pctdistance* are ratios of the radius; therefore they +# vary between ``0`` for the center of the pie and ``1`` for the edge of the +# pie, and can be set to greater than ``1`` to place text outside the pie. +# +# Explode, shade, and rotate slices +# --------------------------------- +# +# In addition to the basic pie chart, this demo shows a few optional features: +# +# * offsetting a slice using *explode* +# * add a drop-shadow using *shadow* +# * custom start angle using *startangle* +# +# This example orders the slices, separates (explodes) them, and rotates them. + explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') -fig1, ax1 = plt.subplots() -ax1.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%', +fig, ax = plt.subplots() +ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%', shadow=True, startangle=90) -ax1.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle. - plt.show() -############################################################################# +# %% +# The default *startangle* is 0, which would start the first slice ("Frogs") on +# the positive x-axis. This example sets ``startangle = 90`` such that all the +# slices are rotated counter-clockwise by 90 degrees, and the frog slice starts +# on the positive y-axis. # # .. admonition:: References # diff --git a/examples/pie_and_polar_charts/polar_error_caps.py b/examples/pie_and_polar_charts/polar_error_caps.py new file mode 100644 index 000000000000..009f697346f3 --- /dev/null +++ b/examples/pie_and_polar_charts/polar_error_caps.py @@ -0,0 +1,53 @@ +""" +================================= +Error bar rendering on polar axis +================================= + +Demo of error bar plot in polar coordinates. +Theta error bars are curved lines ended with caps oriented towards the +center. +Radius error bars are straight lines oriented towards center with +perpendicular caps. +""" +import numpy as np +import matplotlib.pyplot as plt + +theta = np.arange(0, 2 * np.pi, np.pi / 4) +r = theta / np.pi / 2 + 0.5 + +fig = plt.figure(figsize=(10, 10)) +ax = fig.add_subplot(projection='polar') +ax.errorbar(theta, r, xerr=0.25, yerr=0.1, capsize=7, fmt="o", c="seagreen") +ax.set_title("Pretty polar error bars") +plt.show() + +############################################################################# +# Please acknowledge that large theta error bars will be overlapping. +# This may reduce readability of the output plot. See example figure below: + +fig = plt.figure(figsize=(10, 10)) +ax = fig.add_subplot(projection='polar') +ax.errorbar(theta, r, xerr=5.25, yerr=0.1, capsize=7, fmt="o", c="darkred") +ax.set_title("Overlapping theta error bars") +plt.show() + +############################################################################# +# On the other hand, large radius error bars will never overlap, they just +# lead to unwanted scale in the data, reducing the displayed range. + +fig = plt.figure(figsize=(10, 10)) +ax = fig.add_subplot(projection='polar') +ax.errorbar(theta, r, xerr=0.25, yerr=10.1, capsize=7, fmt="o", c="orangered") +ax.set_title("Large radius error bars") +plt.show() + + +############################################################################# +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.axes.Axes.errorbar` / `matplotlib.pyplot.errorbar` +# - `matplotlib.projections.polar` diff --git a/examples/pie_and_polar_charts/polar_legend.py b/examples/pie_and_polar_charts/polar_legend.py index ddea65cea39e..955e435c9abd 100644 --- a/examples/pie_and_polar_charts/polar_legend.py +++ b/examples/pie_and_polar_charts/polar_legend.py @@ -1,9 +1,9 @@ """ ============ -Polar Legend +Polar legend ============ -Demo of a legend on a polar-axis plot. +Using a legend on a polar-axis plot. """ import matplotlib.pyplot as plt @@ -19,7 +19,7 @@ ax.tick_params(grid_color="palegoldenrod") # For polar axes, it may be useful to move the legend slightly away from the # axes center, to avoid overlap between the legend and the axes. The following -# snippet places the legend's lower left corner just outside of the polar axes +# snippet places the legend's lower left corner just outside the polar axes # at an angle of 67.5 degrees in polar coordinates. angle = np.deg2rad(67.5) ax.legend(loc="lower left", diff --git a/examples/pyplots/README.txt b/examples/pyplots/README.txt index 30c7d60fdb36..ff10c9fd6b1a 100644 --- a/examples/pyplots/README.txt +++ b/examples/pyplots/README.txt @@ -1,4 +1,4 @@ .. _pyplots_examples: -pyplot -====== +The pyplot module +================= diff --git a/examples/pyplots/axline.py b/examples/pyplots/axline.py index 68149eaafc4f..5da9dc1a68ef 100644 --- a/examples/pyplots/axline.py +++ b/examples/pyplots/axline.py @@ -10,6 +10,7 @@ `~.axes.Axes.axline` draws infinite straight lines in arbitrary directions. """ + import numpy as np import matplotlib.pyplot as plt diff --git a/examples/pyplots/boxplot_demo_pyplot.py b/examples/pyplots/boxplot_demo_pyplot.py deleted file mode 100644 index 501eb2cf3447..000000000000 --- a/examples/pyplots/boxplot_demo_pyplot.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -============ -Boxplot Demo -============ - -Example boxplot code -""" - -import numpy as np -import matplotlib.pyplot as plt - -# Fixing random state for reproducibility -np.random.seed(19680801) - -# fake up some data -spread = np.random.rand(50) * 100 -center = np.ones(25) * 50 -flier_high = np.random.rand(10) * 100 + 100 -flier_low = np.random.rand(10) * -100 -data = np.concatenate((spread, center, flier_high, flier_low)) - -############################################################################### - -fig1, ax1 = plt.subplots() -ax1.set_title('Basic Plot') -ax1.boxplot(data) - -############################################################################### - -fig2, ax2 = plt.subplots() -ax2.set_title('Notched boxes') -ax2.boxplot(data, notch=True) - -############################################################################### - -green_diamond = dict(markerfacecolor='g', marker='D') -fig3, ax3 = plt.subplots() -ax3.set_title('Changed Outlier Symbols') -ax3.boxplot(data, flierprops=green_diamond) - -############################################################################### - -fig4, ax4 = plt.subplots() -ax4.set_title('Hide Outlier Points') -ax4.boxplot(data, showfliers=False) - -############################################################################### - -red_square = dict(markerfacecolor='r', marker='s') -fig5, ax5 = plt.subplots() -ax5.set_title('Horizontal Boxes') -ax5.boxplot(data, vert=False, flierprops=red_square) - -############################################################################### - -fig6, ax6 = plt.subplots() -ax6.set_title('Shorter Whisker Length') -ax6.boxplot(data, flierprops=red_square, vert=False, whis=0.75) - -############################################################################### -# Fake up some more data - -spread = np.random.rand(50) * 100 -center = np.ones(25) * 40 -flier_high = np.random.rand(10) * 100 + 100 -flier_low = np.random.rand(10) * -100 -d2 = np.concatenate((spread, center, flier_high, flier_low)) - -############################################################################### -# Making a 2-D array only works if all the columns are the -# same length. If they are not, then use a list instead. -# This is actually more efficient because boxplot converts -# a 2-D array into a list of vectors internally anyway. - -data = [data, d2, d2[::2]] -fig7, ax7 = plt.subplots() -ax7.set_title('Multiple Samples with Different sizes') -ax7.boxplot(data) - -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.axes.Axes.boxplot` / `matplotlib.pyplot.boxplot` diff --git a/examples/pyplots/fig_axes_labels_simple.py b/examples/pyplots/fig_axes_labels_simple.py deleted file mode 100644 index 41423b0b4845..000000000000 --- a/examples/pyplots/fig_axes_labels_simple.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -================== -Simple axes labels -================== - -Label the axes of a plot. -""" -import numpy as np -import matplotlib.pyplot as plt - -fig = plt.figure() -fig.subplots_adjust(top=0.8) -ax1 = fig.add_subplot(211) -ax1.set_ylabel('volts') -ax1.set_title('a sine wave') - -t = np.arange(0.0, 1.0, 0.01) -s = np.sin(2 * np.pi * t) -line, = ax1.plot(t, s, lw=2) - -# Fixing random state for reproducibility -np.random.seed(19680801) - -ax2 = fig.add_axes([0.15, 0.1, 0.7, 0.3]) -n, bins, patches = ax2.hist(np.random.randn(1000), 50) -ax2.set_xlabel('time (s)') - -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.axes.Axes.set_xlabel` -# - `matplotlib.axes.Axes.set_ylabel` -# - `matplotlib.axes.Axes.set_title` -# - `matplotlib.axes.Axes.plot` -# - `matplotlib.axes.Axes.hist` -# - `matplotlib.figure.Figure.add_axes` diff --git a/examples/pyplots/pyplot_formatstr.py b/examples/pyplots/pyplot_formatstr.py deleted file mode 100644 index e9be3830ec98..000000000000 --- a/examples/pyplots/pyplot_formatstr.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -==================== -plot() format string -==================== - -Use a format string (here, 'ro') to set the color and markers of a -`~matplotlib.axes.Axes.plot`. -""" - -import matplotlib.pyplot as plt -plt.plot([1, 2, 3, 4], [1, 4, 9, 16], 'ro') -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.axes.Axes.plot` / `matplotlib.pyplot.plot` diff --git a/examples/pyplots/pyplot_mathtext.py b/examples/pyplots/pyplot_mathtext.py deleted file mode 100644 index af4db39a4e95..000000000000 --- a/examples/pyplots/pyplot_mathtext.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -=============== -Pyplot Mathtext -=============== - -Use mathematical expressions in text labels. For an overview over MathText -see :doc:`/tutorials/text/mathtext`. -""" -import numpy as np -import matplotlib.pyplot as plt -t = np.arange(0.0, 2.0, 0.01) -s = np.sin(2*np.pi*t) - -plt.plot(t, s) -plt.title(r'$\alpha_i > \beta_i$', fontsize=20) -plt.text(1, -0.6, r'$\sum_{i=0}^\infty x_i$', fontsize=20) -plt.text(0.6, 0.6, r'$\mathcal{A}\mathrm{sin}(2 \omega t)$', - fontsize=20) -plt.xlabel('time (s)') -plt.ylabel('volts (mV)') -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.axes.Axes.text` / `matplotlib.pyplot.text` diff --git a/examples/pyplots/pyplot_simple.py b/examples/pyplots/pyplot_simple.py index 7ecdcd406b21..096323b626dc 100644 --- a/examples/pyplots/pyplot_simple.py +++ b/examples/pyplots/pyplot_simple.py @@ -1,14 +1,19 @@ """ -============= -Pyplot Simple -============= +=========== +Simple plot +=========== -A very simple pyplot where a list of numbers are ploted against their -index. Creates a straight line due to the rate of change being 1 for -both the X and Y axis. +A simple plot where a list of numbers are plotted against their index, +resulting in a straight line. Use a format string (here, 'o-r') to set the +markers (circles), linestyle (solid line) and color (red). + +.. redirect-from:: /gallery/pyplots/fig_axes_labels_simple +.. redirect-from:: /gallery/pyplots/pyplot_formatstr """ + import matplotlib.pyplot as plt -plt.plot([1, 2, 3, 4]) + +plt.plot([1, 2, 3, 4], 'o-r') plt.ylabel('some numbers') plt.show() diff --git a/examples/pyplots/pyplot_text.py b/examples/pyplots/pyplot_text.py index e50e9038827a..00e738ef414b 100644 --- a/examples/pyplots/pyplot_text.py +++ b/examples/pyplots/pyplot_text.py @@ -1,29 +1,29 @@ """ -=========== -Pyplot Text -=========== +============================== +Text and mathtext using pyplot +============================== -""" -import numpy as np -import matplotlib.pyplot as plt +Set the special text objects `~.pyplot.title`, `~.pyplot.xlabel`, and +`~.pyplot.ylabel` through the dedicated pyplot functions. Additional text +objects can be placed in the axes using `~.pyplot.text`. -# Fixing random state for reproducibility -np.random.seed(19680801) +You can use TeX-like mathematical typesetting in all texts; see also +:doc:`/tutorials/text/mathtext`. -mu, sigma = 100, 15 -x = mu + sigma * np.random.randn(10000) +.. redirect-from:: /gallery/pyplots/pyplot_mathtext +""" -# the histogram of the data -n, bins, patches = plt.hist(x, 50, density=True, facecolor='g', alpha=0.75) +import numpy as np +import matplotlib.pyplot as plt +t = np.arange(0.0, 2.0, 0.01) +s = np.sin(2*np.pi*t) -plt.xlabel('Smarts') -plt.ylabel('Probability') -plt.title('Histogram of IQ') -plt.text(60, .025, r'$\mu=100,\ \sigma=15$') -plt.xlim(40, 160) -plt.ylim(0, 0.03) -plt.grid(True) +plt.plot(t, s) +plt.text(0, -1, r'Hello, world!', fontsize=15) +plt.title(r'$\mathcal{A}\sin(\omega t)$', fontsize=20) +plt.xlabel('Time [s]') +plt.ylabel('Voltage [mV]') plt.show() ############################################################################# diff --git a/examples/pyplots/pyplot_three.py b/examples/pyplots/pyplot_three.py index 476796b45f81..7dfcddb7bb94 100644 --- a/examples/pyplots/pyplot_three.py +++ b/examples/pyplots/pyplot_three.py @@ -1,10 +1,11 @@ """ -============ -Pyplot Three -============ +=========================== +Multiple lines using pyplot +=========================== -Plot three line plots in a single call to `~matplotlib.pyplot.plot`. +Plot three datasets with a single call to `~matplotlib.pyplot.plot`. """ + import numpy as np import matplotlib.pyplot as plt diff --git a/examples/pyplots/pyplot_two_subplots.py b/examples/pyplots/pyplot_two_subplots.py index 5280fb0bb37a..d316da2fc930 100644 --- a/examples/pyplots/pyplot_two_subplots.py +++ b/examples/pyplots/pyplot_two_subplots.py @@ -1,10 +1,11 @@ """ -=================== -Pyplot Two Subplots -=================== +========================= +Two subplots using pyplot +========================= -Create a figure with two subplots with `.pyplot.subplot`. +Create a figure with two subplots using `.pyplot.subplot`. """ + import numpy as np import matplotlib.pyplot as plt diff --git a/examples/pyplots/text_layout.py b/examples/pyplots/text_layout.py deleted file mode 100644 index 641aa77320a5..000000000000 --- a/examples/pyplots/text_layout.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -=========== -Text Layout -=========== - -Create text with different alignment and rotation. -""" - -import matplotlib.pyplot as plt -import matplotlib.patches as patches - -fig = plt.figure() - -left, width = .25, .5 -bottom, height = .25, .5 -right = left + width -top = bottom + height - -# Draw a rectangle in figure coordinates ((0, 0) is bottom left and (1, 1) is -# upper right). -p = patches.Rectangle((left, bottom), width, height, fill=False) -fig.add_artist(p) - -# Figure.text (aka. plt.figtext) behaves like Axes.text (aka. plt.text), with -# the sole exception that the coordinates are relative to the figure ((0, 0) is -# bottom left and (1, 1) is upper right). -fig.text(left, bottom, 'left top', - horizontalalignment='left', verticalalignment='top') -fig.text(left, bottom, 'left bottom', - horizontalalignment='left', verticalalignment='bottom') -fig.text(right, top, 'right bottom', - horizontalalignment='right', verticalalignment='bottom') -fig.text(right, top, 'right top', - horizontalalignment='right', verticalalignment='top') -fig.text(right, bottom, 'center top', - horizontalalignment='center', verticalalignment='top') -fig.text(left, 0.5*(bottom+top), 'right center', - horizontalalignment='right', verticalalignment='center', - rotation='vertical') -fig.text(left, 0.5*(bottom+top), 'left center', - horizontalalignment='left', verticalalignment='center', - rotation='vertical') -fig.text(0.5*(left+right), 0.5*(bottom+top), 'middle', - horizontalalignment='center', verticalalignment='center', - fontsize=20, color='red') -fig.text(right, 0.5*(bottom+top), 'centered', - horizontalalignment='center', verticalalignment='center', - rotation='vertical') -fig.text(left, top, 'rotated\nwith newlines', - horizontalalignment='center', verticalalignment='center', - rotation=45) - -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.figure.Figure.add_artist` -# - `matplotlib.figure.Figure.text` diff --git a/examples/scales/asinh_demo.py b/examples/scales/asinh_demo.py new file mode 100644 index 000000000000..ae13fb1265de --- /dev/null +++ b/examples/scales/asinh_demo.py @@ -0,0 +1,109 @@ +""" +============ +Asinh Demo +============ + +Illustration of the `asinh <.scale.AsinhScale>` axis scaling, +which uses the transformation + +.. math:: + + a \\rightarrow a_0 \\sinh^{-1} (a / a_0) + +For coordinate values close to zero (i.e. much smaller than +the "linear width" :math:`a_0`), this leaves values essentially unchanged: + +.. math:: + + a \\rightarrow a + \\mathcal{O}(a^3) + +but for larger values (i.e. :math:`|a| \\gg a_0`, this is asymptotically + +.. math:: + + a \\rightarrow a_0 \\, \\mathrm{sgn}(a) \\ln |a| + \\mathcal{O}(1) + +As with the `symlog <.scale.SymmetricalLogScale>` scaling, +this allows one to plot quantities +that cover a very wide dynamic range that includes both positive +and negative values. However, ``symlog`` involves a transformation +that has discontinuities in its gradient because it is built +from *separate* linear and logarithmic transformations. +The ``asinh`` scaling uses a transformation that is smooth +for all (finite) values, which is both mathematically cleaner +and reduces visual artifacts associated with an abrupt +transition between linear and logarithmic regions of the plot. + +.. note:: + `.scale.AsinhScale` is experimental, and the API may change. + +See `~.scale.AsinhScale`, `~.scale.SymmetricalLogScale`. +""" + +import numpy as np +import matplotlib.pyplot as plt + +# Prepare sample values for variations on y=x graph: +x = np.linspace(-3, 6, 500) + +############################################################################### +# Compare "symlog" and "asinh" behaviour on sample y=x graph, +# where there is a discontinuous gradient in "symlog" near y=2: +fig1 = plt.figure() +ax0, ax1 = fig1.subplots(1, 2, sharex=True) + +ax0.plot(x, x) +ax0.set_yscale('symlog') +ax0.grid() +ax0.set_title('symlog') + +ax1.plot(x, x) +ax1.set_yscale('asinh') +ax1.grid() +ax1.set_title('asinh') + + +############################################################################### +# Compare "asinh" graphs with different scale parameter "linear_width": +fig2 = plt.figure(constrained_layout=True) +axs = fig2.subplots(1, 3, sharex=True) +for ax, (a0, base) in zip(axs, ((0.2, 2), (1.0, 0), (5.0, 10))): + ax.set_title(f'linear_width={a0:.3g}') + ax.plot(x, x, label='y=x') + ax.plot(x, 10*x, label='y=10x') + ax.plot(x, 100*x, label='y=100x') + ax.set_yscale('asinh', linear_width=a0, base=base) + ax.grid() + ax.legend(loc='best', fontsize='small') + + +############################################################################### +# Compare "symlog" and "asinh" scalings +# on 2D Cauchy-distributed random numbers, +# where one may be able to see more subtle artifacts near y=2 +# due to the gradient-discontinuity in "symlog": +fig3 = plt.figure() +ax = fig3.subplots(1, 1) +r = 3 * np.tan(np.random.uniform(-np.pi / 2.02, np.pi / 2.02, + size=(5000,))) +th = np.random.uniform(0, 2*np.pi, size=r.shape) + +ax.scatter(r * np.cos(th), r * np.sin(th), s=4, alpha=0.5) +ax.set_xscale('asinh') +ax.set_yscale('symlog') +ax.set_xlabel('asinh') +ax.set_ylabel('symlog') +ax.set_title('2D Cauchy random deviates') +ax.set_xlim(-50, 50) +ax.set_ylim(-50, 50) +ax.grid() + +plt.show() + +############################################################################### +# +# .. admonition:: References +# +# - `matplotlib.scale.AsinhScale` +# - `matplotlib.ticker.AsinhLocator` +# - `matplotlib.scale.SymmetricalLogScale` diff --git a/examples/scales/log_test.py b/examples/scales/semilogx_demo.py similarity index 86% rename from examples/scales/log_test.py rename to examples/scales/semilogx_demo.py index 72c04d61aaae..00c708a03479 100644 --- a/examples/scales/log_test.py +++ b/examples/scales/semilogx_demo.py @@ -3,6 +3,8 @@ Log Axis ======== +.. redirect-from:: /gallery/scales/log_test + This is an example of assigning a log-scale for the x-axis using `~.axes.Axes.semilogx`. """ diff --git a/examples/scales/symlog_demo.py b/examples/scales/symlog_demo.py index e1c433b22b88..6c5f04ade8d6 100644 --- a/examples/scales/symlog_demo.py +++ b/examples/scales/symlog_demo.py @@ -33,3 +33,17 @@ fig.tight_layout() plt.show() + +############################################################################### +# It should be noted that the coordinate transform used by ``symlog`` +# has a discontinuous gradient at the transition between its linear +# and logarithmic regions. The ``asinh`` axis scale is an alternative +# technique that may avoid visual artifacts caused by these discontinuities. + +############################################################################### +# +# .. admonition:: References +# +# - `matplotlib.scale.SymmetricalLogScale` +# - `matplotlib.ticker.SymmetricalLogLocator` +# - `matplotlib.scale.AsinhScale` diff --git a/examples/shapes_and_collections/arrow_guide.py b/examples/shapes_and_collections/arrow_guide.py index 24151544ba17..efe169d909f3 100644 --- a/examples/shapes_and_collections/arrow_guide.py +++ b/examples/shapes_and_collections/arrow_guide.py @@ -41,7 +41,7 @@ # Head shape fixed in display space and anchor points fixed in data space # ----------------------------------------------------------------------- # -# This is useful if you are annotating a plot, and don't want the arrow to +# This is useful if you are annotating a plot, and don't want the arrow # to change shape or position if you pan or scale the plot. # # In this case we use `.patches.FancyArrowPatch`. diff --git a/examples/shapes_and_collections/artist_reference.py b/examples/shapes_and_collections/artist_reference.py index 5bda4cd217ca..4ab6d67c0542 100644 --- a/examples/shapes_and_collections/artist_reference.py +++ b/examples/shapes_and_collections/artist_reference.py @@ -3,66 +3,25 @@ Reference for Matplotlib artists ================================ -This example displays several of Matplotlib's graphics primitives (artists) -drawn using matplotlib API. A full list of artists and the documentation is -available at :ref:`the artist API `. +This example displays several of Matplotlib's graphics primitives (artists). +A full list of artists is documented at :ref:`the artist API `. + +See also :doc:`/gallery/shapes_and_collections/patch_collection`, which groups +all artists into a single `.PatchCollection` instead. Copyright (c) 2010, Bartosz Telenczuk BSD License """ -import matplotlib.pyplot as plt -import numpy as np -import matplotlib.path as mpath + +import matplotlib as mpl import matplotlib.lines as mlines import matplotlib.patches as mpatches -from matplotlib.collections import PatchCollection - - -def label(xy, text): - y = xy[1] - 0.15 # shift y-value for label so that it's below the artist - plt.text(xy[0], y, text, ha="center", family='sans-serif', size=14) - - -fig, ax = plt.subplots() -# create 3x3 grid to plot the artists -grid = np.mgrid[0.2:0.8:3j, 0.2:0.8:3j].reshape(2, -1).T - -patches = [] - -# add a circle -circle = mpatches.Circle(grid[0], 0.1, ec="none") -patches.append(circle) -label(grid[0], "Circle") - -# add a rectangle -rect = mpatches.Rectangle(grid[1] - [0.025, 0.05], 0.05, 0.1, ec="none") -patches.append(rect) -label(grid[1], "Rectangle") - -# add a wedge -wedge = mpatches.Wedge(grid[2], 0.1, 30, 270, ec="none") -patches.append(wedge) -label(grid[2], "Wedge") - -# add a Polygon -polygon = mpatches.RegularPolygon(grid[3], 5, 0.1) -patches.append(polygon) -label(grid[3], "Polygon") - -# add an ellipse -ellipse = mpatches.Ellipse(grid[4], 0.2, 0.1) -patches.append(ellipse) -label(grid[4], "Ellipse") - -# add an arrow -arrow = mpatches.Arrow(grid[5, 0] - 0.05, grid[5, 1] - 0.05, 0.1, 0.1, - width=0.1) -patches.append(arrow) -label(grid[5], "Arrow") +import matplotlib.path as mpath +import matplotlib.pyplot as plt -# add a path patch +# Prepare the data for the PathPatch below. Path = mpath.Path -path_data = [ +codes, verts = zip(*[ (Path.MOVETO, [0.018, -0.11]), (Path.CURVE4, [-0.031, -0.051]), (Path.CURVE4, [-0.115, 0.073]), @@ -71,35 +30,28 @@ def label(xy, text): (Path.CURVE4, [0.043, 0.121]), (Path.CURVE4, [0.075, -0.005]), (Path.CURVE4, [0.035, -0.027]), - (Path.CLOSEPOLY, [0.018, -0.11])] -codes, verts = zip(*path_data) -path = mpath.Path(verts + grid[6], codes) -patch = mpatches.PathPatch(path) -patches.append(patch) -label(grid[6], "PathPatch") - -# add a fancy box -fancybox = mpatches.FancyBboxPatch( - grid[7] - [0.025, 0.05], 0.05, 0.1, - boxstyle=mpatches.BoxStyle("Round", pad=0.02)) -patches.append(fancybox) -label(grid[7], "FancyBboxPatch") - -# add a line -x, y = ([-0.06, 0.0, 0.1], [0.05, -0.05, 0.05]) -line = mlines.Line2D(x + grid[8, 0], y + grid[8, 1], lw=5., alpha=0.3) -label(grid[8], "Line2D") - -colors = np.linspace(0, 1, len(patches)) -collection = PatchCollection(patches, cmap=plt.cm.hsv, alpha=0.3) -collection.set_array(colors) -ax.add_collection(collection) -ax.add_line(line) - -plt.axis('equal') -plt.axis('off') -plt.tight_layout() - + (Path.CLOSEPOLY, [0.018, -0.11])]) + +artists = [ + mpatches.Circle((0, 0), 0.1, ec="none"), + mpatches.Rectangle((-0.025, -0.05), 0.05, 0.1, ec="none"), + mpatches.Wedge((0, 0), 0.1, 30, 270, ec="none"), + mpatches.RegularPolygon((0, 0), 5, radius=0.1), + mpatches.Ellipse((0, 0), 0.2, 0.1), + mpatches.Arrow(-0.05, -0.05, 0.1, 0.1, width=0.1), + mpatches.PathPatch(mpath.Path(verts, codes), ec="none"), + mpatches.FancyBboxPatch((-0.025, -0.05), 0.05, 0.1, ec="none", + boxstyle=mpatches.BoxStyle("Round", pad=0.02)), + mlines.Line2D([-0.06, 0.0, 0.1], [0.05, -0.05, 0.05], lw=5), +] + +axs = plt.figure(figsize=(6, 6), layout="constrained").subplots(3, 3) +for i, (ax, artist) in enumerate(zip(axs.flat, artists)): + artist.set(color=mpl.colormaps["hsv"](i / len(artists))) + ax.add_artist(artist) + ax.set(title=type(artist).__name__, + aspect=1, xlim=(-.2, .2), ylim=(-.2, .2)) + ax.set_axis_off() plt.show() ############################################################################# @@ -122,8 +74,4 @@ def label(xy, text): # - `matplotlib.patches.PathPatch` # - `matplotlib.patches.FancyBboxPatch` # - `matplotlib.patches.RegularPolygon` -# - `matplotlib.collections` -# - `matplotlib.collections.PatchCollection` -# - `matplotlib.cm.ScalarMappable.set_array` -# - `matplotlib.axes.Axes.add_collection` -# - `matplotlib.axes.Axes.add_line` +# - `matplotlib.axes.Axes.add_artist` diff --git a/examples/shapes_and_collections/collections.py b/examples/shapes_and_collections/collections.py index db65511e9f2d..b7ce0d6340ac 100644 --- a/examples/shapes_and_collections/collections.py +++ b/examples/shapes_and_collections/collections.py @@ -5,20 +5,20 @@ For the first two subplots, we will use spirals. Their size will be set in plot units, not data units. Their positions will be set in data units by using -the *offsets* and *transOffset* keyword arguments of the `.LineCollection` and -`.PolyCollection`. +the *offsets* and *offset_transform* keyword arguments of the `.LineCollection` +and `.PolyCollection`. The third subplot will make regular polygons, with the same type of scaling and positioning as in the first two. -The last subplot illustrates the use of "offsets=(xo, yo)", +The last subplot illustrates the use of ``offsets=(xo, yo)``, that is, a single tuple instead of a list of tuples, to generate successively offset curves, with the offset given in data units. This behavior is available only for the LineCollection. """ import matplotlib.pyplot as plt -from matplotlib import collections, colors, transforms +from matplotlib import collections, transforms import numpy as np nverts = 50 @@ -38,16 +38,15 @@ xyo = rs.randn(npts, 2) # Make a list of colors cycling through the default series. -colors = [colors.to_rgba(c) - for c in plt.rcParams['axes.prop_cycle'].by_key()['color']] +colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) fig.subplots_adjust(top=0.92, left=0.07, right=0.97, hspace=0.3, wspace=0.3) -col = collections.LineCollection([spiral], offsets=xyo, - transOffset=ax1.transData) +col = collections.LineCollection( + [spiral], offsets=xyo, offset_transform=ax1.transData) trans = fig.dpi_scale_trans + transforms.Affine2D().scale(1.0/72.0) col.set_transform(trans) # the points to pixels transform # Note: the first argument to the collection initializer @@ -71,8 +70,8 @@ # The same data as above, but fill the curves. -col = collections.PolyCollection([spiral], offsets=xyo, - transOffset=ax2.transData) +col = collections.PolyCollection( + [spiral], offsets=xyo, offset_transform=ax2.transData) trans = transforms.Affine2D().scale(fig.dpi/72.0) col.set_transform(trans) # the points to pixels transform ax2.add_collection(col, autolim=True) @@ -85,7 +84,7 @@ # 7-sided regular polygons col = collections.RegularPolyCollection( - 7, sizes=np.abs(xx) * 10.0, offsets=xyo, transOffset=ax3.transData) + 7, sizes=np.abs(xx) * 10.0, offsets=xyo, offset_transform=ax3.transData) trans = transforms.Affine2D().scale(fig.dpi / 72.0) col.set_transform(trans) # the points to pixels transform ax3.add_collection(col, autolim=True) diff --git a/examples/shapes_and_collections/ellipse_collection.py b/examples/shapes_and_collections/ellipse_collection.py index 695bd759bdb2..7a92eff580ee 100644 --- a/examples/shapes_and_collections/ellipse_collection.py +++ b/examples/shapes_and_collections/ellipse_collection.py @@ -7,6 +7,7 @@ a `~.collections.EllipseCollection` or `~.collections.PathCollection`, the use of an `~.collections.EllipseCollection` allows for much shorter code. """ + import matplotlib.pyplot as plt import numpy as np from matplotlib.collections import EllipseCollection @@ -25,7 +26,7 @@ fig, ax = plt.subplots() ec = EllipseCollection(ww, hh, aa, units='x', offsets=XY, - transOffset=ax.transData) + offset_transform=ax.transData) ec.set_array((X + Y).ravel()) ax.add_collection(ec) ax.autoscale_view() diff --git a/examples/shapes_and_collections/fancybox_demo.py b/examples/shapes_and_collections/fancybox_demo.py index de3263612d2a..ea1383d01941 100644 --- a/examples/shapes_and_collections/fancybox_demo.py +++ b/examples/shapes_and_collections/fancybox_demo.py @@ -44,7 +44,7 @@ def add_fancy_patch_around(ax, bb, **kwargs): - fancy = FancyBboxPatch((bb.xmin, bb.ymin), bb.width, bb.height, + fancy = FancyBboxPatch(bb.p0, bb.width, bb.height, fc=(1, 0.8, 1, 0.5), ec=(1, 0.5, 1, 0.5), **kwargs) ax.add_patch(fancy) diff --git a/examples/shapes_and_collections/line_collection.py b/examples/shapes_and_collections/line_collection.py index 9adfd8024e5b..c0c44a7d8b8b 100644 --- a/examples/shapes_and_collections/line_collection.py +++ b/examples/shapes_and_collections/line_collection.py @@ -1,23 +1,17 @@ """ -=============== -Line Collection -=============== +============================================= +Plotting multiple lines with a LineCollection +============================================= -Plotting lines with Matplotlib. - -`~matplotlib.collections.LineCollection` allows one to plot multiple -lines on a figure. Below we show off some of its properties. +Matplotlib can efficiently draw multiple lines at once using a +`~.LineCollection`, as showcased below. """ import matplotlib.pyplot as plt from matplotlib.collections import LineCollection -from matplotlib import colors as mcolors import numpy as np -# In order to efficiently plot many lines in a single set of axes, -# Matplotlib has the ability to add the lines all at once. Here is a -# simple example showing how it is done. x = np.arange(100) # Here are many sets of y to plot vs. x @@ -30,7 +24,7 @@ # Mask some values to test masked array support: segs = np.ma.masked_where((segs > 50) & (segs < 60), segs) -# We need to set the plot limits. +# We need to set the plot limits, they will not autoscale fig, ax = plt.subplots() ax.set_xlim(x.min(), x.max()) ax.set_ylim(ys.min(), ys.max()) @@ -41,8 +35,7 @@ # onoffseq is an even length tuple of on and off ink in points. If linestyle # is omitted, 'solid' is used. # See `matplotlib.collections.LineCollection` for more information. -colors = [mcolors.to_rgba(c) - for c in plt.rcParams['axes.prop_cycle'].by_key()['color']] +colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] line_segments = LineCollection(segs, linewidths=(0.5, 1, 1.5, 2), colors=colors, linestyle='solid') @@ -51,32 +44,22 @@ plt.show() ############################################################################### -# In order to efficiently plot many lines in a single set of axes, -# Matplotlib has the ability to add the lines all at once. Here is a -# simple example showing how it is done. +# In the following example, instead of passing a list of colors +# (``colors=colors``), we pass an array of values (``array=x``) that get +# colormapped. N = 50 x = np.arange(N) -# Here are many sets of y to plot vs. x -ys = [x + i for i in x] +ys = [x + i for i in x] # Many sets of y to plot vs. x +segs = [np.column_stack([x, y]) for y in ys] -# We need to set the plot limits, they will not autoscale fig, ax = plt.subplots() ax.set_xlim(np.min(x), np.max(x)) ax.set_ylim(np.min(ys), np.max(ys)) -# colors is sequence of rgba tuples -# linestyle is a string or dash tuple. Legal string values are -# solid|dashed|dashdot|dotted. The dash tuple is (offset, onoffseq) -# where onoffseq is an even length tuple of on and off ink in points. -# If linestyle is omitted, 'solid' is used -# See `matplotlib.collections.LineCollection` for more information - -# Make a sequence of (x, y) pairs. -line_segments = LineCollection([np.column_stack([x, y]) for y in ys], +line_segments = LineCollection(segs, array=x, linewidths=(0.5, 1, 1.5, 2), linestyles='solid') -line_segments.set_array(x) ax.add_collection(line_segments) axcb = fig.colorbar(line_segments) axcb.set_label('Line Number') diff --git a/examples/shapes_and_collections/patch_collection.py b/examples/shapes_and_collections/patch_collection.py index b980694627f9..98533c451cd1 100644 --- a/examples/shapes_and_collections/patch_collection.py +++ b/examples/shapes_and_collections/patch_collection.py @@ -4,6 +4,9 @@ ============================ This example demonstrates how to use `.collections.PatchCollection`. + +See also :doc:`/gallery/shapes_and_collections/artist_reference`, which instead +adds each artist separately to its own axes. """ import numpy as np @@ -45,7 +48,7 @@ ] for i in range(N): - polygon = Polygon(np.random.rand(N, 2), True) + polygon = Polygon(np.random.rand(N, 2), closed=True) patches.append(polygon) colors = 100 * np.random.rand(len(patches)) diff --git a/examples/showcase/bachelors_degrees_by_gender.py b/examples/showcase/bachelors_degrees_by_gender.py deleted file mode 100644 index 0bfdf1ea88a3..000000000000 --- a/examples/showcase/bachelors_degrees_by_gender.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -============================ -Bachelor's degrees by gender -============================ - -A graph of multiple time series which demonstrates extensive custom -styling of plot frame, tick lines and labels, and line graph properties. - -Also demonstrates the custom placement of text labels along the right edge -as an alternative to a conventional legend. - -Note: The third-party mpl style dufte_ produces similar-looking plots with -less code. - -.. _dufte: https://github.com/nschloe/dufte -""" - -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.cbook import get_sample_data - - -fname = get_sample_data('percent_bachelors_degrees_women_usa.csv', - asfileobj=False) -gender_degree_data = np.genfromtxt(fname, delimiter=',', names=True) - -# You typically want your plot to be ~1.33x wider than tall. This plot -# is a rare exception because of the number of lines being plotted on it. -# Common sizes: (10, 7.5) and (12, 9) -fig, ax = plt.subplots(1, 1, figsize=(12, 14)) - -# These are the colors that will be used in the plot -ax.set_prop_cycle(color=[ - '#1f77b4', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c', '#98df8a', - '#d62728', '#ff9896', '#9467bd', '#c5b0d5', '#8c564b', '#c49c94', - '#e377c2', '#f7b6d2', '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', - '#17becf', '#9edae5']) - -# Remove the plot frame lines. They are unnecessary here. -ax.spines[:].set_visible(False) - -# Ensure that the axis ticks only show up on the bottom and left of the plot. -# Ticks on the right and top of the plot are generally unnecessary. -ax.xaxis.tick_bottom() -ax.yaxis.tick_left() - -fig.subplots_adjust(left=.06, right=.75, bottom=.02, top=.94) -# Limit the range of the plot to only where the data is. -# Avoid unnecessary whitespace. -ax.set_xlim(1969.5, 2011.1) -ax.set_ylim(-0.25, 90) - -# Set a fixed location and format for ticks. -ax.set_xticks(range(1970, 2011, 10)) -ax.set_yticks(range(0, 91, 10)) -# Use automatic StrMethodFormatter creation -ax.xaxis.set_major_formatter('{x:.0f}') -ax.yaxis.set_major_formatter('{x:.0f}%') - -# Provide tick lines across the plot to help your viewers trace along -# the axis ticks. Make sure that the lines are light and small so they -# don't obscure the primary data lines. -ax.grid(True, 'major', 'y', ls='--', lw=.5, c='k', alpha=.3) - -# Remove the tick marks; they are unnecessary with the tick lines we just -# plotted. Make sure your axis ticks are large enough to be easily read. -# You don't want your viewers squinting to read your plot. -ax.tick_params(axis='both', which='both', labelsize=14, - bottom=False, top=False, labelbottom=True, - left=False, right=False, labelleft=True) - -# Now that the plot is prepared, it's time to actually plot the data! -# Note that I plotted the majors in order of the highest % in the final year. -majors = ['Health Professions', 'Public Administration', 'Education', - 'Psychology', 'Foreign Languages', 'English', - 'Communications\nand Journalism', 'Art and Performance', 'Biology', - 'Agriculture', 'Social Sciences and History', 'Business', - 'Math and Statistics', 'Architecture', 'Physical Sciences', - 'Computer Science', 'Engineering'] - -y_offsets = {'Foreign Languages': 0.5, 'English': -0.5, - 'Communications\nand Journalism': 0.75, - 'Art and Performance': -0.25, 'Agriculture': 1.25, - 'Social Sciences and History': 0.25, 'Business': -0.75, - 'Math and Statistics': 0.75, 'Architecture': -0.75, - 'Computer Science': 0.75, 'Engineering': -0.25} - -for column in majors: - # Plot each line separately with its own color. - column_rec_name = column.replace('\n', '_').replace(' ', '_') - - line, = ax.plot('Year', column_rec_name, data=gender_degree_data, - lw=2.5) - - # Add a text label to the right end of every line. Most of the code below - # is adding specific offsets y position because some labels overlapped. - y_pos = gender_degree_data[column_rec_name][-1] - 0.5 - - if column in y_offsets: - y_pos += y_offsets[column] - - # Again, make sure that all labels are large enough to be easily read - # by the viewer. - ax.text(2011.5, y_pos, column, fontsize=14, color=line.get_color()) - -# Make the title big enough so it spans the entire plot, but don't make it -# so big that it requires two lines to show. - -# Note that if the title is descriptive enough, it is unnecessary to include -# axis labels; they are self-evident, in this plot's case. -fig.suptitle("Percentage of Bachelor's degrees conferred to women in " - "the U.S.A. by major (1970-2011)", fontsize=18, ha="center") - -# Finally, save the figure as a PNG. -# You can also save it as a PDF, JPEG, etc. -# Just change the file extension in this call. -# fig.savefig('percent-bachelors-degrees-women-usa.png', bbox_inches='tight') -plt.show() - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.pyplot.subplots` -# - `matplotlib.axes.Axes.text` -# - `matplotlib.axis.Axis.set_major_formatter` -# - `matplotlib.axis.XAxis.tick_bottom` -# - `matplotlib.axis.YAxis.tick_left` -# - `matplotlib.artist.Artist.set_visible` -# - `matplotlib.ticker.StrMethodFormatter` diff --git a/examples/showcase/integral.py b/examples/showcase/integral.py index c75ef940b0db..731f7b9ecd50 100644 --- a/examples/showcase/integral.py +++ b/examples/showcase/integral.py @@ -42,10 +42,7 @@ def func(x): fig.text(0.9, 0.05, '$x$') fig.text(0.1, 0.9, '$y$') -ax.spines.right.set_visible(False) -ax.spines.top.set_visible(False) -ax.xaxis.set_ticks_position('bottom') - +ax.spines[['top', 'right']].set_visible(False) ax.set_xticks([a, b], labels=['$a$', '$b$']) ax.set_yticks([]) diff --git a/examples/showcase/mandelbrot.py b/examples/showcase/mandelbrot.py index 53db00ae3d0d..6806fc38a5cc 100644 --- a/examples/showcase/mandelbrot.py +++ b/examples/showcase/mandelbrot.py @@ -44,7 +44,7 @@ def mandelbrot_set(xmin, xmax, ymin, ymax, xn, yn, maxiter, horizon=2.0): # https://linas.org/art-gallery/escape/smooth.html # https://web.archive.org/web/20160331171238/https://www.ibm.com/developerworks/community/blogs/jfp/entry/My_Christmas_Gift?lang=en - # This line will generate warnings for null values but it is faster to + # This line will generate warnings for null values, but it is faster to # process them afterwards using the nan_to_num with np.errstate(invalid='ignore'): M = np.nan_to_num(N + 1 - np.log2(np.log(abs(Z))) + log_horizon) diff --git a/examples/showcase/stock_prices.py b/examples/showcase/stock_prices.py new file mode 100644 index 000000000000..7f7ffba08f03 --- /dev/null +++ b/examples/showcase/stock_prices.py @@ -0,0 +1,119 @@ +""" +========================== +Stock prices over 32 years +========================== + +.. redirect-from:: /gallery/showcase/bachelors_degrees_by_gender + +A graph of multiple time series that demonstrates custom styling of plot frame, +tick lines, tick labels, and line graph properties. It also uses custom +placement of text labels along the right edge as an alternative to a +conventional legend. + +Note: The third-party mpl style dufte_ produces similar-looking plots with less +code. + +.. _dufte: https://github.com/nschloe/dufte +""" + +import numpy as np +import matplotlib.transforms as mtransforms +import matplotlib.pyplot as plt +from matplotlib.cbook import get_sample_data + + +def convertdate(x): + return np.datetime64(x, 'D') + + +fname = get_sample_data('Stocks.csv', asfileobj=False) +stock_data = np.genfromtxt(fname, encoding='utf-8', delimiter=',', + names=True, dtype=None, converters={0: convertdate}, + skip_header=1) + + +fig, ax = plt.subplots(1, 1, figsize=(6, 8), layout='constrained') + +# These are the colors that will be used in the plot +ax.set_prop_cycle(color=[ + '#1f77b4', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c', '#98df8a', + '#d62728', '#ff9896', '#9467bd', '#c5b0d5', '#8c564b', '#c49c94', + '#e377c2', '#f7b6d2', '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', + '#17becf', '#9edae5']) + +stocks_name = ['IBM', 'Apple', 'Microsoft', 'Xerox', 'Amazon', 'Dell', + 'Alphabet', 'Adobe', 'S&P 500', 'NASDAQ'] +stocks_ticker = ['IBM', 'AAPL', 'MSFT', 'XRX', 'AMZN', 'DELL', 'GOOGL', + 'ADBE', 'GSPC', 'IXIC'] + +# Manually adjust the label positions vertically (units are points = 1/72 inch) +y_offsets = {k: 0 for k in stocks_ticker} +y_offsets['IBM'] = 5 +y_offsets['AAPL'] = -5 +y_offsets['AMZN'] = -6 + +for nn, column in enumerate(stocks_ticker): + # Plot each line separately with its own color. + # don't include any data with NaN. + good = np.nonzero(np.isfinite(stock_data[column])) + line, = ax.plot(stock_data['Date'][good], stock_data[column][good], lw=2.5) + + # Add a text label to the right end of every line. Most of the code below + # is adding specific offsets y position because some labels overlapped. + y_pos = stock_data[column][-1] + + # Use an offset transform, in points, for any text that needs to be nudged + # up or down. + offset = y_offsets[column] / 72 + trans = mtransforms.ScaledTranslation(0, offset, fig.dpi_scale_trans) + trans = ax.transData + trans + + # Again, make sure that all labels are large enough to be easily read + # by the viewer. + ax.text(np.datetime64('2022-10-01'), y_pos, stocks_name[nn], + color=line.get_color(), transform=trans) + +ax.set_xlim(np.datetime64('1989-06-01'), np.datetime64('2023-01-01')) + +fig.suptitle("Technology company stocks prices dollars (1990-2022)", + ha="center") + +# Remove the plot frame lines. They are unnecessary here. +ax.spines[:].set_visible(False) + +# Ensure that the axis ticks only show up on the bottom and left of the plot. +# Ticks on the right and top of the plot are generally unnecessary. +ax.xaxis.tick_bottom() +ax.yaxis.tick_left() +ax.set_yscale('log') + +# Provide tick lines across the plot to help your viewers trace along +# the axis ticks. Make sure that the lines are light and small so they +# don't obscure the primary data lines. +ax.grid(True, 'major', 'both', ls='--', lw=.5, c='k', alpha=.3) + +# Remove the tick marks; they are unnecessary with the tick lines we just +# plotted. Make sure your axis ticks are large enough to be easily read. +# You don't want your viewers squinting to read your plot. +ax.tick_params(axis='both', which='both', labelsize='large', + bottom=False, top=False, labelbottom=True, + left=False, right=False, labelleft=True) + +# Finally, save the figure as a PNG. +# You can also save it as a PDF, JPEG, etc. +# Just change the file extension in this call. +# fig.savefig('stock-prices.png', bbox_inches='tight') +plt.show() + +############################################################################# +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.pyplot.subplots` +# - `matplotlib.axes.Axes.text` +# - `matplotlib.axis.XAxis.tick_bottom` +# - `matplotlib.axis.YAxis.tick_left` +# - `matplotlib.artist.Artist.set_visible` diff --git a/examples/showcase/xkcd.py b/examples/showcase/xkcd.py index 4ab16c66a536..4088a0133814 100644 --- a/examples/showcase/xkcd.py +++ b/examples/showcase/xkcd.py @@ -16,8 +16,7 @@ fig = plt.figure() ax = fig.add_axes((0.1, 0.2, 0.8, 0.7)) - ax.spines.right.set_color('none') - ax.spines.top.set_color('none') + ax.spines[['top', 'right']].set_visible(False) ax.set_xticks([]) ax.set_yticks([]) ax.set_ylim([-30, 10]) @@ -47,8 +46,7 @@ fig = plt.figure() ax = fig.add_axes((0.1, 0.2, 0.8, 0.7)) ax.bar([0, 1], [0, 100], 0.25) - ax.spines.right.set_color('none') - ax.spines.top.set_color('none') + ax.spines[['top', 'right']].set_visible(False) ax.xaxis.set_ticks_position('bottom') ax.set_xticks([0, 1]) ax.set_xticklabels(['CONFIRMED BY\nEXPERIMENT', 'REFUTED BY\nEXPERIMENT']) diff --git a/examples/specialty_plots/README.txt b/examples/specialty_plots/README.txt index d186022d93db..fc1bf44d3b92 100644 --- a/examples/specialty_plots/README.txt +++ b/examples/specialty_plots/README.txt @@ -1,4 +1,4 @@ .. _specialty_plots_examples: -Specialty Plots +Specialty plots =============== diff --git a/examples/specialty_plots/leftventricle_bulleye.py b/examples/specialty_plots/leftventricle_bullseye.py similarity index 84% rename from examples/specialty_plots/leftventricle_bulleye.py rename to examples/specialty_plots/leftventricle_bullseye.py index 3f32dafd2b08..169c08447ad1 100644 --- a/examples/specialty_plots/leftventricle_bulleye.py +++ b/examples/specialty_plots/leftventricle_bullseye.py @@ -5,6 +5,8 @@ This example demonstrates how to create the 17 segment model for the left ventricle recommended by the American Heart Association (AHA). + +.. redirect-from:: /gallery/specialty_plots/leftventricle_bulleye """ import numpy as np @@ -56,6 +58,9 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): theta = np.linspace(0, 2 * np.pi, 768) r = np.linspace(0.2, 1, 4) + # Remove grid + ax.grid(False) + # Create the bound for the segment 17 for i in range(r.shape[0]): ax.plot(theta, np.repeat(r[i], theta.shape), '-k', lw=linewidth) @@ -77,7 +82,7 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): # First segment start at 60 degrees theta0 = theta[i * 128:i * 128 + 128] + np.deg2rad(60) theta0 = np.repeat(theta0[:, np.newaxis], 2, axis=1) - z = np.ones((128, 2)) * data[i] + z = np.ones((128 - 1, 2 - 1)) * data[i] ax.pcolormesh(theta0, r0, z, cmap=cmap, norm=norm, shading='auto') if i + 1 in seg_bold: ax.plot(theta0, r0, '-k', lw=linewidth + 2) @@ -91,7 +96,7 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): # First segment start at 60 degrees theta0 = theta[i * 128:i * 128 + 128] + np.deg2rad(60) theta0 = np.repeat(theta0[:, np.newaxis], 2, axis=1) - z = np.ones((128, 2)) * data[i + 6] + z = np.ones((128 - 1, 2 - 1)) * data[i + 6] ax.pcolormesh(theta0, r0, z, cmap=cmap, norm=norm, shading='auto') if i + 7 in seg_bold: ax.plot(theta0, r0, '-k', lw=linewidth + 2) @@ -105,7 +110,7 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): # First segment start at 45 degrees theta0 = theta[i * 192:i * 192 + 192] + np.deg2rad(45) theta0 = np.repeat(theta0[:, np.newaxis], 2, axis=1) - z = np.ones((192, 2)) * data[i + 12] + z = np.ones((192 - 1, 2 - 1)) * data[i + 12] ax.pcolormesh(theta0, r0, z, cmap=cmap, norm=norm, shading='auto') if i + 13 in seg_bold: ax.plot(theta0, r0, '-k', lw=linewidth + 2) @@ -117,7 +122,7 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): r0 = np.array([0, r[0]]) r0 = np.repeat(r0[:, np.newaxis], theta.size, axis=1).T theta0 = np.repeat(theta[:, np.newaxis], 2, axis=1) - z = np.ones((theta.size, 2)) * data[16] + z = np.ones((theta.size - 1, 2 - 1)) * data[16] ax.pcolormesh(theta0, r0, z, cmap=cmap, norm=norm, shading='auto') if 17 in seg_bold: ax.plot(theta0, r0, '-k', lw=linewidth + 2) @@ -132,15 +137,11 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): # Make a figure and axes with dimensions as desired. -fig, ax = plt.subplots(figsize=(12, 8), nrows=1, ncols=3, - subplot_kw=dict(projection='polar')) +fig = plt.figure(figsize=(10, 5), layout="constrained") +fig.get_layout_engine().set(wspace=.1, w_pad=.2) +axs = fig.subplots(1, 3, subplot_kw=dict(projection='polar')) fig.canvas.manager.set_window_title('Left Ventricle Bulls Eyes (AHA)') -# Create the axis for the colorbars -axl = fig.add_axes([0.14, 0.15, 0.2, 0.05]) -axl2 = fig.add_axes([0.41, 0.15, 0.2, 0.05]) -axl3 = fig.add_axes([0.69, 0.15, 0.2, 0.05]) - # Set the colormap and norm to correspond to the data for which # the colorbar will be used. @@ -149,14 +150,16 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): # Create an empty ScalarMappable to set the colorbar's colormap and norm. # The following gives a basic continuous colorbar with ticks and labels. fig.colorbar(mpl.cm.ScalarMappable(cmap=cmap, norm=norm), - cax=axl, orientation='horizontal', label='Some Units') + cax=axs[0].inset_axes([0, -.15, 1, .1]), + orientation='horizontal', label='Some Units') # And again for the second colorbar. cmap2 = mpl.cm.cool norm2 = mpl.colors.Normalize(vmin=1, vmax=17) fig.colorbar(mpl.cm.ScalarMappable(cmap=cmap2, norm=norm2), - cax=axl2, orientation='horizontal', label='Some other units') + cax=axs[1].inset_axes([0, -.15, 1, .1]), + orientation='horizontal', label='Some other units') # The second example illustrates the use of a ListedColormap, a @@ -170,9 +173,7 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): bounds = [2, 3, 7, 9, 15] norm3 = mpl.colors.BoundaryNorm(bounds, cmap3.N) fig.colorbar(mpl.cm.ScalarMappable(cmap=cmap3, norm=norm3), - cax=axl3, - # to use 'extend', you must specify two extra boundaries: - boundaries=[0] + bounds + [18], + cax=axs[2].inset_axes([0, -.15, 1, .1]), extend='both', ticks=bounds, # optional spacing='proportional', @@ -181,14 +182,14 @@ def bullseye_plot(ax, data, seg_bold=None, cmap=None, norm=None): # Create the 17 segment model -bullseye_plot(ax[0], data, cmap=cmap, norm=norm) -ax[0].set_title('Bulls Eye (AHA)') +bullseye_plot(axs[0], data, cmap=cmap, norm=norm) +axs[0].set_title('Bulls Eye (AHA)') -bullseye_plot(ax[1], data, cmap=cmap2, norm=norm2) -ax[1].set_title('Bulls Eye (AHA)') +bullseye_plot(axs[1], data, cmap=cmap2, norm=norm2) +axs[1].set_title('Bulls Eye (AHA)') -bullseye_plot(ax[2], data, seg_bold=[3, 5, 6, 11, 12, 16], +bullseye_plot(axs[2], data, seg_bold=[3, 5, 6, 11, 12, 16], cmap=cmap3, norm=norm3) -ax[2].set_title('Segments [3, 5, 6, 11, 12, 16] in bold') +axs[2].set_title('Segments [3, 5, 6, 11, 12, 16] in bold') plt.show() diff --git a/examples/specialty_plots/mri_with_eeg.py b/examples/specialty_plots/mri_with_eeg.py index 91bcd0e232f6..911a26bcbff7 100644 --- a/examples/specialty_plots/mri_with_eeg.py +++ b/examples/specialty_plots/mri_with_eeg.py @@ -1,6 +1,6 @@ """ ============ -MRI With EEG +MRI with EEG ============ Displays a set of subplots with an MRI image, its intensity @@ -17,7 +17,7 @@ fig = plt.figure("MRI_with_EEG") -# Load the MRI data (256x256 16 bit integers) +# Load the MRI data (256x256 16-bit integers) with cbook.get_sample_data('s1045.ima.gz') as dfile: im = np.frombuffer(dfile.read(), np.uint16).reshape((256, 256)) @@ -64,10 +64,10 @@ offsets = np.zeros((n_rows, 2), dtype=float) offsets[:, 1] = ticklocs -lines = LineCollection(segs, offsets=offsets, transOffset=None) +lines = LineCollection(segs, offsets=offsets, offset_transform=None) ax2.add_collection(lines) -# Set the yticks to use axes coordinates on the y axis +# Set the yticks to use axes coordinates on the y-axis ax2.set_yticks(ticklocs, labels=['PG3', 'PG5', 'PG7', 'PG9']) ax2.set_xlabel('Time (s)') diff --git a/examples/specialty_plots/radar_chart.py b/examples/specialty_plots/radar_chart.py index 339ca269e45d..ae76308f52e6 100644 --- a/examples/specialty_plots/radar_chart.py +++ b/examples/specialty_plots/radar_chart.py @@ -8,7 +8,7 @@ Although this example allows a frame of either 'circle' or 'polygon', polygon frames don't have proper gridlines (the lines are circles instead of polygons). It's possible to get a polygon grid by setting GRIDLINE_INTERPOLATION_STEPS in -matplotlib.axis to the desired number of vertices, but the orientation of the +`matplotlib.axis` to the desired number of vertices, but the orientation of the polygon is not aligned with the radial axes. .. [1] https://en.wikipedia.org/wiki/Radar_chart @@ -55,8 +55,6 @@ def transform_path_non_affine(self, path): class RadarAxes(PolarAxes): name = 'radar' - # use 1 line segment to connect specified points - RESOLUTION = 1 PolarTransform = RadarTransform def __init__(self, *args, **kwargs): @@ -133,7 +131,7 @@ def example_data(): # Organic Carbon fraction 1 (OC) # Organic Carbon fraction 2 (OC2) # Organic Carbon fraction 3 (OC3) - # Pyrolized Organic Carbon (OP) + # Pyrolyzed Organic Carbon (OP) # 2)Inclusion of gas-phase specie carbon monoxide (CO) # 3)Inclusion of gas-phase specie ozone (O3). # 4)Inclusion of both gas-phase species is present... diff --git a/examples/specialty_plots/topographic_hillshading.py b/examples/specialty_plots/topographic_hillshading.py index f76aecd094d3..b9a980448b7f 100644 --- a/examples/specialty_plots/topographic_hillshading.py +++ b/examples/specialty_plots/topographic_hillshading.py @@ -58,7 +58,7 @@ # Label rows and columns for ax, ve in zip(axs[0], [0.1, 1, 10]): - ax.set_title('{0}'.format(ve), size=18) + ax.set_title(f'{ve}', size=18) for ax, mode in zip(axs[:, 0], ['Hillshade', 'hsv', 'overlay', 'soft']): ax.set_ylabel(mode, size=18) diff --git a/examples/spines/multiple_yaxis_with_spines.py b/examples/spines/multiple_yaxis_with_spines.py index 54eebdae11ba..c1dc95a62612 100644 --- a/examples/spines/multiple_yaxis_with_spines.py +++ b/examples/spines/multiple_yaxis_with_spines.py @@ -1,21 +1,20 @@ r""" -========================== -Multiple Yaxis With Spines -========================== +=========================== +Multiple y-axis with Spines +=========================== -Create multiple y axes with a shared x axis. This is done by creating +Create multiple y axes with a shared x-axis. This is done by creating a `~.axes.Axes.twinx` axes, turning all spines but the right one invisible and offset its position using `~.spines.Spine.set_position`. Note that this approach uses `matplotlib.axes.Axes` and their -`~matplotlib.spines.Spine`\s. An alternative approach for parasite -axes is shown in the :doc:`/gallery/axisartist/demo_parasite_axes` and +`~matplotlib.spines.Spine`\s. Alternative approaches using non-standard axes +are shown in the :doc:`/gallery/axisartist/demo_parasite_axes` and :doc:`/gallery/axisartist/demo_parasite_axes2` examples. """ import matplotlib.pyplot as plt - fig, ax = plt.subplots() fig.subplots_adjust(right=0.75) @@ -26,29 +25,21 @@ # placed on the right by twinx above. twin2.spines.right.set_position(("axes", 1.2)) -p1, = ax.plot([0, 1, 2], [0, 1, 2], "b-", label="Density") -p2, = twin1.plot([0, 1, 2], [0, 3, 2], "r-", label="Temperature") -p3, = twin2.plot([0, 1, 2], [50, 30, 15], "g-", label="Velocity") - -ax.set_xlim(0, 2) -ax.set_ylim(0, 2) -twin1.set_ylim(0, 4) -twin2.set_ylim(1, 65) +p1, = ax.plot([0, 1, 2], [0, 1, 2], "C0", label="Density") +p2, = twin1.plot([0, 1, 2], [0, 3, 2], "C1", label="Temperature") +p3, = twin2.plot([0, 1, 2], [50, 30, 15], "C2", label="Velocity") -ax.set_xlabel("Distance") -ax.set_ylabel("Density") -twin1.set_ylabel("Temperature") -twin2.set_ylabel("Velocity") +ax.set(xlim=(0, 2), ylim=(0, 2), xlabel="Distance", ylabel="Density") +twin1.set(ylim=(0, 4), ylabel="Temperature") +twin2.set(ylim=(1, 65), ylabel="Velocity") ax.yaxis.label.set_color(p1.get_color()) twin1.yaxis.label.set_color(p2.get_color()) twin2.yaxis.label.set_color(p3.get_color()) -tkw = dict(size=4, width=1.5) -ax.tick_params(axis='y', colors=p1.get_color(), **tkw) -twin1.tick_params(axis='y', colors=p2.get_color(), **tkw) -twin2.tick_params(axis='y', colors=p3.get_color(), **tkw) -ax.tick_params(axis='x', **tkw) +ax.tick_params(axis='y', colors=p1.get_color()) +twin1.tick_params(axis='y', colors=p2.get_color()) +twin2.tick_params(axis='y', colors=p3.get_color()) ax.legend(handles=[p1, p2, p3]) diff --git a/examples/spines/spine_placement_demo.py b/examples/spines/spine_placement_demo.py index d433236657f9..7d14d854f41a 100644 --- a/examples/spines/spine_placement_demo.py +++ b/examples/spines/spine_placement_demo.py @@ -1,9 +1,9 @@ """ =============== -Spine Placement +Spine placement =============== -Adjusting the location and appearance of axis spines. +The position of the axis spines can be influenced using `~.Spine.set_position`. Note: If you want to obtain arrow heads at the ends of the axes, also check out the :doc:`/gallery/spines/centered_spines_with_arrows` example. @@ -14,49 +14,41 @@ ############################################################################### -fig = plt.figure() -x = np.linspace(-np.pi, np.pi, 100) +x = np.linspace(0, 2*np.pi, 100) y = 2 * np.sin(x) -ax = fig.add_subplot(2, 2, 1) -ax.set_title('centered spines') +fig, ax_dict = plt.subplot_mosaic( + [['center', 'zero'], + ['axes', 'data']] +) +fig.suptitle('Spine positions') + + +ax = ax_dict['center'] +ax.set_title("'center'") ax.plot(x, y) -ax.spines.left.set_position('center') -ax.spines.right.set_color('none') -ax.spines.bottom.set_position('center') -ax.spines.top.set_color('none') -ax.xaxis.set_ticks_position('bottom') -ax.yaxis.set_ticks_position('left') +ax.spines[['left', 'bottom']].set_position('center') +ax.spines[['top', 'right']].set_visible(False) -ax = fig.add_subplot(2, 2, 2) -ax.set_title('zeroed spines') +ax = ax_dict['zero'] +ax.set_title("'zero'") ax.plot(x, y) -ax.spines.left.set_position('zero') -ax.spines.right.set_color('none') -ax.spines.bottom.set_position('zero') -ax.spines.top.set_color('none') -ax.xaxis.set_ticks_position('bottom') -ax.yaxis.set_ticks_position('left') +ax.spines[['left', 'bottom']].set_position('zero') +ax.spines[['top', 'right']].set_visible(False) -ax = fig.add_subplot(2, 2, 3) -ax.set_title('spines at axes (0.6, 0.1)') +ax = ax_dict['axes'] +ax.set_title("'axes' (0.2, 0.2)") ax.plot(x, y) -ax.spines.left.set_position(('axes', 0.6)) -ax.spines.right.set_color('none') -ax.spines.bottom.set_position(('axes', 0.1)) -ax.spines.top.set_color('none') -ax.xaxis.set_ticks_position('bottom') -ax.yaxis.set_ticks_position('left') +ax.spines.left.set_position(('axes', 0.2)) +ax.spines.bottom.set_position(('axes', 0.2)) +ax.spines[['top', 'right']].set_visible(False) -ax = fig.add_subplot(2, 2, 4) -ax.set_title('spines at data (1, 2)') +ax = ax_dict['data'] +ax.set_title("'data' (1, 2)") ax.plot(x, y) ax.spines.left.set_position(('data', 1)) -ax.spines.right.set_color('none') ax.spines.bottom.set_position(('data', 2)) -ax.spines.top.set_color('none') -ax.xaxis.set_ticks_position('bottom') -ax.yaxis.set_ticks_position('left') +ax.spines[['top', 'right']].set_visible(False) ############################################################################### # Define a method that adjusts the location of the axis spines diff --git a/examples/spines/spines.py b/examples/spines/spines.py index 08e3700d1387..0917fc8c107c 100644 --- a/examples/spines/spines.py +++ b/examples/spines/spines.py @@ -5,9 +5,15 @@ This demo compares: -- normal axes, with spines on all four sides; -- an axes with spines only on the left and bottom; -- an axes using custom bounds to limit the extent of the spine. +- normal Axes, with spines on all four sides; +- an Axes with spines only on the left and bottom; +- an Axes using custom bounds to limit the extent of the spine. + +Each `.axes.Axes` has a list of `.Spine` objects, accessible +via the container ``ax.spines``. + +.. redirect-from:: /gallery/spines/spines_bounds + """ import numpy as np import matplotlib.pyplot as plt @@ -28,19 +34,23 @@ # Hide the right and top spines ax1.spines.right.set_visible(False) ax1.spines.top.set_visible(False) -# Only show ticks on the left and bottom spines -ax1.yaxis.set_ticks_position('left') -ax1.xaxis.set_ticks_position('bottom') ax2.plot(x, y) +ax2.set_title('spines with bounds limited to data range') -# Only draw spine between the y-ticks -ax2.spines.left.set_bounds(-1, 1) +# Only draw spines for the data range, not in the margins +ax2.spines.bottom.set_bounds(x.min(), x.max()) +ax2.spines.left.set_bounds(y.min(), y.max()) # Hide the right and top spines ax2.spines.right.set_visible(False) ax2.spines.top.set_visible(False) -# Only show ticks on the left and bottom spines -ax2.yaxis.set_ticks_position('left') -ax2.xaxis.set_ticks_position('bottom') plt.show() + +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.spines.Spine.set_visible` +# - `matplotlib.spines.Spine.set_bounds` diff --git a/examples/spines/spines_bounds.py b/examples/spines/spines_bounds.py deleted file mode 100644 index 5ccf3051e1ea..000000000000 --- a/examples/spines/spines_bounds.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -=================== -Custom spine bounds -=================== - -Demo of spines using custom bounds to limit the extent of the spine. -""" -import numpy as np -import matplotlib.pyplot as plt - -# Fixing random state for reproducibility -np.random.seed(19680801) - -x = np.linspace(0, 2*np.pi, 50) -y = np.sin(x) -y2 = y + 0.1 * np.random.normal(size=x.shape) - -fig, ax = plt.subplots() -ax.plot(x, y) -ax.plot(x, y2) - -# set ticks and tick labels -ax.set_xlim((0, 2*np.pi)) -ax.set_xticks([0, np.pi, 2*np.pi], labels=['0', r'$\pi$', r'2$\pi$']) -ax.set_ylim((-1.5, 1.5)) -ax.set_yticks([-1, 0, 1]) - -# Only draw spine between the y-ticks -ax.spines.left.set_bounds((-1, 1)) -# Hide the right and top spines -ax.spines.right.set_visible(False) -ax.spines.top.set_visible(False) -# Only show ticks on the left and bottom spines -ax.yaxis.set_ticks_position('left') -ax.xaxis.set_ticks_position('bottom') - -plt.show() diff --git a/examples/spines/spines_dropped.py b/examples/spines/spines_dropped.py index 954e1c7ffb3a..94487e86d1d3 100644 --- a/examples/spines/spines_dropped.py +++ b/examples/spines/spines_dropped.py @@ -18,13 +18,8 @@ ax.set_title('dropped spines') # Move left and bottom spines outward by 10 points -ax.spines.left.set_position(('outward', 10)) -ax.spines.bottom.set_position(('outward', 10)) +ax.spines[['left', 'bottom']].set_position(('outward', 10)) # Hide the right and top spines -ax.spines.right.set_visible(False) -ax.spines.top.set_visible(False) -# Only show ticks on the left and bottom spines -ax.yaxis.set_ticks_position('left') -ax.xaxis.set_ticks_position('bottom') +ax.spines[['top', 'right']].set_visible(False) plt.show() diff --git a/examples/statistics/boxplot_demo.py b/examples/statistics/boxplot_demo.py index b3bb44a1a96c..252ca25bd58d 100644 --- a/examples/statistics/boxplot_demo.py +++ b/examples/statistics/boxplot_demo.py @@ -8,6 +8,8 @@ The following examples show off how to visualize boxplots with Matplotlib. There are many options to control their appearance and the statistics that they use to summarize the data. + +.. redirect-from:: /gallery/pyplots/boxplot_demo_pyplot """ import matplotlib.pyplot as plt @@ -105,7 +107,7 @@ fig.canvas.manager.set_window_title('A Boxplot Example') fig.subplots_adjust(left=0.075, right=0.95, top=0.9, bottom=0.25) -bp = ax1.boxplot(data, notch=0, sym='+', vert=1, whis=1.5) +bp = ax1.boxplot(data, notch=False, sym='+', vert=True, whis=1.5) plt.setp(bp['boxes'], color='black') plt.setp(bp['whiskers'], color='black') plt.setp(bp['fliers'], color='red', marker='+') @@ -221,7 +223,7 @@ def fake_bootstrapper(n): fig, ax = plt.subplots() pos = np.arange(len(treatments)) + 1 bp = ax.boxplot(treatments, sym='k+', positions=pos, - notch=1, bootstrap=5000, + notch=True, bootstrap=5000, usermedians=medians, conf_intervals=conf_intervals) @@ -231,6 +233,18 @@ def fake_bootstrapper(n): plt.setp(bp['fliers'], markersize=3.0) plt.show() + +############################################################################### +# Here we customize the widths of the caps . + +x = np.linspace(-7, 7, 140) +x = np.hstack([-25, x, 25]) +fig, ax = plt.subplots() + +ax.boxplot([x, x], notch=True, capwidths=[0.01, 0.2]) + +plt.show() + ############################################################################# # # .. admonition:: References diff --git a/examples/statistics/confidence_ellipse.py b/examples/statistics/confidence_ellipse.py index b5d3b4793c79..c67da152ad7d 100644 --- a/examples/statistics/confidence_ellipse.py +++ b/examples/statistics/confidence_ellipse.py @@ -67,19 +67,19 @@ def confidence_ellipse(x, y, ax, n_std=3.0, facecolor='none', **kwargs): cov = np.cov(x, y) pearson = cov[0, 1]/np.sqrt(cov[0, 0] * cov[1, 1]) # Using a special case to obtain the eigenvalues of this - # two-dimensionl dataset. + # two-dimensional dataset. ell_radius_x = np.sqrt(1 + pearson) ell_radius_y = np.sqrt(1 - pearson) ellipse = Ellipse((0, 0), width=ell_radius_x * 2, height=ell_radius_y * 2, facecolor=facecolor, **kwargs) - # Calculating the stdandard deviation of x from + # Calculating the standard deviation of x from # the squareroot of the variance and multiplying # with the given number of standard deviations. scale_x = np.sqrt(cov[0, 0]) * n_std mean_x = np.mean(x) - # calculating the stdandard deviation of y ... + # calculating the standard deviation of y ... scale_y = np.sqrt(cov[1, 1]) * n_std mean_y = np.mean(y) @@ -97,7 +97,7 @@ def confidence_ellipse(x, y, ax, n_std=3.0, facecolor='none', **kwargs): # A helper function to create a correlated dataset # """""""""""""""""""""""""""""""""""""""""""""""" # -# Creates a random two-dimesional dataset with the specified +# Creates a random two-dimensional dataset with the specified # two-dimensional mean (mu) and dimensions (scale). # The correlation can be controlled by the param 'dependency', # a 2x2 matrix. diff --git a/examples/statistics/customized_violin.py b/examples/statistics/customized_violin.py index 4809e5f28d81..59978815e57e 100644 --- a/examples/statistics/customized_violin.py +++ b/examples/statistics/customized_violin.py @@ -27,8 +27,6 @@ def adjacent_values(vals, q1, q3): def set_axis_style(ax, labels): - ax.xaxis.set_tick_params(direction='out') - ax.xaxis.set_ticks_position('bottom') ax.set_xticks(np.arange(1, len(labels) + 1), labels=labels) ax.set_xlim(0.25, len(labels) + 0.75) ax.set_xlabel('Sample name') diff --git a/examples/statistics/histogram_cumulative.py b/examples/statistics/histogram_cumulative.py index a057cc7b1267..443d30e69ee0 100644 --- a/examples/statistics/histogram_cumulative.py +++ b/examples/statistics/histogram_cumulative.py @@ -24,7 +24,7 @@ 225 on the x-axis corresponds to about 0.85 on the y-axis, so there's an 85% chance that an observation in the sample does not exceed 225. Conversely, setting, ``cumulative`` to -1 as is done in the -last series for this example, creates a "exceedance" curve. +last series for this example, creates an "exceedance" curve. Selecting different bin counts and sizes can significantly affect the shape of a histogram. The Astropy docs have a great section on how to diff --git a/examples/statistics/multiple_histograms_side_by_side.py b/examples/statistics/multiple_histograms_side_by_side.py index 544b3ec4304b..814229636682 100644 --- a/examples/statistics/multiple_histograms_side_by_side.py +++ b/examples/statistics/multiple_histograms_side_by_side.py @@ -9,7 +9,7 @@ to violin plots. To make this highly specialized plot, we can't use the standard ``hist`` -method. Instead we use ``barh`` to draw the horizontal bars directly. The +method. Instead, we use ``barh`` to draw the horizontal bars directly. The vertical positions and lengths of the bars are computed via the ``np.histogram`` function. The histograms for all the samples are computed using the same range (min and max values) and number of bins, @@ -45,8 +45,8 @@ # The bin_edges are the same for all of the histograms bin_edges = np.linspace(hist_range[0], hist_range[1], number_of_bins + 1) -centers = 0.5 * (bin_edges + np.roll(bin_edges, 1))[:-1] heights = np.diff(bin_edges) +centers = bin_edges[:-1] + heights / 2 # Cycle through and plot each histogram fig, ax = plt.subplots() diff --git a/examples/statistics/time_series_histogram.py b/examples/statistics/time_series_histogram.py index 94f904fab91e..a22ceb4e24d4 100644 --- a/examples/statistics/time_series_histogram.py +++ b/examples/statistics/time_series_histogram.py @@ -41,7 +41,7 @@ # Generate unbiased Gaussian random walks Y = np.cumsum(np.random.randn(num_series, num_points), axis=-1) # Generate sinusoidal signals -num_signal = int(round(SNR * num_series)) +num_signal = round(SNR * num_series) phi = (np.pi / 8) * np.random.randn(num_signal, 1) # small random offset Y[-num_signal:] = ( np.sqrt(np.arange(num_points))[None, :] # random walk RMS scaling factor diff --git a/examples/statistics/violinplot.py b/examples/statistics/violinplot.py index 7c9c894f7aec..7d519554a795 100644 --- a/examples/statistics/violinplot.py +++ b/examples/statistics/violinplot.py @@ -10,7 +10,7 @@ compute an empirical distribution of the sample. That computation is controlled by several parameters. This example demonstrates how to modify the number of points at which the KDE is evaluated (``points``) -and how to modify the band-width of the KDE (``bw_method``). +and how to modify the bandwidth of the KDE (``bw_method``). For more information on violin plots and KDE, the scikit-learn docs have a great section: https://scikit-learn.org/stable/modules/density.html diff --git a/examples/style_sheets/style_sheets_reference.py b/examples/style_sheets/style_sheets_reference.py index 657c73aadb10..90e5812a30b8 100644 --- a/examples/style_sheets/style_sheets_reference.py +++ b/examples/style_sheets/style_sheets_reference.py @@ -12,6 +12,7 @@ import numpy as np import matplotlib.pyplot as plt import matplotlib.colors as mcolors +from matplotlib.patches import Rectangle # Fixing random state for reproducibility np.random.seed(19680801) @@ -62,11 +63,15 @@ def plot_colored_circles(ax, prng, nb_samples=15): the color cycle, because different styles may have different numbers of colors. """ - for sty_dict, j in zip(plt.rcParams['axes.prop_cycle'], range(nb_samples)): + for sty_dict, j in zip(plt.rcParams['axes.prop_cycle'](), + range(nb_samples)): ax.add_patch(plt.Circle(prng.normal(scale=3, size=2), radius=1.0, color=sty_dict['color'])) - # Force the limits to be the same across the styles (because different - # styles may have different numbers of available colors). + ax.grid(visible=True) + + # Add title for enabling grid + plt.title('ax.grid(True)', family='monospace', fontsize='small') + ax.set_xlim([-4, 8]) ax.set_ylim([-5, 6]) ax.set_aspect('equal', adjustable='box') # to plot circles as circles @@ -91,6 +96,7 @@ def plot_histograms(ax, prng, nb_samples=10000): values = prng.beta(a, b, size=nb_samples) ax.hist(values, histtype="stepfilled", bins=30, alpha=0.8, density=True) + # Add a small annotation. ax.annotate('Annotation', xy=(0.25, 4.25), xytext=(0.9, 0.9), textcoords=ax.transAxes, @@ -110,7 +116,7 @@ def plot_figure(style_label=""): prng = np.random.RandomState(96917002) fig, axs = plt.subplots(ncols=6, nrows=1, num=style_label, - figsize=(14.8, 2.7), constrained_layout=True) + figsize=(14.8, 2.8), constrained_layout=True) # make a suptitle, in the same style for all subfigures, # except those with dark backgrounds, which get a lighter color: @@ -126,14 +132,19 @@ def plot_figure(style_label=""): plot_scatter(axs[0], prng) plot_image_and_patch(axs[1], prng) plot_bar_graphs(axs[2], prng) - plot_colored_circles(axs[3], prng) - plot_colored_lines(axs[4]) - plot_histograms(axs[5], prng) + plot_colored_lines(axs[3]) + plot_histograms(axs[4], prng) + plot_colored_circles(axs[5], prng) + + # add divider + rec = Rectangle((1 + 0.025, -2), 0.05, 16, + clip_on=False, color='gray') + axs[4].add_artist(rec) if __name__ == "__main__": - # Setup a list of all available styles, in alphabetical order but + # Set up a list of all available styles, in alphabetical order but # the `default` and `classic` ones, which will be forced resp. in # first and second position. # styles with leading underscores are for internal use such as testing diff --git a/examples/subplots_axes_and_figures/align_labels_demo.py b/examples/subplots_axes_and_figures/align_labels_demo.py index 4be8081ee1f0..0e600b790c47 100644 --- a/examples/subplots_axes_and_figures/align_labels_demo.py +++ b/examples/subplots_axes_and_figures/align_labels_demo.py @@ -30,8 +30,7 @@ ax.set_ylabel('YLabel1 %d' % i) ax.set_xlabel('XLabel1 %d' % i) if i == 0: - for tick in ax.get_xticklabels(): - tick.set_rotation(55) + ax.tick_params(axis='x', rotation=55) fig.align_labels() # same as fig.align_xlabels(); fig.align_ylabels() plt.show() diff --git a/examples/pyplots/auto_subplots_adjust.py b/examples/subplots_axes_and_figures/auto_subplots_adjust.py similarity index 98% rename from examples/pyplots/auto_subplots_adjust.py rename to examples/subplots_axes_and_figures/auto_subplots_adjust.py index b4c731cb4cdc..bd6326b8291f 100644 --- a/examples/pyplots/auto_subplots_adjust.py +++ b/examples/subplots_axes_and_figures/auto_subplots_adjust.py @@ -36,6 +36,8 @@ This function is executed after the figure has been drawn. It can now check if the subplot leaves enough room for the text. If not, the subplot parameters are updated and second draw is triggered. + +.. redirect-from:: /gallery/pyplots/auto_subplots_adjust """ import matplotlib.pyplot as plt diff --git a/examples/subplots_axes_and_figures/axes_box_aspect.py b/examples/subplots_axes_and_figures/axes_box_aspect.py index 4444f0f28366..019c7e43c787 100644 --- a/examples/subplots_axes_and_figures/axes_box_aspect.py +++ b/examples/subplots_axes_and_figures/axes_box_aspect.py @@ -3,7 +3,7 @@ Axes box aspect =============== -This demo shows how to set the aspect of an axes box directly via +This demo shows how to set the aspect of an Axes box directly via `~.Axes.set_box_aspect`. The box aspect is the ratio between axes height and axes width in physical units, independent of the data limits. This is useful to e.g. produce a square plot, independent of the data it @@ -119,7 +119,7 @@ # ~~~~~~~~~~~~~~~~~~~~~~~~~~ # # When setting the box aspect, one may still set the data aspect as well. -# Here we create an axes with a box twice as long as tall and use an "equal" +# Here we create an Axes with a box twice as long as tall and use an "equal" # data aspect for its contents, i.e. the circle actually stays circular. fig6, ax = plt.subplots() @@ -135,8 +135,8 @@ # Box aspect for many subplots # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # -# It is possible to pass the box aspect to an axes at initialization. The -# following creates a 2 by 3 subplot grid with all square axes. +# It is possible to pass the box aspect to an Axes at initialization. The +# following creates a 2 by 3 subplot grid with all square Axes. fig7, axs = plt.subplots(2, 3, subplot_kw=dict(box_aspect=1), sharex=True, sharey=True, constrained_layout=True) diff --git a/examples/subplots_axes_and_figures/axes_margins.py b/examples/subplots_axes_and_figures/axes_margins.py index d532f6138fe8..e84a6e835924 100644 --- a/examples/subplots_axes_and_figures/axes_margins.py +++ b/examples/subplots_axes_and_figures/axes_margins.py @@ -69,7 +69,7 @@ def f(t): ) # not sticky ax.margins(x=0.1, y=0.05) ax.set_aspect('equal') - ax.set_title('{} Sticky'.format(status)) + ax.set_title(f'{status} Sticky') plt.show() diff --git a/examples/subplots_axes_and_figures/axes_zoom_effect.py b/examples/subplots_axes_and_figures/axes_zoom_effect.py index f8331d552aed..4b7ff6e91e30 100644 --- a/examples/subplots_axes_and_figures/axes_zoom_effect.py +++ b/examples/subplots_axes_and_figures/axes_zoom_effect.py @@ -32,7 +32,6 @@ def connect_bbox(bbox1, bbox2, bbox_patch2 = BboxPatch(bbox2, **prop_patches) p = BboxConnectorPatch(bbox1, bbox2, - # loc1a=3, loc2a=2, loc1b=4, loc2b=1, loc1a=loc1a, loc2a=loc2a, loc1b=loc1b, loc2b=loc2b, clip_on=False, **prop_patches) diff --git a/examples/subplots_axes_and_figures/colorbar_placement.py b/examples/subplots_axes_and_figures/colorbar_placement.py index 31e514d3291b..66e267ce6d43 100644 --- a/examples/subplots_axes_and_figures/colorbar_placement.py +++ b/examples/subplots_axes_and_figures/colorbar_placement.py @@ -23,7 +23,6 @@ pcm = ax.pcolormesh(np.random.random((20, 20)) * (col + 1), cmap=cmaps[col]) fig.colorbar(pcm, ax=ax) -plt.show() ###################################################################### # The first column has the same type of data in both rows, so it may @@ -38,7 +37,6 @@ pcm = ax.pcolormesh(np.random.random((20, 20)) * (col + 1), cmap=cmaps[col]) fig.colorbar(pcm, ax=axs[:, col], shrink=0.6) -plt.show() ###################################################################### # Relatively complicated colorbar layouts are possible using this @@ -53,7 +51,6 @@ fig.colorbar(pcm, ax=[axs[0, 2]], location='bottom') fig.colorbar(pcm, ax=axs[1:, :], location='right', shrink=0.6) fig.colorbar(pcm, ax=[axs[2, 1]], location='left') -plt.show() ###################################################################### # Colorbars with fixed-aspect-ratio axes @@ -75,7 +72,6 @@ ax.set_aspect(1/2) if row == 1: fig.colorbar(pcm, ax=ax, shrink=0.6) -plt.show() ###################################################################### # One way around this issue is to use an `.Axes.inset_axes` to locate the @@ -94,6 +90,7 @@ else: ax.set_aspect(1/2) if row == 1: - cax = ax.inset_axes([1.04, 0.2, 0.05, 0.6], transform=ax.transAxes) + cax = ax.inset_axes([1.04, 0.2, 0.05, 0.6]) fig.colorbar(pcm, ax=ax, cax=cax) + plt.show() diff --git a/examples/subplots_axes_and_figures/demo_tight_layout.py b/examples/subplots_axes_and_figures/demo_tight_layout.py index ca4cf75184d7..e3f22618ede5 100644 --- a/examples/subplots_axes_and_figures/demo_tight_layout.py +++ b/examples/subplots_axes_and_figures/demo_tight_layout.py @@ -3,9 +3,8 @@ Resizing axes with tight layout =============================== -`~.figure.Figure.tight_layout` attempts to resize subplots in -a figure so that there are no overlaps between axes objects and labels -on the axes. +`~.Figure.tight_layout` attempts to resize subplots in a figure so that there +are no overlaps between axes objects and labels on the axes. See :doc:`/tutorials/intermediate/tight_layout_guide` for more details and :doc:`/tutorials/intermediate/constrainedlayout_guide` for an alternative. diff --git a/examples/subplots_axes_and_figures/fahrenheit_celsius_scales.py b/examples/subplots_axes_and_figures/fahrenheit_celsius_scales.py index 1212f5a37f78..07b447c97521 100644 --- a/examples/subplots_axes_and_figures/fahrenheit_celsius_scales.py +++ b/examples/subplots_axes_and_figures/fahrenheit_celsius_scales.py @@ -3,7 +3,7 @@ Different scales on the same axes ================================= -Demo of how to display two scales on the left and right y axis. +Demo of how to display two scales on the left and right y-axis. This example uses the Fahrenheit and Celsius scales. """ @@ -23,7 +23,7 @@ def make_plot(): # Define a closure function to register as a callback def convert_ax_c_to_celsius(ax_f): """ - Update second axis according with first axis. + Update second axis according to first axis. """ y1, y2 = ax_f.get_ylim() ax_c.set_ylim(fahrenheit2celsius(y1), fahrenheit2celsius(y2)) diff --git a/examples/subplots_axes_and_figures/ganged_plots.py b/examples/subplots_axes_and_figures/ganged_plots.py index 4014dabdf046..b32d2aed83d6 100644 --- a/examples/subplots_axes_and_figures/ganged_plots.py +++ b/examples/subplots_axes_and_figures/ganged_plots.py @@ -8,8 +8,8 @@ will automatically turn off all x ticks and labels except those on the bottom axis. -In this example the plots share a common x axis but you can follow the same -logic to supply a common y axis. +In this example the plots share a common x-axis, but you can follow the same +logic to supply a common y-axis. """ import matplotlib.pyplot as plt import numpy as np @@ -21,7 +21,7 @@ s3 = s1 * s2 fig, axs = plt.subplots(3, 1, sharex=True) -# Remove horizontal space between axes +# Remove vertical space between axes fig.subplots_adjust(hspace=0) # Plot each graph, and manually set the y tick values diff --git a/examples/subplots_axes_and_figures/gridspec_nested.py b/examples/subplots_axes_and_figures/gridspec_nested.py index e47106e5d47c..2a73fc4b3c1c 100644 --- a/examples/subplots_axes_and_figures/gridspec_nested.py +++ b/examples/subplots_axes_and_figures/gridspec_nested.py @@ -7,7 +7,7 @@ set the position for a nested grid of subplots. Note that the same functionality can be achieved more directly with -`~.figure.FigureBase.subfigures`; see +`~.FigureBase.subfigures`; see :doc:`/gallery/subplots_axes_and_figures/subfigures`. """ diff --git a/tutorials/provisional/mosaic.py b/examples/subplots_axes_and_figures/mosaic.py similarity index 69% rename from tutorials/provisional/mosaic.py rename to examples/subplots_axes_and_figures/mosaic.py index 9aa06c9520e1..9b77893bb2d6 100644 --- a/tutorials/provisional/mosaic.py +++ b/examples/subplots_axes_and_figures/mosaic.py @@ -1,16 +1,13 @@ """ -======================================= -Complex and semantic figure composition -======================================= +.. redirect-from:: /tutorials/provisional/mosaic -.. warning:: - This tutorial documents experimental / provisional API. - We are releasing this in v3.3 to get user feedback. We may - make breaking changes in future versions with no warning. +======================================================== +Complex and semantic figure composition (subplot_mosaic) +======================================================== -Laying out Axes in a Figure in a non uniform grid can be both tedious +Laying out Axes in a Figure in a non-uniform grid can be both tedious and verbose. For dense, even grids we have `.Figure.subplots` but for more complex layouts, such as Axes that span multiple columns / rows of the layout or leave some areas of the Figure blank, you can use @@ -105,7 +102,7 @@ def identify_axes(ax_dict, fontsize=48): # String short-hand # ================= # -# By restricting our axes labels to single characters we can use Using we can +# By restricting our axes labels to single characters we can # "draw" the Axes we want as "ASCII art". The following @@ -123,14 +120,30 @@ def identify_axes(ax_dict, fontsize=48): ax_dict = fig.subplot_mosaic(mosaic) identify_axes(ax_dict) +############################################################################### +# Alternatively, you can use the more compact string notation +mosaic = "AB;CD" ############################################################################### -# Something we can do with `.Figure.subplot_mosaic` that you can not -# do with `.Figure.subplots` is specify that an Axes should span -# several rows or columns. +# will give you the same composition, where the ``";"`` is used +# as the row separator instead of newline. + +fig = plt.figure(constrained_layout=True) +ax_dict = fig.subplot_mosaic(mosaic) +identify_axes(ax_dict) + +############################################################################### +# Axes spanning multiple rows/columns +# =================================== # -# If we want to re-arrange our four Axes to have C be a horizontal -# span on the bottom and D be a vertical span on the right we would do +# Something we can do with `.Figure.subplot_mosaic`, that we cannot +# do with `.Figure.subplots`, is to specify that an Axes should span +# several rows or columns. + + +############################################################################### +# If we want to re-arrange our four Axes to have ``"C"`` be a horizontal +# span on the bottom and ``"D"`` be a vertical span on the right we would do axd = plt.figure(constrained_layout=True).subplot_mosaic( """ @@ -186,15 +199,17 @@ def identify_axes(ax_dict, fontsize=48): # empty sentinel with the string shorthand because it may be stripped # while processing the input. # -# Controlling mosaic and subplot creation -# ======================================= +# Controlling mosaic creation +# =========================== # # This feature is built on top of `.gridspec` and you can pass the # keyword arguments through to the underlying `.gridspec.GridSpec` # (the same as `.Figure.subplots`). # # In this case we want to use the input to specify the arrangement, -# but set the relative widths of the rows / columns via *gridspec_kw*. +# but set the relative widths of the rows / columns. For convenience, +# `.gridspec.GridSpec`'s *height_ratios* and *width_ratios* are exposed in the +# `.Figure.subplot_mosaic` calling sequence. axd = plt.figure(constrained_layout=True).subplot_mosaic( @@ -203,19 +218,18 @@ def identify_axes(ax_dict, fontsize=48): bAc .d. """, - gridspec_kw={ - # set the height ratios between the rows - "height_ratios": [1, 3.5, 1], - # set the width ratios between the columns - "width_ratios": [1, 3.5, 1], - }, + # set the height ratios between the rows + height_ratios=[1, 3.5, 1], + # set the width ratios between the columns + width_ratios=[1, 3.5, 1], ) identify_axes(axd) ############################################################################### -# Or use the {*left*, *right*, *bottom*, *top*} keyword arguments to +# Other `.gridspec.GridSpec` keywords can be passed via *gridspec_kw*. For +# example, use the {*left*, *right*, *bottom*, *top*} keyword arguments to # position the overall mosaic to put multiple versions of the same -# mosaic in a figure +# mosaic in a figure. mosaic = """AA BC""" @@ -261,8 +275,12 @@ def identify_axes(ax_dict, fontsize=48): ############################################################################### +# Controlling subplot creation +# ============================ +# # We can also pass through arguments used to create the subplots -# (again, the same as `.Figure.subplots`). +# (again, the same as `.Figure.subplots`) which will apply to all +# of the Axes created. axd = plt.figure(constrained_layout=True).subplot_mosaic( @@ -270,12 +288,64 @@ def identify_axes(ax_dict, fontsize=48): ) identify_axes(axd) +############################################################################### +# Per-Axes subplot keyword arguments +# ---------------------------------- +# +# If you need to control the parameters passed to each subplot individually use +# *per_subplot_kw* to pass a mapping between the Axes identifiers (or +# tuples of Axes identifiers) to dictionaries of keywords to be passed. +# +# .. versionadded:: 3.7 +# + + +fig, axd = plt.subplot_mosaic( + "AB;CD", + per_subplot_kw={ + "A": {"projection": "polar"}, + ("C", "D"): {"xscale": "log"} + }, +) +identify_axes(axd) + +############################################################################### +# If the layout is specified with the string short-hand, then we know the +# Axes labels will be one character and can unambiguously interpret longer +# strings in *per_subplot_kw* to specify a set of Axes to apply the +# keywords to: + + +fig, axd = plt.subplot_mosaic( + "AB;CD", + per_subplot_kw={ + "AD": {"projection": "polar"}, + "BC": {"facecolor": ".9"} + }, +) +identify_axes(axd) + +############################################################################### +# If *subplot_kw* and *per_subplot_kw* are used together, then they are +# merged with *per_subplot_kw* taking priority: + + +axd = plt.figure(constrained_layout=True).subplot_mosaic( + "AB;CD", + subplot_kw={"facecolor": "xkcd:tangerine"}, + per_subplot_kw={ + "B": {"facecolor": "xkcd:water blue"}, + "D": {"projection": "polar", "facecolor": "w"}, + } +) +identify_axes(axd) + ############################################################################### -# Nested List input +# Nested list input # ================= # -# Everything we can do with the string short-hand we can also do when +# Everything we can do with the string shorthand we can also do when # passing in a list (internally we convert the string shorthand to a nested # list), for example using spans, blanks, and *gridspec_kw*: @@ -285,7 +355,7 @@ def identify_axes(ax_dict, fontsize=48): ["main", "BLANK"], ], empty_sentinel="BLANK", - gridspec_kw={"width_ratios": [2, 1]}, + width_ratios=[2, 1], ) identify_axes(axd) diff --git a/examples/subplots_axes_and_figures/multiple_figs_demo.py b/examples/subplots_axes_and_figures/multiple_figs_demo.py index 2d30768902c7..ee667e95565f 100644 --- a/examples/subplots_axes_and_figures/multiple_figs_demo.py +++ b/examples/subplots_axes_and_figures/multiple_figs_demo.py @@ -10,10 +10,11 @@ .. note:: - We discourage working with multiple figures in pyplot because managing - the *current figure* is cumbersome and error-prone. Instead, we recommend - to use the object-oriented approach and call methods on Figure and Axes - instances. + We discourage working with multiple figures through the implicit pyplot + interface because managing the *current figure* is cumbersome and + error-prone. Instead, we recommend using the explicit approach and call + methods on Figure and Axes instances. See :ref:`api_interfaces` for an + explanation of the trade-offs between the implicit and explicit interfaces. """ import matplotlib.pyplot as plt diff --git a/examples/subplots_axes_and_figures/secondary_axis.py b/examples/subplots_axes_and_figures/secondary_axis.py index 8122a211f899..a8ba17d3478b 100644 --- a/examples/subplots_axes_and_figures/secondary_axis.py +++ b/examples/subplots_axes_and_figures/secondary_axis.py @@ -59,7 +59,7 @@ def rad2deg(x): def one_over(x): """Vectorized 1/x, treating x==0 manually""" - x = np.array(x).astype(float) + x = np.array(x, float) near_zero = np.isclose(x, 0) x[near_zero] = np.inf x[~near_zero] = 1 / x[~near_zero] diff --git a/examples/subplots_axes_and_figures/shared_axis_demo.py b/examples/subplots_axes_and_figures/shared_axis_demo.py index e475a3307288..cfe2d68701f0 100644 --- a/examples/subplots_axes_and_figures/shared_axis_demo.py +++ b/examples/subplots_axes_and_figures/shared_axis_demo.py @@ -1,17 +1,17 @@ """ =========== -Shared Axis +Shared axis =========== -You can share the x or y axis limits for one axis with another by -passing an axes instance as a *sharex* or *sharey* keyword argument. +You can share the x- or y-axis limits for one axis with another by +passing an `~.axes.Axes` instance as a *sharex* or *sharey* keyword argument. Changing the axis limits on one axes will be reflected automatically in the other, and vice-versa, so when you navigate with the toolbar -the axes will follow each other on their shared axes. Ditto for +the Axes will follow each other on their shared axis. Ditto for changes in the axis scaling (e.g., log vs. linear). However, it is possible to have differences in tick labeling, e.g., you can selectively -turn off the tick labels on one axes. +turn off the tick labels on one Axes. The example below shows how to customize the tick labels on the various axes. Shared axes share the tick locator, tick formatter, @@ -20,13 +20,13 @@ because you may want to make the tick labels smaller on the upper axes, e.g., in the example below. -If you want to turn off the ticklabels for a given axes (e.g., on -subplot(211) or subplot(212), you cannot do the standard trick:: +If you want to turn off the ticklabels for a given Axes (e.g., on +subplot(211) or subplot(212)), you cannot do the standard trick:: setp(ax2, xticklabels=[]) because this changes the tick Formatter, which is shared among all -axes. But you can alter the visibility of the labels, which is a +Axes. But you can alter the visibility of the labels, which is a property:: setp(ax2.get_xticklabels(), visible=False) diff --git a/examples/subplots_axes_and_figures/subplots_demo.py b/examples/subplots_axes_and_figures/subplots_demo.py index bbc8afa469fa..41b14dadd620 100644 --- a/examples/subplots_axes_and_figures/subplots_demo.py +++ b/examples/subplots_axes_and_figures/subplots_demo.py @@ -176,7 +176,7 @@ ax3.plot(x + 1, -y, 'tab:green') ax4.plot(x + 2, -y**2, 'tab:red') -for ax in axs.flat: +for ax in fig.get_axes(): ax.label_outer() ############################################################################### diff --git a/examples/subplots_axes_and_figures/zoom_inset_axes.py b/examples/subplots_axes_and_figures/zoom_inset_axes.py index 85f3f78ec6b4..db78aedb318f 100644 --- a/examples/subplots_axes_and_figures/zoom_inset_axes.py +++ b/examples/subplots_axes_and_figures/zoom_inset_axes.py @@ -29,7 +29,7 @@ def get_demo_image(): # inset axes.... axins = ax.inset_axes([0.5, 0.5, 0.47, 0.47]) axins.imshow(Z2, extent=extent, origin="lower") -# sub region of the original image +# subregion of the original image x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 axins.set_xlim(x1, x2) axins.set_ylim(y1, y2) diff --git a/examples/text_labels_and_annotations/accented_text.py b/examples/text_labels_and_annotations/accented_text.py index 68a6f5d26456..9e9ad65e9f15 100644 --- a/examples/text_labels_and_annotations/accented_text.py +++ b/examples/text_labels_and_annotations/accented_text.py @@ -1,9 +1,9 @@ r""" ================================= -Using accented text in matplotlib +Using accented text in Matplotlib ================================= -Matplotlib supports accented characters via TeX mathtext or unicode. +Matplotlib supports accented characters via TeX mathtext or Unicode. Using mathtext, the following accents are provided: \\hat, \\breve, \\grave, \\bar, \\acute, \\tilde, \\vec, \\dot, \\ddot. All of them have the same diff --git a/examples/pyplots/align_ylabels.py b/examples/text_labels_and_annotations/align_ylabels.py similarity index 97% rename from examples/pyplots/align_ylabels.py rename to examples/text_labels_and_annotations/align_ylabels.py index 3523e08f93fd..8e47909cc6c1 100644 --- a/examples/pyplots/align_ylabels.py +++ b/examples/text_labels_and_annotations/align_ylabels.py @@ -6,6 +6,7 @@ Two methods are shown here, one using a short call to `.Figure.align_ylabels` and the second a manual way to align the labels. +.. redirect-from:: /gallery/pyplots/align_ylabels """ import numpy as np import matplotlib.pyplot as plt diff --git a/examples/text_labels_and_annotations/angle_annotation.py b/examples/text_labels_and_annotations/angle_annotation.py index 065e4f51c9b8..8272dd1f3e06 100644 --- a/examples/text_labels_and_annotations/angle_annotation.py +++ b/examples/text_labels_and_annotations/angle_annotation.py @@ -134,8 +134,7 @@ def get_size(self): if self.unit == "points": factor = self.ax.figure.dpi / 72. elif self.unit[:4] == "axes": - b = TransformedBbox(Bbox.from_bounds(0, 0, 1, 1), - self.ax.transAxes) + b = TransformedBbox(Bbox.unit(), self.ax.transAxes) dic = {"max": max(b.width, b.height), "min": min(b.width, b.height), "width": b.width, "height": b.height} diff --git a/examples/text_labels_and_annotations/angles_on_bracket_arrows.py b/examples/text_labels_and_annotations/angles_on_bracket_arrows.py new file mode 100644 index 000000000000..35a6cad22130 --- /dev/null +++ b/examples/text_labels_and_annotations/angles_on_bracket_arrows.py @@ -0,0 +1,64 @@ +""" +=================================== +Angle annotations on bracket arrows +=================================== + +This example shows how to add angle annotations to bracket arrow styles +created using `.FancyArrowPatch`. *angleA* and *angleB* are measured from a +vertical line as positive (to the left) or negative (to the right). Blue +`.FancyArrowPatch` arrows indicate the directions of *angleA* and *angleB* +from the vertical and axes text annotate the angle sizes. +""" + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import FancyArrowPatch + + +def get_point_of_rotated_vertical(origin, line_length, degrees): + """Return xy coordinates of the vertical line end rotated by degrees.""" + rad = np.deg2rad(-degrees) + return [origin[0] + line_length * np.sin(rad), + origin[1] + line_length * np.cos(rad)] + + +fig, ax = plt.subplots(figsize=(8, 7)) +ax.set(xlim=(0, 6), ylim=(-1, 4)) +ax.set_title("Orientation of the bracket arrows relative to angleA and angleB") + +for i, style in enumerate(["]-[", "|-|"]): + for j, angle in enumerate([-40, 60]): + y = 2*i + j + arrow_centers = ((1, y), (5, y)) + vlines = ((1, y + 0.5), (5, y + 0.5)) + anglesAB = (angle, -angle) + bracketstyle = f"{style}, angleA={anglesAB[0]}, angleB={anglesAB[1]}" + bracket = FancyArrowPatch(*arrow_centers, arrowstyle=bracketstyle, + mutation_scale=42) + ax.add_patch(bracket) + ax.text(3, y + 0.05, bracketstyle, ha="center", va="bottom") + ax.vlines([i[0] for i in vlines], [y, y], [i[1] for i in vlines], + linestyles="--", color="C0") + # Get the top coordinates for the drawn patches at A and B + patch_tops = [get_point_of_rotated_vertical(center, 0.5, angle) + for center, angle in zip(arrow_centers, anglesAB)] + # Define the connection directions for the annotation arrows + connection_dirs = (1, -1) if angle > 0 else (-1, 1) + # Add arrows and annotation text + arrowstyle = "Simple, tail_width=0.5, head_width=4, head_length=8" + for vline, dir, patch_top, angle in zip(vlines, connection_dirs, + patch_tops, anglesAB): + kw = dict(connectionstyle=f"arc3,rad={dir * 0.5}", + arrowstyle=arrowstyle, color="C0") + ax.add_patch(FancyArrowPatch(vline, patch_top, **kw)) + ax.text(vline[0] - dir * 0.15, y + 0.3, f'{angle}°', ha="center", + va="center") + +############################################################################# +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.patches.ArrowStyle` diff --git a/examples/pyplots/annotate_transform.py b/examples/text_labels_and_annotations/annotate_transform.py similarity index 96% rename from examples/pyplots/annotate_transform.py rename to examples/text_labels_and_annotations/annotate_transform.py index 9d3d09f5a0ba..1145f7fdb9a2 100644 --- a/examples/pyplots/annotate_transform.py +++ b/examples/text_labels_and_annotations/annotate_transform.py @@ -6,6 +6,8 @@ This example shows how to use different coordinate systems for annotations. For a complete overview of the annotation capabilities, also see the :doc:`annotation tutorial`. + +.. redirect-from:: /gallery/pyplots/annotate_transform """ import numpy as np diff --git a/examples/pyplots/annotation_basic.py b/examples/text_labels_and_annotations/annotation_basic.py similarity index 94% rename from examples/pyplots/annotation_basic.py rename to examples/text_labels_and_annotations/annotation_basic.py index 5a6327b48748..d9ea9748197f 100644 --- a/examples/pyplots/annotation_basic.py +++ b/examples/text_labels_and_annotations/annotation_basic.py @@ -8,6 +8,8 @@ For a complete overview of the annotation capabilities, also see the :doc:`annotation tutorial`. + +.. redirect-from:: /gallery/pyplots/annotation_basic """ import numpy as np import matplotlib.pyplot as plt diff --git a/examples/text_labels_and_annotations/annotation_demo.py b/examples/text_labels_and_annotations/annotation_demo.py index 6e1cfb07c2df..2008a8744675 100644 --- a/examples/text_labels_and_annotations/annotation_demo.py +++ b/examples/text_labels_and_annotations/annotation_demo.py @@ -249,7 +249,6 @@ xy=(2., -1), xycoords='data', xytext=(-100, 60), textcoords='offset points', size=20, - # bbox=dict(boxstyle="round", fc="0.8"), arrowprops=dict(arrowstyle="fancy", fc="0.6", ec="none", patchB=el, @@ -258,7 +257,6 @@ xy=(2., -1), xycoords='data', xytext=(100, 60), textcoords='offset points', size=20, - # bbox=dict(boxstyle="round", fc="0.8"), arrowprops=dict(arrowstyle="simple", fc="0.6", ec="none", patchB=el, @@ -267,7 +265,6 @@ xy=(2., -1), xycoords='data', xytext=(-100, -100), textcoords='offset points', size=20, - # bbox=dict(boxstyle="round", fc="0.8"), arrowprops=dict(arrowstyle="wedge,tail_width=0.7", fc="0.6", ec="none", patchB=el, diff --git a/examples/pyplots/annotation_polar.py b/examples/text_labels_and_annotations/annotation_polar.py similarity index 95% rename from examples/pyplots/annotation_polar.py rename to examples/text_labels_and_annotations/annotation_polar.py index 24a3a20bc3fe..a7f8b764d914 100644 --- a/examples/pyplots/annotation_polar.py +++ b/examples/text_labels_and_annotations/annotation_polar.py @@ -7,6 +7,8 @@ For a complete overview of the annotation capabilities, also see the :doc:`annotation tutorial`. + +.. redirect-from:: /gallery/pyplots/annotation_polar """ import numpy as np import matplotlib.pyplot as plt diff --git a/examples/text_labels_and_annotations/arrow_demo.py b/examples/text_labels_and_annotations/arrow_demo.py index f501506082a3..cf9e86d12c3b 100644 --- a/examples/text_labels_and_annotations/arrow_demo.py +++ b/examples/text_labels_and_annotations/arrow_demo.py @@ -116,6 +116,7 @@ def make_arrow_graph(ax, data, size=4, display='length', shape='right', fc=fc, ec=ec or fc, alpha=alpha, width=width, head_width=head_width, head_length=head_length, shape=shape, length_includes_head=True, + **kwargs ) # figure out coordinates for text: diff --git a/examples/text_labels_and_annotations/custom_legends.py b/examples/text_labels_and_annotations/custom_legends.py index e9f840fe768f..7f4ca07398f4 100644 --- a/examples/text_labels_and_annotations/custom_legends.py +++ b/examples/text_labels_and_annotations/custom_legends.py @@ -19,7 +19,8 @@ and call ``ax.legend()``, you will get the following: """ # sphinx_gallery_thumbnail_number = 2 -from matplotlib import rcParams, cycler +import matplotlib as mpl +from matplotlib import cycler import matplotlib.pyplot as plt import numpy as np @@ -29,7 +30,7 @@ N = 10 data = (np.geomspace(1, 10, 100) + np.random.randn(N, 100)).T cmap = plt.cm.coolwarm -rcParams['axes.prop_cycle'] = cycler(color=cmap(np.linspace(0, 1, N))) +mpl.rcParams['axes.prop_cycle'] = cycler(color=cmap(np.linspace(0, 1, N))) fig, ax = plt.subplots() lines = ax.plot(data) diff --git a/examples/text_labels_and_annotations/date.py b/examples/text_labels_and_annotations/date.py index f1701ad9bc3b..9c72972a04e4 100644 --- a/examples/text_labels_and_annotations/date.py +++ b/examples/text_labels_and_annotations/date.py @@ -26,10 +26,10 @@ import matplotlib.dates as mdates import matplotlib.cbook as cbook -# Load a numpy structured array from yahoo csv data with fields date, open, -# close, volume, adj_close from the mpl-data/example directory. This array -# stores the date as an np.datetime64 with a day unit ('D') in the 'date' -# column. +# Load a numpy record array from yahoo csv data with fields date, open, high, +# low, close, volume, adj_close from the mpl-data/sample_data directory. The +# record array stores the date as an np.datetime64 with a day unit ('D') in +# the date column. data = cbook.get_sample_data('goog.npz', np_load=True)['price_data'] fig, axs = plt.subplots(3, 1, figsize=(6.4, 7), constrained_layout=True) @@ -54,7 +54,7 @@ ax = axs[2] ax.set_title('Manual DateFormatter', loc='left', y=0.85, x=0.02, fontsize='medium') -# Text in the x axis will be displayed in 'YYYY-mm' format. +# Text in the x-axis will be displayed in 'YYYY-mm' format. ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%b')) # Rotates and right-aligns the x labels so they don't crowd each other. for label in ax.get_xticklabels(which='major'): diff --git a/examples/text_labels_and_annotations/date_index_formatter.py b/examples/text_labels_and_annotations/date_index_formatter.py deleted file mode 100644 index 4da74ebb7687..000000000000 --- a/examples/text_labels_and_annotations/date_index_formatter.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -===================================== -Custom tick formatter for time series -===================================== - -When plotting time series, e.g., financial time series, one often wants -to leave out days on which there is no data, i.e. weekends. The example -below shows how to use an 'index formatter' to achieve the desired plot -""" -import numpy as np -import matplotlib.pyplot as plt -import matplotlib.cbook as cbook - -# Load a numpy record array from yahoo csv data with fields date, open, close, -# volume, adj_close from the mpl-data/example directory. The record array -# stores the date as an np.datetime64 with a day unit ('D') in the date column. -r = (cbook.get_sample_data('goog.npz', np_load=True)['price_data'] - .view(np.recarray)) -r = r[-30:] # get the last 30 days - -# first we'll do it the default way, with gaps on weekends -fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(8, 4)) -ax1.plot(r.date, r.adj_close, 'o-') -ax1.set_title("Default") -fig.autofmt_xdate() - -# next we'll write a custom formatter -N = len(r) -ind = np.arange(N) # the evenly spaced plot indices - - -def format_date(x, pos=None): - thisind = np.clip(int(x + 0.5), 0, N - 1) - return r.date[thisind].item().strftime('%Y-%m-%d') - - -ax2.plot(ind, r.adj_close, 'o-') -# Use automatic FuncFormatter creation -ax2.xaxis.set_major_formatter(format_date) -ax2.set_title("Custom tick formatter") -fig.autofmt_xdate() - -plt.show() - - -############################################################################# -# -# .. admonition:: References -# -# The use of the following functions, methods, classes and modules is shown -# in this example: -# -# - `matplotlib.pyplot.subplots` -# - `matplotlib.axis.Axis.set_major_formatter` -# - `matplotlib.cbook.get_sample_data` -# - `matplotlib.ticker.FuncFormatter` diff --git a/examples/text_labels_and_annotations/demo_annotation_box.py b/examples/text_labels_and_annotations/demo_annotation_box.py index 44dff1b87d31..1f2f812649a4 100644 --- a/examples/text_labels_and_annotations/demo_annotation_box.py +++ b/examples/text_labels_and_annotations/demo_annotation_box.py @@ -31,7 +31,8 @@ xybox=(-20, 40), xycoords='data', boxcoords="offset points", - arrowprops=dict(arrowstyle="->")) + arrowprops=dict(arrowstyle="->"), + bboxprops=dict(boxstyle="sawtooth")) ax.add_artist(ab) # Annotate the 1st position with another text box ('Test') @@ -54,11 +55,12 @@ da.add_artist(p) ab = AnnotationBbox(da, xy, - xybox=(1.02, xy[1]), + xybox=(1., xy[1]), xycoords='data', boxcoords=("axes fraction", "data"), - box_alignment=(0., 0.5), - arrowprops=dict(arrowstyle="->")) + box_alignment=(0.2, 0.5), + arrowprops=dict(arrowstyle="->"), + bboxprops=dict(alpha=0.5)) ax.add_artist(ab) diff --git a/examples/text_labels_and_annotations/demo_text_path.py b/examples/text_labels_and_annotations/demo_text_path.py index 460670d79dae..1899f314f5b6 100644 --- a/examples/text_labels_and_annotations/demo_text_path.py +++ b/examples/text_labels_and_annotations/demo_text_path.py @@ -3,7 +3,7 @@ Using a text as a Path ====================== -`~matplotlib.textpath.TextPath` creates a `.Path` that is the outline of the +`~matplotlib.text.TextPath` creates a `.Path` that is the outline of the characters of a text. The resulting path can be employed e.g. as a clip path for an image. """ @@ -46,8 +46,6 @@ def draw(self, renderer=None): if __name__ == "__main__": - usetex = plt.rcParams["text.usetex"] - fig, (ax1, ax2) = plt.subplots(2) # EXAMPLE 1 @@ -68,30 +66,28 @@ def draw(self, renderer=None): ax1.add_artist(ao) # another text - from matplotlib.patches import PathPatch - if usetex: - r = r"\mbox{textpath supports mathtext \& \TeX}" - else: - r = r"textpath supports mathtext & TeX" - - text_path = TextPath((0, 0), r, size=20, usetex=usetex) - - p1 = PathPatch(text_path, ec="w", lw=3, fc="w", alpha=0.9, - transform=IdentityTransform()) - p2 = PathPatch(text_path, ec="none", fc="k", - transform=IdentityTransform()) - - offsetbox2 = AuxTransformBox(IdentityTransform()) - offsetbox2.add_artist(p1) - offsetbox2.add_artist(p2) - - ab = AnnotationBbox(offsetbox2, (0.95, 0.05), - xycoords='axes fraction', - boxcoords="offset points", - box_alignment=(1., 0.), - frameon=False - ) - ax1.add_artist(ab) + for usetex, ypos, string in [ + (False, 0.25, r"textpath supports mathtext"), + (True, 0.05, r"textpath supports \TeX"), + ]: + text_path = TextPath((0, 0), string, size=20, usetex=usetex) + + p1 = PathPatch(text_path, ec="w", lw=3, fc="w", alpha=0.9, + transform=IdentityTransform()) + p2 = PathPatch(text_path, ec="none", fc="k", + transform=IdentityTransform()) + + offsetbox2 = AuxTransformBox(IdentityTransform()) + offsetbox2.add_artist(p1) + offsetbox2.add_artist(p2) + + ab = AnnotationBbox(offsetbox2, (0.95, ypos), + xycoords='axes fraction', + boxcoords="offset points", + box_alignment=(1., 0.), + frameon=False, + ) + ax1.add_artist(ab) ax1.imshow([[0, 1, 2], [1, 2, 3]], cmap=plt.cm.gist_gray_r, interpolation="bilinear", aspect="auto") @@ -100,32 +96,34 @@ def draw(self, renderer=None): arr = np.arange(256).reshape(1, 256) / 256 - if usetex: - s = (r"$\displaystyle\left[\sum_{n=1}^\infty" - r"\frac{-e^{i\pi}}{2^n}\right]$!") - else: - s = r"$\left[\sum_{n=1}^\infty\frac{-e^{i\pi}}{2^n}\right]$!" - text_path = TextPath((0, 0), s, size=40, usetex=usetex) - text_patch = PathClippedImagePatch(text_path, arr, ec="none", - transform=IdentityTransform()) - - shadow1 = Shadow(text_patch, 1, -1, fc="none", ec="0.6", lw=3) - shadow2 = Shadow(text_patch, 1, -1, fc="0.3", ec="none") - - # make offset box - offsetbox = AuxTransformBox(IdentityTransform()) - offsetbox.add_artist(shadow1) - offsetbox.add_artist(shadow2) - offsetbox.add_artist(text_patch) - - # place the anchored offset box using AnnotationBbox - ab = AnnotationBbox(offsetbox, (0.5, 0.5), - xycoords='data', - boxcoords="offset points", - box_alignment=(0.5, 0.5), - ) - - ax2.add_artist(ab) + for usetex, xpos, string in [ + (False, 0.25, + r"$\left[\sum_{n=1}^\infty\frac{-e^{i\pi}}{2^n}\right]$!"), + (True, 0.75, + r"$\displaystyle\left[\sum_{n=1}^\infty" + r"\frac{-e^{i\pi}}{2^n}\right]$!"), + ]: + text_path = TextPath((0, 0), string, size=40, usetex=usetex) + text_patch = PathClippedImagePatch(text_path, arr, ec="none", + transform=IdentityTransform()) + + shadow1 = Shadow(text_patch, 1, -1, fc="none", ec="0.6", lw=3) + shadow2 = Shadow(text_patch, 1, -1, fc="0.3", ec="none") + + # make offset box + offsetbox = AuxTransformBox(IdentityTransform()) + offsetbox.add_artist(shadow1) + offsetbox.add_artist(shadow2) + offsetbox.add_artist(text_patch) + + # place the anchored offset box using AnnotationBbox + ab = AnnotationBbox(offsetbox, (xpos, 0.5), + xycoords='data', + boxcoords="offset points", + box_alignment=(0.5, 0.5), + ) + + ax2.add_artist(ab) ax2.set_xlim(0, 1) ax2.set_ylim(0, 1) diff --git a/examples/text_labels_and_annotations/demo_text_rotation_mode.py b/examples/text_labels_and_annotations/demo_text_rotation_mode.py index 834e92519134..8920cb543162 100644 --- a/examples/text_labels_and_annotations/demo_text_rotation_mode.py +++ b/examples/text_labels_and_annotations/demo_text_rotation_mode.py @@ -17,26 +17,19 @@ the bounding box of the rotated text. - ``rotation_mode='anchor'`` aligns the unrotated text and then rotates the text around the point of alignment. + +.. redirect-from:: /gallery/text_labels_and_annotations/text_rotation """ import matplotlib.pyplot as plt -import numpy as np -def test_rotation_mode(fig, mode, subplot_location): +def test_rotation_mode(fig, mode): ha_list = ["left", "center", "right"] va_list = ["top", "center", "baseline", "bottom"] - axs = np.empty((len(va_list), len(ha_list)), object) - gs = subplot_location.subgridspec(*axs.shape, hspace=0, wspace=0) - axs[0, 0] = fig.add_subplot(gs[0, 0]) - for i in range(len(va_list)): - for j in range(len(ha_list)): - if (i, j) == (0, 0): - continue # Already set. - axs[i, j] = fig.add_subplot( - gs[i, j], sharex=axs[0, 0], sharey=axs[0, 0]) - for ax in axs.flat: - ax.set(aspect=1) + axs = fig.subplots(len(va_list), len(ha_list), sharex=True, sharey=True, + subplot_kw=dict(aspect=1), + gridspec_kw=dict(hspace=0, wspace=0)) # labels and title for ha, ax in zip(ha_list, axs[-1, :]): @@ -50,6 +43,8 @@ def test_rotation_mode(fig, mode, subplot_location): {"bbox": dict(boxstyle="square,pad=0.", ec="none", fc="C1", alpha=0.3)} ) + texts = {} + # use a different text alignment in each axes for i, va in enumerate(va_list): for j, ha in enumerate(ha_list): @@ -64,12 +59,12 @@ def test_rotation_mode(fig, mode, subplot_location): size="x-large", rotation=40, horizontalalignment=ha, verticalalignment=va, rotation_mode=mode, **kw) + texts[ax] = tx if mode == "default": # highlight bbox fig.canvas.draw() - for ax in axs.flat: - text, = ax.texts + for ax, text in texts.items(): bb = text.get_window_extent().transformed(ax.transData.inverted()) rect = plt.Rectangle((bb.x0, bb.y0), bb.width, bb.height, facecolor="C1", alpha=0.3, zorder=2) @@ -77,9 +72,9 @@ def test_rotation_mode(fig, mode, subplot_location): fig = plt.figure(figsize=(8, 5)) -gs = fig.add_gridspec(1, 2) -test_rotation_mode(fig, "default", gs[0]) -test_rotation_mode(fig, "anchor", gs[1]) +subfigs = fig.subfigures(1, 2) +test_rotation_mode(subfigs[0], "default") +test_rotation_mode(subfigs[1], "anchor") plt.show() diff --git a/examples/text_labels_and_annotations/figlegend_demo.py b/examples/text_labels_and_annotations/figlegend_demo.py index 674b7627f09f..c749ae795cd5 100644 --- a/examples/text_labels_and_annotations/figlegend_demo.py +++ b/examples/text_labels_and_annotations/figlegend_demo.py @@ -23,8 +23,31 @@ l3, = axs[1].plot(x, y3, color='tab:green') l4, = axs[1].plot(x, y4, color='tab:red', marker='^') -fig.legend((l1, l2), ('Line 1', 'Line 2'), 'upper left') -fig.legend((l3, l4), ('Line 3', 'Line 4'), 'upper right') +fig.legend((l1, l2), ('Line 1', 'Line 2'), loc='upper left') +fig.legend((l3, l4), ('Line 3', 'Line 4'), loc='upper right') plt.tight_layout() plt.show() + +############################################################################## +# Sometimes we do not want the legend to overlap the axes. If you use +# constrained_layout you can specify "outside right upper", and +# constrained_layout will make room for the legend. + +fig, axs = plt.subplots(1, 2, layout='constrained') + +x = np.arange(0.0, 2.0, 0.02) +y1 = np.sin(2 * np.pi * x) +y2 = np.exp(-x) +l1, = axs[0].plot(x, y1) +l2, = axs[0].plot(x, y2, marker='o') + +y3 = np.sin(4 * np.pi * x) +y4 = np.exp(-2 * x) +l3, = axs[1].plot(x, y3, color='tab:green') +l4, = axs[1].plot(x, y4, color='tab:red', marker='^') + +fig.legend((l1, l2), ('Line 1', 'Line 2'), loc='upper left') +fig.legend((l3, l4), ('Line 3', 'Line 4'), loc='outside right upper') + +plt.show() diff --git a/examples/text_labels_and_annotations/font_family_rc.py b/examples/text_labels_and_annotations/font_family_rc.py new file mode 100644 index 000000000000..d840a8575920 --- /dev/null +++ b/examples/text_labels_and_annotations/font_family_rc.py @@ -0,0 +1,73 @@ +""" +=========================== +Configuring the font family +=========================== + +You can explicitly set which font family is picked up, either by specifying +family names of fonts installed on user's system, or generic-families +(e.g., 'serif', 'sans-serif', 'monospace', 'fantasy' or 'cursive'), +or a combination of both. +(see :doc:`font tutorial `) + +In the example below, we are overriding the default sans-serif generic family +to include a specific (Tahoma) font. (Note that the best way to achieve this +would simply be to prepend 'Tahoma' in 'font.family') + +The default family is set with the font.family rcparam, +e.g. :: + + rcParams['font.family'] = 'sans-serif' + +and for the font.family you set a list of font styles to try to find +in order:: + + rcParams['font.sans-serif'] = ['Tahoma', 'DejaVu Sans', + 'Lucida Grande', 'Verdana'] + +.. redirect-from:: /gallery/font_family_rc_sgskip + + + +The font font.family defaults are OS dependent and can be viewed with +""" +import matplotlib.pyplot as plt + +print(plt.rcParams["font.sans-serif"][0]) +print(plt.rcParams["font.monospace"][0]) + + +################################################################# +# Choose default sans-serif font + +def print_text(text): + fig, ax = plt.subplots(figsize=(6, 1), facecolor="#eefade") + ax.text(0.5, 0.5, text, ha='center', va='center', size=40) + ax.axis("off") + plt.show() + + +plt.rcParams["font.family"] = "sans-serif" +print_text("Hello World! 01") + + +################################################################# +# Choose sans-serif font and specify to it to "Nimbus Sans" + +plt.rcParams["font.family"] = "sans-serif" +plt.rcParams["font.sans-serif"] = ["Nimbus Sans"] +print_text("Hello World! 02") + + +################################################################## +# Choose default monospace font + +plt.rcParams["font.family"] = "monospace" +print_text("Hello World! 03") + + +################################################################### +# Choose monospace font and specify to it to "FreeMono" + +plt.rcParams["font.family"] = "monospace" +plt.rcParams["font.monospace"] = ["FreeMono"] +print_text("Hello World! 04") diff --git a/examples/text_labels_and_annotations/font_family_rc_sgskip.py b/examples/text_labels_and_annotations/font_family_rc_sgskip.py deleted file mode 100644 index 4633ab618903..000000000000 --- a/examples/text_labels_and_annotations/font_family_rc_sgskip.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -=========================== -Configuring the font family -=========================== - -You can explicitly set which font family is picked up, either by specifying -family names of fonts installed on user's system, or generic-families -(e.g., 'serif', 'sans-serif', 'monospace', 'fantasy' or 'cursive'), -or a combination of both. -(see :doc:`font tutorial `) - -In the example below, we are overriding the default sans-serif generic family -to include a specific (Tahoma) font. (Note that the best way to achieve this -would simply be to prepend 'Tahoma' in 'font.family') - -The default family is set with the font.family rcparam, -e.g. :: - - rcParams['font.family'] = 'sans-serif' - -and for the font.family you set a list of font styles to try to find -in order:: - - rcParams['font.sans-serif'] = ['Tahoma', 'DejaVu Sans', - 'Lucida Grande', 'Verdana'] -""" - -import matplotlib.pyplot as plt - -plt.rcParams['font.family'] = 'sans-serif' -plt.rcParams['font.sans-serif'] = ['Tahoma'] - -fig, ax = plt.subplots() -ax.plot([1, 2, 3], label='test') - -ax.legend() -plt.show() diff --git a/examples/text_labels_and_annotations/font_file.py b/examples/text_labels_and_annotations/font_file.py index 51752e6f4c5b..7d808a122231 100644 --- a/examples/text_labels_and_annotations/font_file.py +++ b/examples/text_labels_and_annotations/font_file.py @@ -12,7 +12,7 @@ Matplotlib. For a more flexible solution, see -:doc:`/gallery/text_labels_and_annotations/font_family_rc_sgskip` and +:doc:`/gallery/text_labels_and_annotations/font_family_rc` and :doc:`/gallery/text_labels_and_annotations/fonts_demo`. """ diff --git a/examples/text_labels_and_annotations/font_table.py b/examples/text_labels_and_annotations/font_table.py index e9296430ac13..6b03a602fbe6 100644 --- a/examples/text_labels_and_annotations/font_table.py +++ b/examples/text_labels_and_annotations/font_table.py @@ -75,8 +75,8 @@ def draw_font_table(path): # we have indeed selected a Unicode charmap. codes = font.get_charmap().items() - labelc = ["{:X}".format(i) for i in range(16)] - labelr = ["{:02X}".format(16 * i) for i in range(16)] + labelc = [f"{i:X}" for i in range(16)] + labelr = [f"{i:02X}" for i in range(0, 16*16, 16)] chars = [["" for c in range(16)] for r in range(16)] for char_code, glyph_index in codes: diff --git a/examples/text_labels_and_annotations/fonts_demo.py b/examples/text_labels_and_annotations/fonts_demo.py index c9f23a722477..112c6b0b4ce9 100644 --- a/examples/text_labels_and_annotations/fonts_demo.py +++ b/examples/text_labels_and_annotations/fonts_demo.py @@ -11,89 +11,60 @@ from matplotlib.font_manager import FontProperties import matplotlib.pyplot as plt -font0 = FontProperties() +fig = plt.figure() alignment = {'horizontalalignment': 'center', 'verticalalignment': 'baseline'} -# Show family options - -families = ['serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'] - -font1 = font0.copy() -font1.set_size('large') - -t = plt.figtext(0.1, 0.9, 'family', fontproperties=font1, **alignment) - yp = [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2] +heading_font = FontProperties(size='large') +# Show family options +fig.text(0.1, 0.9, 'family', fontproperties=heading_font, **alignment) +families = ['serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'] for k, family in enumerate(families): - font = font0.copy() + font = FontProperties() font.set_family(family) - t = plt.figtext(0.1, yp[k], family, fontproperties=font, **alignment) + fig.text(0.1, yp[k], family, fontproperties=font, **alignment) # Show style options - styles = ['normal', 'italic', 'oblique'] - -t = plt.figtext(0.3, 0.9, 'style', fontproperties=font1, **alignment) - +fig.text(0.3, 0.9, 'style', fontproperties=heading_font, **alignment) for k, style in enumerate(styles): - font = font0.copy() + font = FontProperties() font.set_family('sans-serif') font.set_style(style) - t = plt.figtext(0.3, yp[k], style, fontproperties=font, **alignment) + fig.text(0.3, yp[k], style, fontproperties=font, **alignment) # Show variant options - variants = ['normal', 'small-caps'] - -t = plt.figtext(0.5, 0.9, 'variant', fontproperties=font1, **alignment) - +fig.text(0.5, 0.9, 'variant', fontproperties=heading_font, **alignment) for k, variant in enumerate(variants): - font = font0.copy() + font = FontProperties() font.set_family('serif') font.set_variant(variant) - t = plt.figtext(0.5, yp[k], variant, fontproperties=font, **alignment) + fig.text(0.5, yp[k], variant, fontproperties=font, **alignment) # Show weight options - weights = ['light', 'normal', 'medium', 'semibold', 'bold', 'heavy', 'black'] - -t = plt.figtext(0.7, 0.9, 'weight', fontproperties=font1, **alignment) - +fig.text(0.7, 0.9, 'weight', fontproperties=heading_font, **alignment) for k, weight in enumerate(weights): - font = font0.copy() + font = FontProperties() font.set_weight(weight) - t = plt.figtext(0.7, yp[k], weight, fontproperties=font, **alignment) + fig.text(0.7, yp[k], weight, fontproperties=font, **alignment) # Show size options - -sizes = ['xx-small', 'x-small', 'small', 'medium', 'large', - 'x-large', 'xx-large'] - -t = plt.figtext(0.9, 0.9, 'size', fontproperties=font1, **alignment) - +sizes = [ + 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'] +fig.text(0.9, 0.9, 'size', fontproperties=heading_font, **alignment) for k, size in enumerate(sizes): - font = font0.copy() + font = FontProperties() font.set_size(size) - t = plt.figtext(0.9, yp[k], size, fontproperties=font, **alignment) + fig.text(0.9, yp[k], size, fontproperties=font, **alignment) # Show bold italic - -font = font0.copy() -font.set_style('italic') -font.set_weight('bold') -font.set_size('x-small') -t = plt.figtext(0.3, 0.1, 'bold italic', fontproperties=font, **alignment) - -font = font0.copy() -font.set_style('italic') -font.set_weight('bold') -font.set_size('medium') -t = plt.figtext(0.3, 0.2, 'bold italic', fontproperties=font, **alignment) - -font = font0.copy() -font.set_style('italic') -font.set_weight('bold') -font.set_size('x-large') -t = plt.figtext(-0.4, 0.3, 'bold italic', fontproperties=font, **alignment) +font = FontProperties(style='italic', weight='bold', size='x-small') +fig.text(0.3, 0.1, 'bold italic', fontproperties=font, **alignment) +font = FontProperties(style='italic', weight='bold', size='medium') +fig.text(0.3, 0.2, 'bold italic', fontproperties=font, **alignment) +font = FontProperties(style='italic', weight='bold', size='x-large') +fig.text(0.3, 0.3, 'bold italic', fontproperties=font, **alignment) plt.show() diff --git a/examples/text_labels_and_annotations/fonts_demo_kw.py b/examples/text_labels_and_annotations/fonts_demo_kw.py index 17e2998e32b9..ca19222f878d 100644 --- a/examples/text_labels_and_annotations/fonts_demo_kw.py +++ b/examples/text_labels_and_annotations/fonts_demo_kw.py @@ -10,67 +10,47 @@ import matplotlib.pyplot as plt +fig = plt.figure() alignment = {'horizontalalignment': 'center', 'verticalalignment': 'baseline'} +yp = [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2] # Show family options - +fig.text(0.1, 0.9, 'family', size='large', **alignment) families = ['serif', 'sans-serif', 'cursive', 'fantasy', 'monospace'] - -t = plt.figtext(0.1, 0.9, 'family', size='large', **alignment) - -yp = [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2] - for k, family in enumerate(families): - t = plt.figtext(0.1, yp[k], family, family=family, **alignment) + fig.text(0.1, yp[k], family, family=family, **alignment) # Show style options - +fig.text(0.3, 0.9, 'style', **alignment) styles = ['normal', 'italic', 'oblique'] - -t = plt.figtext(0.3, 0.9, 'style', **alignment) - for k, style in enumerate(styles): - t = plt.figtext(0.3, yp[k], style, family='sans-serif', style=style, - **alignment) + fig.text(0.3, yp[k], style, family='sans-serif', style=style, **alignment) # Show variant options - +fig.text(0.5, 0.9, 'variant', **alignment) variants = ['normal', 'small-caps'] - -t = plt.figtext(0.5, 0.9, 'variant', **alignment) - for k, variant in enumerate(variants): - t = plt.figtext(0.5, yp[k], variant, family='serif', variant=variant, - **alignment) + fig.text(0.5, yp[k], variant, family='serif', variant=variant, **alignment) # Show weight options - +fig.text(0.7, 0.9, 'weight', **alignment) weights = ['light', 'normal', 'medium', 'semibold', 'bold', 'heavy', 'black'] - -t = plt.figtext(0.7, 0.9, 'weight', **alignment) - for k, weight in enumerate(weights): - t = plt.figtext(0.7, yp[k], weight, weight=weight, **alignment) + fig.text(0.7, yp[k], weight, weight=weight, **alignment) # Show size options - -sizes = ['xx-small', 'x-small', 'small', 'medium', 'large', - 'x-large', 'xx-large'] - -t = plt.figtext(0.9, 0.9, 'size', **alignment) - +fig.text(0.9, 0.9, 'size', **alignment) +sizes = [ + 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'] for k, size in enumerate(sizes): - t = plt.figtext(0.9, yp[k], size, size=size, **alignment) + fig.text(0.9, yp[k], size, size=size, **alignment) # Show bold italic -t = plt.figtext(0.3, 0.1, 'bold italic', style='italic', - weight='bold', size='x-small', - **alignment) -t = plt.figtext(0.3, 0.2, 'bold italic', - style='italic', weight='bold', size='medium', - **alignment) -t = plt.figtext(0.3, 0.3, 'bold italic', - style='italic', weight='bold', size='x-large', - **alignment) +fig.text(0.3, 0.1, 'bold italic', + style='italic', weight='bold', size='x-small', **alignment) +fig.text(0.3, 0.2, 'bold italic', + style='italic', weight='bold', size='medium', **alignment) +fig.text(0.3, 0.3, 'bold italic', + style='italic', weight='bold', size='x-large', **alignment) plt.show() diff --git a/examples/text_labels_and_annotations/legend_demo.py b/examples/text_labels_and_annotations/legend_demo.py index 74bdd245df80..11df93095fe4 100644 --- a/examples/text_labels_and_annotations/legend_demo.py +++ b/examples/text_labels_and_annotations/legend_demo.py @@ -45,9 +45,9 @@ # Plot the lines y=x**n for n=1..4. for n in range(1, 5): - ax0.plot(x, x**n, label="n={0}".format(n)) + ax0.plot(x, x**n, label=f"{n=}") leg = ax0.legend(loc="upper left", bbox_to_anchor=[0, 1], - ncol=2, shadow=True, title="Legend", fancybox=True) + ncols=2, shadow=True, title="Legend", fancybox=True) leg.get_title().set_color("red") # Demonstrate some more complex labels. @@ -116,7 +116,7 @@ plt.show() ############################################################################### -# Finally, it is also possible to write custom objects that define +# Finally, it is also possible to write custom classes that define # how to stylize legends. @@ -166,7 +166,6 @@ def create_artists(self, legend, orig_handle, fig, ax = plt.subplots() colors = plt.rcParams['axes.prop_cycle'].by_key()['color'][:5] styles = ['solid', 'dashed', 'dashed', 'dashed', 'solid'] -lines = [] for i, color, style in zip(range(5), colors, styles): ax.plot(x, np.sin(x) - .1 * i, c=color, ls=style) diff --git a/examples/text_labels_and_annotations/mathtext_examples.py b/examples/text_labels_and_annotations/mathtext_examples.py index b762ace5aa4a..838b68bb2ca2 100644 --- a/examples/text_labels_and_annotations/mathtext_examples.py +++ b/examples/text_labels_and_annotations/mathtext_examples.py @@ -89,9 +89,7 @@ def doall(): baseline = 1 - i_line * line_axesfrac baseline_next = baseline - line_axesfrac fill_color = ['white', 'tab:blue'][i_line % 2] - ax.fill_between([0, 1], [baseline, baseline], - [baseline_next, baseline_next], - color=fill_color, alpha=0.2) + ax.axhspan(baseline, baseline_next, color=fill_color, alpha=0.2) ax.annotate(f'{title}:', xy=(0.06, baseline - 0.3 * line_axesfrac), color=mpl_grey_rgb, weight='bold') diff --git a/examples/text_labels_and_annotations/rainbow_text.py b/examples/text_labels_and_annotations/rainbow_text.py index e66bef4b54af..9cec0bdc8f81 100644 --- a/examples/text_labels_and_annotations/rainbow_text.py +++ b/examples/text_labels_and_annotations/rainbow_text.py @@ -24,7 +24,7 @@ """ import matplotlib.pyplot as plt -from matplotlib.transforms import Affine2D +from matplotlib.transforms import Affine2D, offset_copy def rainbow_text(x, y, strings, colors, orientation='horizontal', @@ -51,7 +51,8 @@ def rainbow_text(x, y, strings, colors, orientation='horizontal', if ax is None: ax = plt.gca() t = ax.transData - canvas = ax.figure.canvas + fig = ax.figure + canvas = fig.canvas assert orientation in ['horizontal', 'vertical'] if orientation == 'vertical': @@ -63,10 +64,16 @@ def rainbow_text(x, y, strings, colors, orientation='horizontal', # Need to draw to update the text position. text.draw(canvas.get_renderer()) ex = text.get_window_extent() + # Convert window extent from pixels to inches + # to avoid issues displaying at different dpi + ex = fig.dpi_scale_trans.inverted().transform_bbox(ex) + if orientation == 'horizontal': - t = text.get_transform() + Affine2D().translate(ex.width, 0) + t = text.get_transform() + \ + offset_copy(Affine2D(), fig=fig, x=ex.width, y=0) else: - t = text.get_transform() + Affine2D().translate(0, ex.height) + t = text.get_transform() + \ + offset_copy(Affine2D(), fig=fig, x=0, y=ex.height) words = "all unicorns poop rainbows ! ! !".split() diff --git a/examples/text_labels_and_annotations/text_alignment.py b/examples/text_labels_and_annotations/text_alignment.py index 6ad79fb18fb8..a748481d5261 100644 --- a/examples/text_labels_and_annotations/text_alignment.py +++ b/examples/text_labels_and_annotations/text_alignment.py @@ -1,12 +1,50 @@ """ -=================== -Precise text layout -=================== +============== +Text alignment +============== + +Texts are aligned relative to their anchor point depending on the properties +``horizontalalignment`` and ``verticalalignment``. + +.. redirect-from:: /gallery/pyplots/text_layout + +.. plot:: + + import matplotlib.pyplot as plt + import numpy as np + + y = [0.22, 0.34, 0.5, 0.56, 0.78] + x = [0.17, 0.5, 0.855] + X, Y = np.meshgrid(x, y) + + fig, ax = plt.subplots(figsize=(6, 4), dpi=100) + ax.set(xlim=(0, 1), ylim=(0, 1), xticks=[], yticks=[]) + ax.spines[:].set_visible(False) + ax.text(0.5, 0.5, 'plot', fontsize=128, ha='center', va='center', zorder=1) + ax.hlines(y, x[0], x[-1], color='grey') + ax.vlines(x, y[0], y[-1], color='grey') + ax.plot(X.ravel(), Y.ravel(), 'o') + pad_x = 0.02 + pad_y = 0.04 + ax.text(x[0] - pad_x, y[0], 'bottom', ha='right', va='center') + ax.text(x[0] - pad_x, y[1], 'baseline', ha='right', va='center') + ax.text(x[0] - pad_x, y[2], 'center', ha='right', va='center') + ax.text(x[0] - pad_x, y[3], 'center_baseline', ha='right', va='center') + ax.text(x[0] - pad_x, y[4], 'top', ha='right', va='center') + ax.text(x[0], y[0] - pad_y, 'left', ha='center', va='top') + ax.text(x[1], y[0] - pad_y, 'center', ha='center', va='top') + ax.text(x[2], y[0] - pad_y, 'right', ha='center', va='top') + ax.set_xlabel('horizontalalignment', fontsize=14) + ax.set_ylabel('verticalalignment', fontsize=14, labelpad=35) + ax.set_title( + 'Relative position of text anchor point depending on alignment') + plt.show() -You can precisely layout text in data or axes coordinates. This example shows -you some of the alignment and rotation specifications for text layout. """ +############################################################################# +# The following plot uses this to align text relative to a plotted rectangle. + import matplotlib.pyplot as plt fig, ax = plt.subplots() diff --git a/examples/pyplots/text_commands.py b/examples/text_labels_and_annotations/text_commands.py similarity index 92% rename from examples/pyplots/text_commands.py rename to examples/text_labels_and_annotations/text_commands.py index 8311f2e3613e..cb6d5413f8d4 100644 --- a/examples/pyplots/text_commands.py +++ b/examples/text_labels_and_annotations/text_commands.py @@ -4,6 +4,8 @@ ============= Plotting text of many different kinds. + +.. redirect-from:: /gallery/pyplots/text_commands """ import matplotlib.pyplot as plt @@ -23,7 +25,7 @@ ax.text(2, 6, r'an equation: $E=mc^2$', fontsize=15) -ax.text(3, 2, 'unicode: Institut f\374r Festk\366rperphysik') +ax.text(3, 2, 'Unicode: Institut f\374r Festk\366rperphysik') ax.text(0.95, 0.01, 'colored text in axes coords', verticalalignment='bottom', horizontalalignment='right', diff --git a/examples/text_labels_and_annotations/text_rotation.py b/examples/text_labels_and_annotations/text_rotation.py deleted file mode 100644 index a380fc324bed..000000000000 --- a/examples/text_labels_and_annotations/text_rotation.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -=================================== -Default text rotation demonstration -=================================== - -The way Matplotlib does text layout by default is counter-intuitive to some, so -this example is designed to make it a little clearer. - -The text is aligned by its bounding box (the rectangular box that surrounds the -ink rectangle). The order of operations is rotation then alignment. -Basically, the text is centered at your (x, y) location, rotated around this -point, and then aligned according to the bounding box of the rotated text. - -So if you specify left, bottom alignment, the bottom left of the -bounding box of the rotated text will be at the (x, y) coordinate of the text. - -But a picture is worth a thousand words! -""" - -import matplotlib.pyplot as plt -import numpy as np - - -def addtext(ax, props): - ax.text(0.5, 0.5, 'text 0', props, rotation=0) - ax.text(1.5, 0.5, 'text 45', props, rotation=45) - ax.text(2.5, 0.5, 'text 135', props, rotation=135) - ax.text(3.5, 0.5, 'text 225', props, rotation=225) - ax.text(4.5, 0.5, 'text -45', props, rotation=-45) - for x in range(0, 5): - ax.scatter(x + 0.5, 0.5, color='r', alpha=0.5) - ax.set_yticks([0, .5, 1]) - ax.set_xticks(np.arange(0, 5.1, 0.5)) - ax.set_xlim(0, 5) - ax.grid(True) - - -# the text bounding box -bbox = {'fc': '0.8', 'pad': 0} - -fig, axs = plt.subplots(2, 1, sharex=True) - -addtext(axs[0], {'ha': 'center', 'va': 'center', 'bbox': bbox}) -axs[0].set_ylabel('center / center') - -addtext(axs[1], {'ha': 'left', 'va': 'bottom', 'bbox': bbox}) -axs[1].set_ylabel('left / bottom') - -plt.show() diff --git a/examples/text_labels_and_annotations/usetex_fonteffects.py b/examples/text_labels_and_annotations/usetex_fonteffects.py index 2b14a6336438..a289f3854ed7 100644 --- a/examples/text_labels_and_annotations/usetex_fonteffects.py +++ b/examples/text_labels_and_annotations/usetex_fonteffects.py @@ -15,12 +15,13 @@ def setfont(font): fig = plt.figure() -for y, font, text in zip(range(5), - ['ptmr8r', 'ptmri8r', 'ptmro8r', - 'ptmr8rn', 'ptmrr8re'], - ['Nimbus Roman No9 L ' + x for x in - ['', 'Italics (real italics for comparison)', - '(slanted)', '(condensed)', '(extended)']]): +for y, font, text in zip( + range(5), + ['ptmr8r', 'ptmri8r', 'ptmro8r', 'ptmr8rn', 'ptmrr8re'], + [f'Nimbus Roman No9 L {x}' + for x in ['', 'Italics (real italics for comparison)', + '(slanted)', '(condensed)', '(extended)']], +): fig.text(.1, 1 - (y + 1) / 6, setfont(font) + text, usetex=True) fig.suptitle('Usetex font effects') diff --git a/examples/text_labels_and_annotations/watermark_text.py b/examples/text_labels_and_annotations/watermark_text.py index 3c1b5c06f99a..200a3c8dac15 100644 --- a/examples/text_labels_and_annotations/watermark_text.py +++ b/examples/text_labels_and_annotations/watermark_text.py @@ -18,7 +18,7 @@ ax.text(0.5, 0.5, 'created with matplotlib', transform=ax.transAxes, fontsize=40, color='gray', alpha=0.5, - ha='center', va='center', rotation='30') + ha='center', va='center', rotation=30) plt.show() diff --git a/examples/ticks/centered_ticklabels.py b/examples/ticks/centered_ticklabels.py index 61431e3ab21c..11d3f0e8a9bc 100644 --- a/examples/ticks/centered_ticklabels.py +++ b/examples/ticks/centered_ticklabels.py @@ -7,10 +7,10 @@ 'center', 'left', or 'right' can be controlled using the horizontal alignment property:: - for label in ax.xaxis.get_xticklabels(): + for label in ax.get_xticklabels(): label.set_horizontalalignment('right') -However there is no direct way to center the labels between ticks. To fake +However, there is no direct way to center the labels between ticks. To fake this behavior, one can place a label on the minor ticks in between the major ticks, and hide the major tick labels and minor ticks. @@ -23,7 +23,7 @@ import matplotlib.ticker as ticker import matplotlib.pyplot as plt -# load some financial data; Google's stock price +# Load some financial data; Google's stock price r = (cbook.get_sample_data('goog.npz', np_load=True)['price_data'] .view(np.recarray)) r = r[-250:] # get the last 250 days @@ -38,11 +38,12 @@ ax.xaxis.set_major_formatter(ticker.NullFormatter()) ax.xaxis.set_minor_formatter(dates.DateFormatter('%b')) -for tick in ax.xaxis.get_minor_ticks(): - tick.tick1line.set_markersize(0) - tick.tick2line.set_markersize(0) - tick.label1.set_horizontalalignment('center') +# Remove the tick lines +ax.tick_params(axis='x', which='minor', tick1On=False, tick2On=False) +# Align the minor tick label +for label in ax.get_xticklabels(minor=True): + label.set_horizontalalignment('center') imid = len(r) // 2 ax.set_xlabel(str(r.date[imid].item().year)) plt.show() diff --git a/examples/ticks/custom_ticker1.py b/examples/ticks/custom_ticker1.py index ee088fa4d5af..53b6c32e0789 100644 --- a/examples/ticks/custom_ticker1.py +++ b/examples/ticks/custom_ticker1.py @@ -1,28 +1,27 @@ """ -============== -Custom Ticker1 -============== +============= +Custom Ticker +============= -The new ticker code was designed to explicitly support user customized -ticking. The documentation of :mod:`matplotlib.ticker` details this -process. That code defines a lot of preset tickers but was primarily -designed to be user extensible. +The :mod:`matplotlib.ticker` module defines many preset tickers, but was +primarily designed for extensibility, i.e., to support user customized ticking. -In this example a user defined function is used to format the ticks in -millions of dollars on the y axis. +In this example, a user defined function is used to format the ticks in +millions of dollars on the y-axis. """ -import matplotlib.pyplot as plt -money = [1.5e5, 2.5e6, 5.5e6, 2.0e7] +import matplotlib.pyplot as plt def millions(x, pos): """The two arguments are the value and tick position.""" - return '${:1.1f}M'.format(x*1e-6) + return f'${x*1e-6:1.1f}M' + fig, ax = plt.subplots() -# Use automatic FuncFormatter creation +# set_major_formatter internally creates a FuncFormatter from the callable. ax.yaxis.set_major_formatter(millions) +money = [1.5e5, 2.5e6, 5.5e6, 2.0e7] ax.bar(['Bill', 'Fred', 'Mary', 'Sue'], money) plt.show() @@ -33,6 +32,4 @@ def millions(x, pos): # The use of the following functions, methods, classes and modules is shown # in this example: # -# - `matplotlib.pyplot.subplots` # - `matplotlib.axis.Axis.set_major_formatter` -# - `matplotlib.ticker.FuncFormatter` diff --git a/examples/ticks/date_concise_formatter.py b/examples/ticks/date_concise_formatter.py index 7a7089ac839d..979214b3b406 100644 --- a/examples/ticks/date_concise_formatter.py +++ b/examples/ticks/date_concise_formatter.py @@ -12,7 +12,7 @@ This formatter is a candidate to become the default date tick formatter in future versions of Matplotlib. Please report any issues or - suggestions for improvement to the github repository or mailing list. + suggestions for improvement to the GitHub repository or mailing list. """ import datetime @@ -156,7 +156,7 @@ '%S.%f', ] # secs # these can be the same, except offset by one level.... zero_formats = [''] + formats[:-1] -# ...except for ticks that are mostly hours, then its nice to have month-day +# ...except for ticks that are mostly hours, then it's nice to have month-day zero_formats[3] = '%d-%b' offset_formats = ['', '%Y', diff --git a/examples/ticks/date_formatters_locators.py b/examples/ticks/date_formatters_locators.py new file mode 100644 index 000000000000..9d765c238470 --- /dev/null +++ b/examples/ticks/date_formatters_locators.py @@ -0,0 +1,92 @@ +""" +================================= +Date tick locators and formatters +================================= + +This example illustrates the usage and effect of the various date locators and +formatters. +""" + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.ticker as ticker +from matplotlib.dates import (AutoDateLocator, YearLocator, MonthLocator, + DayLocator, WeekdayLocator, HourLocator, + MinuteLocator, SecondLocator, MicrosecondLocator, + RRuleLocator, rrulewrapper, MONTHLY, + MO, TU, WE, TH, FR, SA, SU, DateFormatter, + AutoDateFormatter, ConciseDateFormatter) + +locators = [ + ('AutoDateLocator(maxticks=8)', '2003-02-01', '%Y-%m'), + ('YearLocator(month=4)', '2003-02-01', '%Y-%m'), + ('MonthLocator(bymonth=[4,8,12])', '2003-02-01', '%Y-%m'), + ('DayLocator(interval=180)', '2003-02-01', '%Y-%m-%d'), + ('WeekdayLocator(byweekday=SU, interval=4)', '2000-07-01', '%a %Y-%m-%d'), + ('HourLocator(byhour=range(0,24,6))', '2000-02-04', '%H h'), + ('MinuteLocator(interval=15)', '2000-02-01 02:00', '%H:%M'), + ('SecondLocator(bysecond=(0,30))', '2000-02-01 00:02', '%H:%M:%S'), + ('MicrosecondLocator(interval=1000)', '2000-02-01 00:00:00.005', '%S.%f'), + ('RRuleLocator(rrulewrapper(freq=MONTHLY, \nbyweekday=(MO, TU, WE, TH,' + + ' FR), bysetpos=-1))', '2000-07-01', '%Y-%m-%d') +] + +formatters = [ + ('AutoDateFormatter(ax.xaxis.get_major_locator())'), + ('ConciseDateFormatter(ax.xaxis.get_major_locator())'), + ('DateFormatter("%b %Y")') +] + + +def plot_axis(ax, locator=None, xmax='2002-02-01', fmt=None, formatter=None): + """Set up common parameters for the Axes in the example.""" + ax.spines.right.set_visible(False) + ax.spines.left.set_visible(False) + ax.spines.top.set_visible(False) + ax.yaxis.set_major_locator(ticker.NullLocator()) + ax.tick_params(which='major', width=1.00, length=5) + ax.tick_params(which='minor', width=0.75, length=2.5) + ax.set_xlim(np.datetime64('2000-02-01'), np.datetime64(xmax)) + if locator: + ax.xaxis.set_major_locator(eval(locator)) + ax.xaxis.set_major_formatter(DateFormatter(fmt)) + else: + ax.xaxis.set_major_formatter(eval(formatter)) + ax.text(0.0, 0.2, locator or formatter, transform=ax.transAxes, + fontsize=14, fontname='Monospace', color='tab:blue') + + +fig, ax = plt.subplots(len(locators), 1, figsize=(8, len(locators) * .8), + layout='constrained') +fig.suptitle('Date Locators') +for i, loc in enumerate(locators): + plot_axis(ax[i], *loc) + +fig, ax = plt.subplots(len(formatters), 1, figsize=(8, len(formatters) * .8), + layout='constrained') +fig.suptitle('Date Formatters') +for i, fmt in enumerate(formatters): + plot_axis(ax[i], formatter=fmt) + + +############################################################################# +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.dates.AutoDateLocator` +# - `matplotlib.dates.YearLocator` +# - `matplotlib.dates.MonthLocator` +# - `matplotlib.dates.DayLocator` +# - `matplotlib.dates.WeekdayLocator` +# - `matplotlib.dates.HourLocator` +# - `matplotlib.dates.MinuteLocator` +# - `matplotlib.dates.SecondLocator` +# - `matplotlib.dates.MicrosecondLocator` +# - `matplotlib.dates.RRuleLocator` +# - `matplotlib.dates.rrulewrapper` +# - `matplotlib.dates.DateFormatter` +# - `matplotlib.dates.AutoDateFormatter` +# - `matplotlib.dates.ConciseDateFormatter` diff --git a/examples/ticks/date_index_formatter.py b/examples/ticks/date_index_formatter.py new file mode 100644 index 000000000000..a2bf4b8796f9 --- /dev/null +++ b/examples/ticks/date_index_formatter.py @@ -0,0 +1,83 @@ +""" +===================================== +Custom tick formatter for time series +===================================== + +.. redirect-from:: /gallery/text_labels_and_annotations/date_index_formatter +.. redirect-from:: /gallery/ticks/date_index_formatter2 + +When plotting daily data, e.g., financial time series, one often wants +to leave out days on which there is no data, for instance weekends, so that +the data are plotted at regular intervals without extra spaces for the days +with no data. +The example shows how to use an 'index formatter' to achieve the desired plot. +""" + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.cbook as cbook +import matplotlib.lines as ml +from matplotlib.dates import DateFormatter, DayLocator +from matplotlib.ticker import Formatter + + +# Load a numpy record array from yahoo csv data with fields date, open, high, +# low, close, volume, adj_close from the mpl-data/sample_data directory. The +# record array stores the date as an np.datetime64 with a day unit ('D') in +# the date column (``r.date``). +r = (cbook.get_sample_data('goog.npz', np_load=True)['price_data'] + .view(np.recarray)) +r = r[:9] # get the first 9 days + +fig, (ax1, ax2) = plt.subplots(nrows=2, figsize=(6, 6), + constrained_layout={'hspace': .15}) + +# First we'll do it the default way, with gaps on weekends +ax1.plot(r.date, r.adj_close, 'o-') + +# Highlight gaps in daily data +gaps = np.flatnonzero(np.diff(r.date) > np.timedelta64(1, 'D')) +for gap in r[['date', 'adj_close']][np.stack((gaps, gaps + 1)).T]: + ax1.plot(gap.date, gap.adj_close, 'w--', lw=2) +ax1.legend(handles=[ml.Line2D([], [], ls='--', label='Gaps in daily data')]) + +ax1.set_title("Plot y at x Coordinates") +ax1.xaxis.set_major_locator(DayLocator()) +ax1.xaxis.set_major_formatter(DateFormatter('%a')) + + +# Next we'll write a custom index formatter. Below we will plot +# the data against an index that goes from 0, 1, ... len(data). Instead of +# formatting the tick marks as integers, we format as times. +def format_date(x, _): + try: + # convert datetime64 to datetime, and use datetime's strftime: + return r.date[round(x)].item().strftime('%a') + except IndexError: + pass + +# Create an index plot (x defaults to range(len(y)) if omitted) +ax2.plot(r.adj_close, 'o-') + +ax2.set_title("Plot y at Index Coordinates Using Custom Formatter") +ax2.xaxis.set_major_formatter(format_date) # internally creates FuncFormatter + +############################################################################# +# Instead of passing a function into `.Axis.set_major_formatter` you can use +# any other callable, e.g. an instance of a class that implements __call__: + + +class MyFormatter(Formatter): + def __init__(self, dates, fmt='%a'): + self.dates = dates + self.fmt = fmt + + def __call__(self, x, pos=0): + """Return the label for time x at position pos.""" + try: + return self.dates[round(x)].item().strftime(self.fmt) + except IndexError: + pass + + +ax2.xaxis.set_major_formatter(MyFormatter(r.date, '%a')) diff --git a/examples/ticks/date_index_formatter2.py b/examples/ticks/date_index_formatter2.py deleted file mode 100644 index 38c9038812ae..000000000000 --- a/examples/ticks/date_index_formatter2.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -==================== -Date Index Formatter -==================== - -When plotting daily data, a frequent request is to plot the data -ignoring skips, e.g., no extra spaces for weekends. This is particularly -common in financial time series, when you may have data for M-F and -not Sat, Sun and you don't want gaps in the x axis. The approach is -to simply use the integer index for the xdata and a custom tick -Formatter to get the appropriate date string for a given index. -""" - -import dateutil.parser -from matplotlib import cbook, dates -import matplotlib.pyplot as plt -from matplotlib.ticker import Formatter -import numpy as np - - -datafile = cbook.get_sample_data('msft.csv', asfileobj=False) -print('loading %s' % datafile) -msft_data = np.genfromtxt( - datafile, delimiter=',', names=True, - converters={0: lambda s: dates.date2num(dateutil.parser.parse(s))}) - - -class MyFormatter(Formatter): - def __init__(self, dates, fmt='%Y-%m-%d'): - self.dates = dates - self.fmt = fmt - - def __call__(self, x, pos=0): - """Return the label for time x at position pos.""" - ind = int(round(x)) - if ind >= len(self.dates) or ind < 0: - return '' - return dates.num2date(self.dates[ind]).strftime(self.fmt) - - -fig, ax = plt.subplots() -ax.xaxis.set_major_formatter(MyFormatter(msft_data['Date'])) -ax.plot(msft_data['Close'], 'o-') -fig.autofmt_xdate() -plt.show() diff --git a/examples/pyplots/dollar_ticks.py b/examples/ticks/dollar_ticks.py similarity index 92% rename from examples/pyplots/dollar_ticks.py rename to examples/ticks/dollar_ticks.py index 1f99ccdd674d..b8fe95e7acd1 100644 --- a/examples/pyplots/dollar_ticks.py +++ b/examples/ticks/dollar_ticks.py @@ -1,9 +1,11 @@ """ ============ -Dollar Ticks +Dollar ticks ============ -Use a `~.ticker.FormatStrFormatter` to prepend dollar signs on y axis labels. +Use a `~.ticker.FormatStrFormatter` to prepend dollar signs on y-axis labels. + +.. redirect-from:: /gallery/pyplots/dollar_ticks """ import numpy as np import matplotlib.pyplot as plt diff --git a/examples/pyplots/fig_axes_customize_simple.py b/examples/ticks/fig_axes_customize_simple.py similarity index 76% rename from examples/pyplots/fig_axes_customize_simple.py rename to examples/ticks/fig_axes_customize_simple.py index 691a9cef26ed..74742f718939 100644 --- a/examples/pyplots/fig_axes_customize_simple.py +++ b/examples/ticks/fig_axes_customize_simple.py @@ -4,6 +4,8 @@ ========================= Customize the background, labels and ticks of a simple plot. + +.. redirect-from:: /gallery/pyplots/fig_axes_customize_simple """ import matplotlib.pyplot as plt @@ -19,18 +21,8 @@ rect = ax1.patch rect.set_facecolor('lightslategray') - -for label in ax1.xaxis.get_ticklabels(): - # label is a Text instance - label.set_color('tab:red') - label.set_rotation(45) - label.set_fontsize(16) - -for line in ax1.yaxis.get_ticklines(): - # line is a Line2D instance - line.set_color('tab:green') - line.set_markersize(25) - line.set_markeredgewidth(3) +ax1.tick_params(axis='x', labelcolor='tab:red', labelrotation=45, labelsize=16) +ax1.tick_params(axis='y', color='tab:green', size=25, width=3) plt.show() @@ -47,7 +39,7 @@ # - `matplotlib.text.Text.set_fontsize` # - `matplotlib.text.Text.set_color` # - `matplotlib.lines.Line2D` -# - `matplotlib.lines.Line2D.set_color` +# - `matplotlib.lines.Line2D.set_markeredgecolor` # - `matplotlib.lines.Line2D.set_markersize` # - `matplotlib.lines.Line2D.set_markeredgewidth` # - `matplotlib.patches.Patch.set_facecolor` diff --git a/examples/ticks/major_minor_demo.py b/examples/ticks/major_minor_demo.py index 26d9a2ce5844..c139ff4741e4 100644 --- a/examples/ticks/major_minor_demo.py +++ b/examples/ticks/major_minor_demo.py @@ -21,9 +21,9 @@ `.Axis.set_minor_formatter`. An appropriate `.StrMethodFormatter` will be created and used automatically. -`.pyplot.grid` changes the grid settings of the major ticks of the y and y axis -together. If you want to control the grid of the minor ticks for a given axis, -use for example :: +`.pyplot.grid` changes the grid settings of the major ticks of the x- and +y-axis together. If you want to control the grid of the minor ticks for a +given axis, use for example :: ax.xaxis.grid(True, which='minor') diff --git a/examples/ticks/tick-formatters.py b/examples/ticks/tick-formatters.py index ea9dc214fbb0..0f5c8f38d411 100644 --- a/examples/ticks/tick-formatters.py +++ b/examples/ticks/tick-formatters.py @@ -7,6 +7,7 @@ is formatted as a string. This example illustrates the usage and effect of the most common formatters. + """ import matplotlib.pyplot as plt @@ -17,9 +18,7 @@ def setup(ax, title): """Set up common parameters for the Axes in the example.""" # only show the bottom spine ax.yaxis.set_major_locator(ticker.NullLocator()) - ax.spines.right.set_color('none') - ax.spines.left.set_color('none') - ax.spines.top.set_color('none') + ax.spines[['left', 'right', 'top']].set_visible(False) # define tick positions ax.xaxis.set_major_locator(ticker.MultipleLocator(1.00)) diff --git a/examples/ticks/tick-locators.py b/examples/ticks/tick-locators.py index eeac29a07ad2..1832f382529e 100644 --- a/examples/ticks/tick-locators.py +++ b/examples/ticks/tick-locators.py @@ -17,9 +17,7 @@ def setup(ax, title): """Set up common parameters for the Axes in the example.""" # only show the bottom spine ax.yaxis.set_major_locator(ticker.NullLocator()) - ax.spines.right.set_color('none') - ax.spines.left.set_color('none') - ax.spines.top.set_color('none') + ax.spines[['left', 'right', 'top']].set_visible(False) ax.xaxis.set_ticks_position('bottom') ax.tick_params(which='major', width=1.00, length=5) diff --git a/examples/ticks/tick_label_right.py b/examples/ticks/tick_label_right.py index f49492e93bf1..79eccd8777e7 100644 --- a/examples/ticks/tick_label_right.py +++ b/examples/ticks/tick_label_right.py @@ -3,10 +3,9 @@ Set default y-axis tick labels on the right ============================================ -We can use :rc:`ytick.labelright` (default False) and :rc:`ytick.right` -(default False) and :rc:`ytick.labelleft` (default True) and :rc:`ytick.left` -(default True) to control where on the axes ticks and their labels appear. -These properties can also be set in the ``.matplotlib/matplotlibrc``. +We can use :rc:`ytick.labelright`, :rc:`ytick.right`, :rc:`ytick.labelleft`, +and :rc:`ytick.left` to control where on the axes ticks and their labels +appear. These properties can also be set in ``.matplotlib/matplotlibrc``. """ import matplotlib.pyplot as plt diff --git a/examples/units/bar_unit_demo.py b/examples/units/bar_unit_demo.py index 7b24a453154d..b64784cc5c07 100644 --- a/examples/units/bar_unit_demo.py +++ b/examples/units/bar_unit_demo.py @@ -18,21 +18,21 @@ N = 5 -men_means = [150*cm, 160*cm, 146*cm, 172*cm, 155*cm] -men_std = [20*cm, 30*cm, 32*cm, 10*cm, 20*cm] +tea_means = [15*cm, 10*cm, 8*cm, 12*cm, 5*cm] +tea_std = [2*cm, 1*cm, 1*cm, 4*cm, 2*cm] fig, ax = plt.subplots() ind = np.arange(N) # the x locations for the groups width = 0.35 # the width of the bars -ax.bar(ind, men_means, width, bottom=0*cm, yerr=men_std, label='Men') +ax.bar(ind, tea_means, width, bottom=0*cm, yerr=tea_std, label='Tea') -women_means = (145*cm, 149*cm, 172*cm, 165*cm, 200*cm) -women_std = (30*cm, 25*cm, 20*cm, 31*cm, 22*cm) -ax.bar(ind + width, women_means, width, bottom=0*cm, yerr=women_std, - label='Women') +coffee_means = (14*cm, 19*cm, 7*cm, 5*cm, 10*cm) +coffee_std = (3*cm, 5*cm, 2*cm, 1*cm, 2*cm) +ax.bar(ind + width, coffee_means, width, bottom=0*cm, yerr=coffee_std, + label='Coffee') -ax.set_title('Scores by group and gender') +ax.set_title('Cup height by group and beverage choice') ax.set_xticks(ind + width / 2, labels=['G1', 'G2', 'G3', 'G4', 'G5']) ax.legend() diff --git a/examples/units/basic_units.py b/examples/units/basic_units.py index 39b8a5f1f899..95df58ccf0c4 100644 --- a/examples/units/basic_units.py +++ b/examples/units/basic_units.py @@ -132,6 +132,9 @@ def __init__(self, value, unit): self.unit = unit self.proxy_target = self.value + def __copy__(self): + return TaggedValue(self.value, self.unit) + def __getattribute__(self, name): if name.startswith('__'): return object.__getattribute__(self, name) @@ -141,16 +144,16 @@ def __getattribute__(self, name): return object.__getattribute__(self, name) def __array__(self, dtype=object): - return np.asarray(self.value).astype(dtype) + return np.asarray(self.value, dtype) def __array_wrap__(self, array, context): return TaggedValue(array, self.unit) def __repr__(self): - return 'TaggedValue({!r}, {!r})'.format(self.value, self.unit) + return f'TaggedValue({self.value!r}, {self.unit!r})' def __str__(self): - return str(self.value) + ' in ' + str(self.unit) + return f"{self.value} in {self.unit}" def __len__(self): return len(self.value) diff --git a/examples/units/ellipse_with_units.py b/examples/units/ellipse_with_units.py index 2a2242cde30c..369ad5cf4e68 100644 --- a/examples/units/ellipse_with_units.py +++ b/examples/units/ellipse_with_units.py @@ -1,14 +1,15 @@ """ ================== -Ellipse With Units +Ellipse with units ================== -Compare the ellipse generated with arcs versus a polygonal approximation +Compare the ellipse generated with arcs versus a polygonal approximation. .. only:: builder_html This example requires :download:`basic_units.py ` """ + from basic_units import cm import numpy as np from matplotlib import patches diff --git a/examples/units/units_sample.py b/examples/units/units_sample.py index db39ee73dbe9..81547601e711 100644 --- a/examples/units/units_sample.py +++ b/examples/units/units_sample.py @@ -19,14 +19,14 @@ cms = cm * np.arange(0, 10, 2) -fig, axs = plt.subplots(2, 2) +fig, axs = plt.subplots(2, 2, constrained_layout=True) axs[0, 0].plot(cms, cms) axs[0, 1].plot(cms, cms, xunits=cm, yunits=inch) axs[1, 0].plot(cms, cms, xunits=inch, yunits=cm) -axs[1, 0].set_xlim(3, 6) # scalars are interpreted in current units +axs[1, 0].set_xlim(-1, 4) # scalars are interpreted in current units axs[1, 1].plot(cms, cms, xunits=inch, yunits=inch) axs[1, 1].set_xlim(3*cm, 6*cm) # cm are converted to inches diff --git a/examples/user_interfaces/README.txt b/examples/user_interfaces/README.txt index d526adc9d65d..75b469da7cf6 100644 --- a/examples/user_interfaces/README.txt +++ b/examples/user_interfaces/README.txt @@ -8,6 +8,6 @@ following the embedding_in_SOMEGUI.py examples here. Currently Matplotlib supports PyQt/PySide, PyGObject, Tkinter, and wxPython. When embedding Matplotlib in a GUI, you must use the Matplotlib API -directly rather than the pylab/pyplot proceedural interface, so take a +directly rather than the pylab/pyplot procedural interface, so take a look at the examples/api directory for some example code working with the API. diff --git a/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py b/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py index 95d8df21a3a2..7009b55bada7 100644 --- a/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py +++ b/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py @@ -36,7 +36,7 @@ vbox.pack_start(canvas, True, True, 0) # Create toolbar -toolbar = NavigationToolbar(canvas, win) +toolbar = NavigationToolbar(canvas) vbox.pack_start(toolbar, False, False, 0) win.show_all() diff --git a/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py b/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py index 685a278fc7ad..b132aff1074f 100644 --- a/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py +++ b/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py @@ -39,7 +39,7 @@ def on_activate(app): vbox.append(canvas) # Create toolbar - toolbar = NavigationToolbar(canvas, win) + toolbar = NavigationToolbar(canvas) vbox.append(toolbar) win.show() diff --git a/examples/user_interfaces/embedding_in_qt_sgskip.py b/examples/user_interfaces/embedding_in_qt_sgskip.py index f276a2a47c16..32529739ac14 100644 --- a/examples/user_interfaces/embedding_in_qt_sgskip.py +++ b/examples/user_interfaces/embedding_in_qt_sgskip.py @@ -5,8 +5,8 @@ Simple Qt application embedding Matplotlib canvases. This program will work equally well using any Qt binding (PyQt6, PySide6, PyQt5, PySide2). The -binding can be selected by setting the ``QT_API`` environment variable to the -binding name, or by first importing it. +binding can be selected by setting the :envvar:`QT_API` environment variable to +the binding name, or by first importing it. """ import sys diff --git a/examples/user_interfaces/embedding_in_tk_sgskip.py b/examples/user_interfaces/embedding_in_tk_sgskip.py index 176af735c3ae..55fb2b48b5d6 100644 --- a/examples/user_interfaces/embedding_in_tk_sgskip.py +++ b/examples/user_interfaces/embedding_in_tk_sgskip.py @@ -37,7 +37,7 @@ "key_press_event", lambda event: print(f"you pressed {event.key}")) canvas.mpl_connect("key_press_event", key_press_handler) -button_quit = tkinter.Button(master=root, text="Quit", command=root.quit) +button_quit = tkinter.Button(master=root, text="Quit", command=root.destroy) def update_frequency(new_val): @@ -62,6 +62,6 @@ def update_frequency(new_val): button_quit.pack(side=tkinter.BOTTOM) slider_update.pack(side=tkinter.BOTTOM) toolbar.pack(side=tkinter.BOTTOM, fill=tkinter.X) -canvas.get_tk_widget().pack(side=tkinter.TOP, fill=tkinter.BOTH, expand=1) +canvas.get_tk_widget().pack(side=tkinter.TOP, fill=tkinter.BOTH, expand=True) tkinter.mainloop() diff --git a/examples/user_interfaces/embedding_in_wx5_sgskip.py b/examples/user_interfaces/embedding_in_wx5_sgskip.py index 2759723687ed..c3c3d91f7368 100644 --- a/examples/user_interfaces/embedding_in_wx5_sgskip.py +++ b/examples/user_interfaces/embedding_in_wx5_sgskip.py @@ -9,7 +9,7 @@ import wx.lib.agw.aui as aui import wx.lib.mixins.inspection as wit -import matplotlib as mpl +from matplotlib.figure import Figure from matplotlib.backends.backend_wxagg import ( FigureCanvasWxAgg as FigureCanvas, NavigationToolbar2WxAgg as NavigationToolbar) @@ -18,7 +18,7 @@ class Plot(wx.Panel): def __init__(self, parent, id=-1, dpi=None, **kwargs): super().__init__(parent, id=id, **kwargs) - self.figure = mpl.figure.Figure(dpi=dpi, figsize=(2, 2)) + self.figure = Figure(dpi=dpi, figsize=(2, 2)) self.canvas = FigureCanvas(self, -1, self.figure) self.toolbar = NavigationToolbar(self.canvas) self.toolbar.Realize() diff --git a/examples/user_interfaces/embedding_webagg_sgskip.py b/examples/user_interfaces/embedding_webagg_sgskip.py index d3a313c6b9ef..15a2dd95b3ba 100644 --- a/examples/user_interfaces/embedding_webagg_sgskip.py +++ b/examples/user_interfaces/embedding_webagg_sgskip.py @@ -11,10 +11,13 @@ The framework being used must support web sockets. """ +import argparse import io import json import mimetypes from pathlib import Path +import signal +import socket try: import tornado @@ -27,7 +30,7 @@ import matplotlib as mpl -from matplotlib.backends.backend_webagg_core import ( +from matplotlib.backends.backend_webagg import ( FigureManagerWebAgg, new_figure_manager_given_figure) from matplotlib.figure import Figure @@ -49,16 +52,15 @@ def create_figure(): # The following is the content of the web page. You would normally # generate this using some sort of template facility in your web # framework, but here we just use Python string formatting. -html_content = """ - +html_content = """ + - - + + @@ -120,7 +122,7 @@ class MainPage(tornado.web.RequestHandler): def get(self): manager = self.application.manager - ws_uri = "ws://{req.host}/".format(req=self.request) + ws_uri = f"ws://{self.request.host}/" content = html_content % { "ws_uri": ws_uri, "fig_id": manager.num} self.write(content) @@ -204,8 +206,8 @@ def send_binary(self, blob): if self.supports_binary: self.write_message(blob, binary=True) else: - data_uri = "data:image/png;base64,{0}".format( - blob.encode('base64').replace('\n', '')) + data_uri = ("data:image/png;base64," + + blob.encode('base64').replace('\n', '')) self.write_message(data_uri) def __init__(self, figure): @@ -238,13 +240,36 @@ def __init__(self, figure): if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument('-p', '--port', type=int, default=8080, + help='Port to listen on (0 for a random port).') + args = parser.parse_args() + figure = create_figure() application = MyApplication(figure) http_server = tornado.httpserver.HTTPServer(application) - http_server.listen(8080) - - print("http://127.0.0.1:8080/") + sockets = tornado.netutil.bind_sockets(args.port, '') + http_server.add_sockets(sockets) + + for s in sockets: + addr, port = s.getsockname()[:2] + if s.family is socket.AF_INET6: + addr = f'[{addr}]' + print(f"Listening on http://{addr}:{port}/") print("Press Ctrl+C to quit") - tornado.ioloop.IOLoop.instance().start() + ioloop = tornado.ioloop.IOLoop.instance() + + def shutdown(): + ioloop.stop() + print("Server stopped") + + old_handler = signal.signal( + signal.SIGINT, + lambda sig, frame: ioloop.add_callback_from_signal(shutdown)) + + try: + ioloop.start() + finally: + signal.signal(signal.SIGINT, old_handler) diff --git a/examples/user_interfaces/fourier_demo_wx_sgskip.py b/examples/user_interfaces/fourier_demo_wx_sgskip.py index 86441ed0c838..392fbb10f9a3 100644 --- a/examples/user_interfaces/fourier_demo_wx_sgskip.py +++ b/examples/user_interfaces/fourier_demo_wx_sgskip.py @@ -75,10 +75,10 @@ def __init__(self, parent, label, param): sizer = wx.BoxSizer(wx.HORIZONTAL) sizer.Add(self.sliderLabel, 0, - wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, + wx.EXPAND | wx.ALL, border=2) sizer.Add(self.sliderText, 0, - wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, + wx.EXPAND | wx.ALL, border=2) sizer.Add(self.slider, 1, wx.EXPAND) self.sizer = sizer @@ -98,7 +98,7 @@ def sliderTextHandler(self, event): self.param.set(value) def setKnob(self, value): - self.sliderText.SetValue('%g' % value) + self.sliderText.SetValue(f'{value:g}') self.slider.SetValue(int(value * 1000)) @@ -115,9 +115,9 @@ def __init__(self, *args, **kwargs): sizer = wx.BoxSizer(wx.VERTICAL) sizer.Add(self.canvas, 1, wx.EXPAND) sizer.Add(self.frequencySliderGroup.sizer, 0, - wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, border=5) + wx.EXPAND | wx.ALL, border=5) sizer.Add(self.amplitudeSliderGroup.sizer, 0, - wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, border=5) + wx.EXPAND | wx.ALL, border=5) panel.SetSizer(sizer) def createCanvas(self, parent): diff --git a/examples/user_interfaces/gtk3_spreadsheet_sgskip.py b/examples/user_interfaces/gtk3_spreadsheet_sgskip.py index 925ea33faa48..4261d9ac1e89 100644 --- a/examples/user_interfaces/gtk3_spreadsheet_sgskip.py +++ b/examples/user_interfaces/gtk3_spreadsheet_sgskip.py @@ -1,10 +1,10 @@ """ ================ -GTK3 Spreadsheet +GTK3 spreadsheet ================ Example of embedding Matplotlib in an application and interacting with a -treeview to store data. Double click on an entry to update plot data. +treeview to store data. Double-click on an entry to update plot data. """ import gi diff --git a/examples/user_interfaces/gtk4_spreadsheet_sgskip.py b/examples/user_interfaces/gtk4_spreadsheet_sgskip.py index 047ae4cf974e..509789f5aedb 100644 --- a/examples/user_interfaces/gtk4_spreadsheet_sgskip.py +++ b/examples/user_interfaces/gtk4_spreadsheet_sgskip.py @@ -1,10 +1,10 @@ """ ================ -GTK4 Spreadsheet +GTK4 spreadsheet ================ Example of embedding Matplotlib in an application and interacting with a -treeview to store data. Double click on an entry to update plot data. +treeview to store data. Double-click on an entry to update plot data. """ import gi diff --git a/examples/user_interfaces/images/eye-symbolic.svg b/examples/user_interfaces/images/eye-symbolic.svg new file mode 100644 index 000000000000..20d5db230405 --- /dev/null +++ b/examples/user_interfaces/images/eye-symbolic.svg @@ -0,0 +1,70 @@ + + + + + + + + 2021-07-14T19:48:07.525592 + image/svg+xml + + + Matplotlib v3.4.2.post1357+gf1afce77c6.d20210714, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/user_interfaces/images/eye.pdf b/examples/user_interfaces/images/eye.pdf new file mode 100644 index 000000000000..52f18e8342f8 Binary files /dev/null and b/examples/user_interfaces/images/eye.pdf differ diff --git a/examples/user_interfaces/images/eye.png b/examples/user_interfaces/images/eye.png new file mode 100644 index 000000000000..365f6fbcde5d Binary files /dev/null and b/examples/user_interfaces/images/eye.png differ diff --git a/examples/user_interfaces/images/eye.svg b/examples/user_interfaces/images/eye.svg new file mode 100644 index 000000000000..20d5db230405 --- /dev/null +++ b/examples/user_interfaces/images/eye.svg @@ -0,0 +1,70 @@ + + + + + + + + 2021-07-14T19:48:07.525592 + image/svg+xml + + + Matplotlib v3.4.2.post1357+gf1afce77c6.d20210714, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/user_interfaces/images/eye_large.png b/examples/user_interfaces/images/eye_large.png new file mode 100644 index 000000000000..f8a2911032a4 Binary files /dev/null and b/examples/user_interfaces/images/eye_large.png differ diff --git a/examples/user_interfaces/mathtext_wx_sgskip.py b/examples/user_interfaces/mathtext_wx_sgskip.py index ca5a869bef5b..f9af7a89c416 100644 --- a/examples/user_interfaces/mathtext_wx_sgskip.py +++ b/examples/user_interfaces/mathtext_wx_sgskip.py @@ -1,7 +1,7 @@ """ -=========== -MathText WX -=========== +====================== +Display mathtext in WX +====================== Demonstrates how to convert (math)text to a wx.Bitmap for display in various controls on wxPython. @@ -15,13 +15,12 @@ import numpy as np import wx -IS_GTK = 'wxGTK' in wx.PlatformInfo IS_WIN = 'wxMSW' in wx.PlatformInfo def mathtext_to_wxbitmap(s): # We draw the text at position (0, 0) but then rely on - # ``facecolor="none"`` and ``bbox_inches="tight", pad_inches=0`` to get an + # ``facecolor="none"`` and ``bbox_inches="tight", pad_inches=0`` to get a # transparent mask that is then loaded into a wx.Bitmap. fig = Figure(facecolor="none") text_color = ( @@ -66,7 +65,7 @@ def __init__(self, parent, title): menuBar.Append(menu, "&File") self.Bind(wx.EVT_MENU, self.OnClose, m_exit) - if IS_GTK or IS_WIN: + if IS_WIN: # Equation Menu menu = wx.Menu() for i, (mt, func) in enumerate(functions): diff --git a/examples/user_interfaces/mpl_with_glade3_sgskip.py b/examples/user_interfaces/mpl_with_glade3_sgskip.py index 2464c5e04c41..5e6a43caca61 100644 --- a/examples/user_interfaces/mpl_with_glade3_sgskip.py +++ b/examples/user_interfaces/mpl_with_glade3_sgskip.py @@ -1,8 +1,7 @@ """ ======================= -Matplotlib With Glade 3 +Matplotlib with Glade 3 ======================= - """ from pathlib import Path diff --git a/examples/user_interfaces/mplcvd.py b/examples/user_interfaces/mplcvd.py new file mode 100644 index 000000000000..8eaa22a8977a --- /dev/null +++ b/examples/user_interfaces/mplcvd.py @@ -0,0 +1,299 @@ +""" +mplcvd -- an example of figure hook +=================================== + +To use this hook, ensure that this module is in your ``PYTHONPATH``, and set +``rcParams["figure.hooks"] = ["mplcvd:setup"]``. This hook depends on +the ``colorspacious`` third-party module. +""" + +import functools +from pathlib import Path + +import colorspacious +import numpy as np + + +_BUTTON_NAME = "Filter" +_BUTTON_HELP = "Simulate color vision deficiencies" +_MENU_ENTRIES = { + "None": None, + "Greyscale": "greyscale", + "Deuteranopia": "deuteranomaly", + "Protanopia": "protanomaly", + "Tritanopia": "tritanomaly", +} + + +def _get_color_filter(name): + """ + Given a color filter name, create a color filter function. + + Parameters + ---------- + name : str + The color filter name, one of the following: + + - ``"none"``: ... + - ``"greyscale"``: Convert the input to luminosity. + - ``"deuteranopia"``: Simulate the most common form of red-green + colorblindness. + - ``"protanopia"``: Simulate a rarer form of red-green colorblindness. + - ``"tritanopia"``: Simulate the rare form of blue-yellow + colorblindness. + + Color conversions use `colorspacious`_. + + Returns + ------- + callable + A color filter function that has the form: + + def filter(input: np.ndarray[M, N, D])-> np.ndarray[M, N, D] + + where (M, N) are the image dimensions, and D is the color depth (3 for + RGB, 4 for RGBA). Alpha is passed through unchanged and otherwise + ignored. + """ + if name not in _MENU_ENTRIES: + raise ValueError(f"Unsupported filter name: {name!r}") + name = _MENU_ENTRIES[name] + + if name is None: + return None + + elif name == "greyscale": + rgb_to_jch = colorspacious.cspace_converter("sRGB1", "JCh") + jch_to_rgb = colorspacious.cspace_converter("JCh", "sRGB1") + + def convert(im): + greyscale_JCh = rgb_to_jch(im) + greyscale_JCh[..., 1] = 0 + im = jch_to_rgb(greyscale_JCh) + return im + + else: + cvd_space = {"name": "sRGB1+CVD", "cvd_type": name, "severity": 100} + convert = colorspacious.cspace_converter(cvd_space, "sRGB1") + + def filter_func(im, dpi): + alpha = None + if im.shape[-1] == 4: + im, alpha = im[..., :3], im[..., 3] + im = convert(im) + if alpha is not None: + im = np.dstack((im, alpha)) + return np.clip(im, 0, 1), 0, 0 + + return filter_func + + +def _set_menu_entry(tb, name): + tb.canvas.figure.set_agg_filter(_get_color_filter(name)) + tb.canvas.draw_idle() + + +def setup(figure): + tb = figure.canvas.toolbar + if tb is None: + return + for cls in type(tb).__mro__: + pkg = cls.__module__.split(".")[0] + if pkg != "matplotlib": + break + if pkg == "gi": + _setup_gtk(tb) + elif pkg in ("PyQt5", "PySide2", "PyQt6", "PySide6"): + _setup_qt(tb) + elif pkg == "tkinter": + _setup_tk(tb) + elif pkg == "wx": + _setup_wx(tb) + else: + raise NotImplementedError("The current backend is not supported") + + +def _setup_gtk(tb): + from gi.repository import Gio, GLib, Gtk + + for idx in range(tb.get_n_items()): + children = tb.get_nth_item(idx).get_children() + if children and isinstance(children[0], Gtk.Label): + break + + toolitem = Gtk.SeparatorToolItem() + tb.insert(toolitem, idx) + + image = Gtk.Image.new_from_gicon( + Gio.Icon.new_for_string( + str(Path(__file__).parent / "images/eye-symbolic.svg")), + Gtk.IconSize.LARGE_TOOLBAR) + + # The type of menu is progressively downgraded depending on GTK version. + if Gtk.check_version(3, 6, 0) is None: + + group = Gio.SimpleActionGroup.new() + action = Gio.SimpleAction.new_stateful("cvdsim", + GLib.VariantType("s"), + GLib.Variant("s", "none")) + group.add_action(action) + + @functools.partial(action.connect, "activate") + def set_filter(action, parameter): + _set_menu_entry(tb, parameter.get_string()) + action.set_state(parameter) + + menu = Gio.Menu() + for name in _MENU_ENTRIES: + menu.append(name, f"local.cvdsim::{name}") + + button = Gtk.MenuButton.new() + button.remove(button.get_children()[0]) + button.add(image) + button.insert_action_group("local", group) + button.set_menu_model(menu) + button.get_style_context().add_class("flat") + + item = Gtk.ToolItem() + item.add(button) + tb.insert(item, idx + 1) + + else: + + menu = Gtk.Menu() + group = [] + for name in _MENU_ENTRIES: + item = Gtk.RadioMenuItem.new_with_label(group, name) + item.set_active(name == "None") + item.connect( + "activate", lambda item: _set_menu_entry(tb, item.get_label())) + group.append(item) + menu.append(item) + menu.show_all() + + tbutton = Gtk.MenuToolButton.new(image, _BUTTON_NAME) + tbutton.set_menu(menu) + tb.insert(tbutton, idx + 1) + + tb.show_all() + + +def _setup_qt(tb): + from matplotlib.backends.qt_compat import QtGui, QtWidgets + + menu = QtWidgets.QMenu() + try: + QActionGroup = QtGui.QActionGroup # Qt6 + except AttributeError: + QActionGroup = QtWidgets.QActionGroup # Qt5 + group = QActionGroup(menu) + group.triggered.connect(lambda action: _set_menu_entry(tb, action.text())) + + for name in _MENU_ENTRIES: + action = menu.addAction(name) + action.setCheckable(True) + action.setActionGroup(group) + action.setChecked(name == "None") + + actions = tb.actions() + before = next( + (action for action in actions + if isinstance(tb.widgetForAction(action), QtWidgets.QLabel)), None) + + tb.insertSeparator(before) + button = QtWidgets.QToolButton() + # FIXME: _icon needs public API. + button.setIcon(tb._icon(str(Path(__file__).parent / "images/eye.png"))) + button.setText(_BUTTON_NAME) + button.setToolTip(_BUTTON_HELP) + button.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) + button.setMenu(menu) + tb.insertWidget(before, button) + + +def _setup_tk(tb): + import tkinter as tk + + tb._Spacer() # FIXME: _Spacer needs public API. + + button = tk.Menubutton(master=tb, relief="raised") + button._image_file = str(Path(__file__).parent / "images/eye.png") + # FIXME: _set_image_for_button needs public API (perhaps like _icon). + tb._set_image_for_button(button) + button.pack(side=tk.LEFT) + + menu = tk.Menu(master=button, tearoff=False) + for name in _MENU_ENTRIES: + menu.add("radiobutton", label=name, + command=lambda _name=name: _set_menu_entry(tb, _name)) + menu.invoke(0) + button.config(menu=menu) + + +def _setup_wx(tb): + import wx + + idx = next(idx for idx in range(tb.ToolsCount) + if tb.GetToolByPos(idx).IsStretchableSpace()) + tb.InsertSeparator(idx) + tool = tb.InsertTool( + idx + 1, -1, _BUTTON_NAME, + # FIXME: _icon needs public API. + tb._icon(str(Path(__file__).parent / "images/eye.png")), + # FIXME: ITEM_DROPDOWN is not supported on macOS. + kind=wx.ITEM_DROPDOWN, shortHelp=_BUTTON_HELP) + + menu = wx.Menu() + for name in _MENU_ENTRIES: + item = menu.AppendRadioItem(-1, name) + menu.Bind( + wx.EVT_MENU, + lambda event, _name=name: _set_menu_entry(tb, _name), + id=item.Id, + ) + tb.SetDropdownMenu(tool.Id, menu) + + +if __name__ == '__main__': + import matplotlib.pyplot as plt + from matplotlib import cbook + + plt.rcParams['figure.hooks'].append('mplcvd:setup') + + fig, axd = plt.subplot_mosaic( + [ + ['viridis', 'turbo'], + ['photo', 'lines'] + ] + ) + + delta = 0.025 + x = y = np.arange(-3.0, 3.0, delta) + X, Y = np.meshgrid(x, y) + Z1 = np.exp(-X**2 - Y**2) + Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) + Z = (Z1 - Z2) * 2 + + imv = axd['viridis'].imshow( + Z, interpolation='bilinear', + origin='lower', extent=[-3, 3, -3, 3], + vmax=abs(Z).max(), vmin=-abs(Z).max() + ) + fig.colorbar(imv) + imt = axd['turbo'].imshow( + Z, interpolation='bilinear', cmap='turbo', + origin='lower', extent=[-3, 3, -3, 3], + vmax=abs(Z).max(), vmin=-abs(Z).max() + ) + fig.colorbar(imt) + + # A sample image + with cbook.get_sample_data('grace_hopper.jpg') as image_file: + photo = plt.imread(image_file) + axd['photo'].imshow(photo) + + th = np.linspace(0, 2*np.pi, 1024) + for j in [1, 2, 4, 6]: + axd['lines'].plot(th, np.sin(th * j), label=f'$\\omega={j}$') + axd['lines'].legend(ncols=2, loc='upper right') + plt.show() diff --git a/examples/user_interfaces/svg_histogram_sgskip.py b/examples/user_interfaces/svg_histogram_sgskip.py index 4311399fcae6..eb4a9db25cfc 100644 --- a/examples/user_interfaces/svg_histogram_sgskip.py +++ b/examples/user_interfaces/svg_histogram_sgskip.py @@ -66,18 +66,18 @@ hist_patches = {} for ic, c in enumerate(containers): - hist_patches['hist_%d' % ic] = [] + hist_patches[f'hist_{ic}'] = [] for il, element in enumerate(c): - element.set_gid('hist_%d_patch_%d' % (ic, il)) - hist_patches['hist_%d' % ic].append('hist_%d_patch_%d' % (ic, il)) + element.set_gid(f'hist_{ic}_patch_{il}') + hist_patches[f'hist_{ic}'].append(f'hist_{ic}_patch_{il}') # Set ids for the legend patches for i, t in enumerate(leg.get_patches()): - t.set_gid('leg_patch_%d' % i) + t.set_gid(f'leg_patch_{i}') # Set ids for the text patches for i, t in enumerate(leg.get_texts()): - t.set_gid('leg_text_%d' % i) + t.set_gid(f'leg_text_{i}') # Save SVG in a fake file object. f = BytesIO() @@ -91,13 +91,13 @@ # Add attributes to the patch objects. for i, t in enumerate(leg.get_patches()): - el = xmlid['leg_patch_%d' % i] + el = xmlid[f'leg_patch_{i}'] el.set('cursor', 'pointer') el.set('onclick', "toggle_hist(this)") # Add attributes to the text objects. for i, t in enumerate(leg.get_texts()): - el = xmlid['leg_text_%d' % i] + el = xmlid[f'leg_text_{i}'] el.set('cursor', 'pointer') el.set('onclick', "toggle_hist(this)") diff --git a/examples/user_interfaces/svg_tooltip_sgskip.py b/examples/user_interfaces/svg_tooltip_sgskip.py index 04827d163bd8..a709428374c0 100644 --- a/examples/user_interfaces/svg_tooltip_sgskip.py +++ b/examples/user_interfaces/svg_tooltip_sgskip.py @@ -48,8 +48,8 @@ zorder=1)) ax.add_patch(patch) - patch.set_gid('mypatch_{:03d}'.format(i)) - annotate.set_gid('mytooltip_{:03d}'.format(i)) + patch.set_gid(f'mypatch_{i:03d}') + annotate.set_gid(f'mytooltip_{i:03d}') # Save the figure in a fake file object ax.set_xlim(-30, 30) @@ -69,10 +69,10 @@ # Get the index of the shape index = shapes.index(i) # Hide the tooltips - tooltip = xmlid['mytooltip_{:03d}'.format(index)] + tooltip = xmlid[f'mytooltip_{index:03d}'] tooltip.set('visibility', 'hidden') # Assign onmouseover and onmouseout callbacks to patches. - mypatch = xmlid['mypatch_{:03d}'.format(index)] + mypatch = xmlid[f'mypatch_{index:03d}'] mypatch.set('onmouseover', "ShowTooltip(this)") mypatch.set('onmouseout', "HideTooltip(this)") diff --git a/examples/user_interfaces/toolmanager_sgskip.py b/examples/user_interfaces/toolmanager_sgskip.py index cec5e577051f..a96e3876b040 100644 --- a/examples/user_interfaces/toolmanager_sgskip.py +++ b/examples/user_interfaces/toolmanager_sgskip.py @@ -3,45 +3,46 @@ Tool Manager ============ -This example demonstrates how to: +This example demonstrates how to -* Modify the Toolbar -* Create tools -* Add tools -* Remove tools +* modify the Toolbar +* create tools +* add tools +* remove tools -Using `matplotlib.backend_managers.ToolManager` +using `matplotlib.backend_managers.ToolManager`. """ import matplotlib.pyplot as plt -plt.rcParams['toolbar'] = 'toolmanager' from matplotlib.backend_tools import ToolBase, ToolToggleBase +plt.rcParams['toolbar'] = 'toolmanager' + + class ListTools(ToolBase): """List all the tools controlled by the `ToolManager`.""" - # keyboard shortcut - default_keymap = 'm' + default_keymap = 'm' # keyboard shortcut description = 'List Tools' def trigger(self, *args, **kwargs): print('_' * 80) - print("{0:12} {1:45} {2}".format( - 'Name (id)', 'Tool description', 'Keymap')) + fmt_tool = "{:12} {:45} {}".format + print(fmt_tool('Name (id)', 'Tool description', 'Keymap')) print('-' * 80) tools = self.toolmanager.tools for name in sorted(tools): if not tools[name].description: continue keys = ', '.join(sorted(self.toolmanager.get_tool_keymap(name))) - print("{0:12} {1:45} {2}".format( - name, tools[name].description, keys)) + print(fmt_tool(name, tools[name].description, keys)) print('_' * 80) + fmt_active_toggle = "{0!s:12} {1!s:45}".format print("Active Toggle tools") - print("{0:12} {1:45}".format("Group", "Active")) + print(fmt_active_toggle("Group", "Active")) print('-' * 80) for group, active in self.toolmanager.active_toggle.items(): - print("{0:12} {1:45}".format(str(group), str(active))) + print(fmt_active_toggle(group, active)) class GroupHideTool(ToolToggleBase): @@ -77,7 +78,6 @@ def set_lines_visibility(self, state): fig.canvas.manager.toolmanager.add_tool('List', ListTools) fig.canvas.manager.toolmanager.add_tool('Show', GroupHideTool, gid='mygroup') - # Add an existing tool to new group `foo`. # It can be added as many times as we want fig.canvas.manager.toolbar.add_tool('zoom', 'foo') diff --git a/examples/user_interfaces/web_application_server_sgskip.py b/examples/user_interfaces/web_application_server_sgskip.py index ff16277cc752..c430c24b70ed 100644 --- a/examples/user_interfaces/web_application_server_sgskip.py +++ b/examples/user_interfaces/web_application_server_sgskip.py @@ -44,7 +44,7 @@ def hello(): ############################################################################# # # Since the above code is a Flask application, it should be run using the -# `flask command-line tool `_ +# `flask command-line tool `_ # Assuming that the working directory contains this script: # # Unix-like systems diff --git a/examples/user_interfaces/wxcursor_demo_sgskip.py b/examples/user_interfaces/wxcursor_demo_sgskip.py index c909a3d8baad..ae1e06c81a01 100644 --- a/examples/user_interfaces/wxcursor_demo_sgskip.py +++ b/examples/user_interfaces/wxcursor_demo_sgskip.py @@ -50,8 +50,7 @@ def ChangeCursor(self, event): def UpdateStatusBar(self, event): if event.inaxes: - self.statusBar.SetStatusText( - "x={} y={}".format(event.xdata, event.ydata)) + self.statusBar.SetStatusText(f"x={event.xdata} y={event.ydata}") class App(wx.App): diff --git a/examples/userdemo/anchored_box04.py b/examples/userdemo/anchored_box04.py index d641e7a18ac4..2a4a0f4cea07 100644 --- a/examples/userdemo/anchored_box04.py +++ b/examples/userdemo/anchored_box04.py @@ -12,7 +12,7 @@ fig, ax = plt.subplots(figsize=(3, 3)) -box1 = TextArea(" Test : ", textprops=dict(color="k")) +box1 = TextArea(" Test: ", textprops=dict(color="k")) box2 = DrawingArea(60, 20, 0, 0) el1 = Ellipse((10, 10), width=16, height=5, angle=30, fc="r") diff --git a/examples/userdemo/colormap_normalizations_symlognorm.py b/examples/userdemo/colormap_normalizations_symlognorm.py deleted file mode 100644 index ff0d4872f75e..000000000000 --- a/examples/userdemo/colormap_normalizations_symlognorm.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -================================== -Colormap Normalizations Symlognorm -================================== - -Demonstration of using norm to map colormaps onto data in non-linear ways. -""" - -import numpy as np -import matplotlib.pyplot as plt -import matplotlib.colors as colors - -""" -SymLogNorm: two humps, one negative and one positive, The positive -with 5-times the amplitude. Linearly, you cannot see detail in the -negative hump. Here we logarithmically scale the positive and -negative data separately. - -Note that colorbar labels do not come out looking very good. -""" - -N = 100 -X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)] -Z1 = np.exp(-X**2 - Y**2) -Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) -Z = (Z1 - Z2) * 2 - -fig, ax = plt.subplots(2, 1) - -pcm = ax[0].pcolormesh(X, Y, Z, - norm=colors.SymLogNorm(linthresh=0.03, linscale=0.03, - vmin=-1.0, vmax=1.0, base=10), - cmap='RdBu_r', shading='nearest') -fig.colorbar(pcm, ax=ax[0], extend='both') - -pcm = ax[1].pcolormesh(X, Y, Z, cmap='RdBu_r', vmin=-np.max(Z), - shading='nearest') -fig.colorbar(pcm, ax=ax[1], extend='both') - -plt.show() diff --git a/examples/userdemo/pgf_fonts.py b/examples/userdemo/pgf_fonts.py index 1f9d49853bcf..112c249752c2 100644 --- a/examples/userdemo/pgf_fonts.py +++ b/examples/userdemo/pgf_fonts.py @@ -1,8 +1,7 @@ """ ========= -Pgf Fonts +PGF fonts ========= - """ import matplotlib.pyplot as plt diff --git a/examples/userdemo/pgf_preamble_sgskip.py b/examples/userdemo/pgf_preamble_sgskip.py index e16ee0f1bbf5..ca193a0aecc0 100644 --- a/examples/userdemo/pgf_preamble_sgskip.py +++ b/examples/userdemo/pgf_preamble_sgskip.py @@ -1,8 +1,7 @@ """ ============ -Pgf Preamble +PGF preamble ============ - """ import matplotlib as mpl diff --git a/examples/userdemo/pgf_texsystem.py b/examples/userdemo/pgf_texsystem.py index dc44e8c12298..20f35b5cc906 100644 --- a/examples/userdemo/pgf_texsystem.py +++ b/examples/userdemo/pgf_texsystem.py @@ -1,8 +1,7 @@ """ ============= -Pgf Texsystem +PGF texsystem ============= - """ import matplotlib.pyplot as plt diff --git a/examples/userdemo/simple_legend01.py b/examples/userdemo/simple_legend01.py index c80488d1ad2d..2aaac1424558 100644 --- a/examples/userdemo/simple_legend01.py +++ b/examples/userdemo/simple_legend01.py @@ -15,7 +15,7 @@ # Place a legend above this subplot, expanding itself to # fully use the given bounding box. ax.legend(bbox_to_anchor=(0., 1.02, 1., .102), loc='lower left', - ncol=2, mode="expand", borderaxespad=0.) + ncols=2, mode="expand", borderaxespad=0.) ax = fig.add_subplot(223) ax.plot([1, 2, 3], label="test1") diff --git a/examples/widgets/annotated_cursor.py b/examples/widgets/annotated_cursor.py index 17cc20be0aea..3fb21b1f2302 100644 --- a/examples/widgets/annotated_cursor.py +++ b/examples/widgets/annotated_cursor.py @@ -1,6 +1,6 @@ """ ================ -Annotated Cursor +Annotated cursor ================ Display a data cursor including a text box, which shows the plot point close @@ -10,9 +10,9 @@ creation of new widgets and their event callbacks. See also the :doc:`cross hair cursor -`, which implements a cursor tracking the plotted -data, but without using inheritance and without displaying the currently -tracked coordinates. +`, which implements a cursor tracking the +plotted data, but without using inheritance and without displaying the +currently tracked coordinates. .. note:: The figure related to this example does not show the cursor, because that @@ -24,6 +24,8 @@ import numpy as np import matplotlib.pyplot as plt +from matplotlib.backend_bases import MouseEvent + class AnnotatedCursor(Cursor): """ @@ -50,7 +52,7 @@ class AnnotatedCursor(Cursor): offset : (float, float) default: (5, 5) The offset in display (pixel) coordinates of the text position - relative to the cross hair. + relative to the cross-hair. dataaxis : {"x", "y"}, optional, default: "x" If "x" is specified, the vertical cursor line sticks to the mouse @@ -76,7 +78,9 @@ class AnnotatedCursor(Cursor): """ def __init__(self, line, numberformat="{0:.4g};{1:.4g}", offset=(5, 5), - dataaxis='x', textprops={}, **cursorargs): + dataaxis='x', textprops=None, **cursorargs): + if textprops is None: + textprops = {} # The line object, for which the coordinates are displayed self.line = line # The format string, on which .format() is called for creating the text @@ -310,6 +314,12 @@ def _update(self): color='red', linewidth=2) +# Simulate a mouse move to (-2, 10), needed for online docs +t = ax.transData +MouseEvent( + "motion_notify_event", ax.figure.canvas, *t.transform((-2, 10)) +)._process() + plt.show() ############################################################################### @@ -337,4 +347,10 @@ def _update(self): useblit=True, color='red', linewidth=2) +# Simulate a mouse move to (-2, 10), needed for online docs +t = ax.transData +MouseEvent( + "motion_notify_event", ax.figure.canvas, *t.transform((-2, 10)) +)._process() + plt.show() diff --git a/examples/widgets/buttons.py b/examples/widgets/buttons.py index 80d77c463266..24331a4acd00 100644 --- a/examples/widgets/buttons.py +++ b/examples/widgets/buttons.py @@ -16,10 +16,10 @@ freqs = np.arange(2, 20, 3) fig, ax = plt.subplots() -plt.subplots_adjust(bottom=0.2) +fig.subplots_adjust(bottom=0.2) t = np.arange(0.0, 1.0, 0.001) s = np.sin(2*np.pi*freqs[0]*t) -l, = plt.plot(t, s, lw=2) +l, = ax.plot(t, s, lw=2) class Index: @@ -40,8 +40,8 @@ def prev(self, event): plt.draw() callback = Index() -axprev = plt.axes([0.7, 0.05, 0.1, 0.075]) -axnext = plt.axes([0.81, 0.05, 0.1, 0.075]) +axprev = fig.add_axes([0.7, 0.05, 0.1, 0.075]) +axnext = fig.add_axes([0.81, 0.05, 0.1, 0.075]) bnext = Button(axnext, 'Next') bnext.on_clicked(callback.next) bprev = Button(axprev, 'Previous') diff --git a/examples/widgets/check_buttons.py b/examples/widgets/check_buttons.py index 97e879cb22e8..b96ffb51c0af 100644 --- a/examples/widgets/check_buttons.py +++ b/examples/widgets/check_buttons.py @@ -1,14 +1,15 @@ """ ============= -Check Buttons +Check buttons ============= Turning visual elements on and off with check buttons. -This program shows the use of 'Check Buttons' which is similar to -check boxes. There are 3 different sine waves shown and we can choose which +This program shows the use of `.CheckButtons` which is similar to +check boxes. There are 3 different sine waves shown, and we can choose which waves are displayed with the check buttons. """ + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import CheckButtons @@ -19,26 +20,32 @@ s2 = np.sin(6*np.pi*t) fig, ax = plt.subplots() -l0, = ax.plot(t, s0, visible=False, lw=2, color='k', label='2 Hz') -l1, = ax.plot(t, s1, lw=2, color='r', label='4 Hz') -l2, = ax.plot(t, s2, lw=2, color='g', label='6 Hz') -plt.subplots_adjust(left=0.2) +l0, = ax.plot(t, s0, visible=False, lw=2, color='black', label='1 Hz') +l1, = ax.plot(t, s1, lw=2, color='red', label='2 Hz') +l2, = ax.plot(t, s2, lw=2, color='green', label='3 Hz') +fig.subplots_adjust(left=0.2) -lines = [l0, l1, l2] +lines_by_label = {l.get_label(): l for l in [l0, l1, l2]} +line_colors = [l.get_color() for l in lines_by_label.values()] # Make checkbuttons with all plotted lines with correct visibility -rax = plt.axes([0.05, 0.4, 0.1, 0.15]) -labels = [str(line.get_label()) for line in lines] -visibility = [line.get_visible() for line in lines] -check = CheckButtons(rax, labels, visibility) - - -def func(label): - index = labels.index(label) - lines[index].set_visible(not lines[index].get_visible()) - plt.draw() - -check.on_clicked(func) +rax = fig.add_axes([0.05, 0.4, 0.1, 0.15]) +check = CheckButtons( + ax=rax, + labels=lines_by_label.keys(), + actives=[l.get_visible() for l in lines_by_label.values()], + label_props={'color': line_colors}, + frame_props={'edgecolor': line_colors}, + check_props={'facecolor': line_colors}, +) + + +def callback(label): + ln = lines_by_label[label] + ln.set_visible(not ln.get_visible()) + ln.figure.canvas.draw_idle() + +check.on_clicked(callback) plt.show() diff --git a/examples/widgets/multicursor.py b/examples/widgets/multicursor.py index 6e0775dd85e7..618fa17c5ad6 100644 --- a/examples/widgets/multicursor.py +++ b/examples/widgets/multicursor.py @@ -5,22 +5,27 @@ Showing a cursor on multiple plots simultaneously. -This example generates two subplots and on hovering the cursor over data in one -subplot, the values of that datapoint are shown in both respectively. +This example generates three axes split over two different figures. On +hovering the cursor over data in one subplot, the values of that datapoint are +shown in all axes. """ + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import MultiCursor t = np.arange(0.0, 2.0, 0.01) s1 = np.sin(2*np.pi*t) -s2 = np.sin(4*np.pi*t) +s2 = np.sin(3*np.pi*t) +s3 = np.sin(4*np.pi*t) fig, (ax1, ax2) = plt.subplots(2, sharex=True) ax1.plot(t, s1) ax2.plot(t, s2) +fig, ax3 = plt.subplots() +ax3.plot(t, s3) -multi = MultiCursor(fig.canvas, (ax1, ax2), color='r', lw=1) +multi = MultiCursor(None, (ax1, ax2, ax3), color='r', lw=1) plt.show() ############################################################################# diff --git a/examples/widgets/polygon_selector_demo.py b/examples/widgets/polygon_selector_demo.py index 3bea63565045..da9e65c39268 100644 --- a/examples/widgets/polygon_selector_demo.py +++ b/examples/widgets/polygon_selector_demo.py @@ -1,7 +1,7 @@ """ -================ -Polygon Selector -================ +======================================================= +Select indices from a collection using polygon selector +======================================================= Shows how one can select indices of a polygon interactively. """ @@ -50,7 +50,7 @@ def __init__(self, ax, collection, alpha_other=0.3): elif len(self.fc) == 1: self.fc = np.tile(self.fc, (self.Npts, 1)) - self.poly = PolygonSelector(ax, self.onselect) + self.poly = PolygonSelector(ax, self.onselect, draw_bounding_box=True) self.ind = [] def onselect(self, verts): diff --git a/examples/widgets/polygon_selector_simple.py b/examples/widgets/polygon_selector_simple.py new file mode 100644 index 000000000000..cdc84b07eaf6 --- /dev/null +++ b/examples/widgets/polygon_selector_simple.py @@ -0,0 +1,46 @@ +""" +================ +Polygon Selector +================ + +Shows how to create a polygon programmatically or interactively +""" + +import matplotlib.pyplot as plt +from matplotlib.widgets import PolygonSelector + +############################################################################### +# +# To create the polygon programmatically +fig, ax = plt.subplots() +fig.show() + +selector = PolygonSelector(ax, lambda *args: None) + +# Add three vertices +selector.verts = [(0.1, 0.4), (0.5, 0.9), (0.3, 0.2)] + + +############################################################################### +# +# To create the polygon interactively + +fig2, ax2 = plt.subplots() +fig2.show() + +selector2 = PolygonSelector(ax2, lambda *args: None) + +print("Click on the figure to create a polygon.") +print("Press the 'esc' key to start a new polygon.") +print("Try holding the 'shift' key to move all of the vertices.") +print("Try holding the 'ctrl' key to move a single vertex.") + + +############################################################################# +# +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.widgets.PolygonSelector` diff --git a/examples/widgets/radio_buttons.py b/examples/widgets/radio_buttons.py index 1421fc90b1bc..4d3c93e579b6 100644 --- a/examples/widgets/radio_buttons.py +++ b/examples/widgets/radio_buttons.py @@ -9,6 +9,7 @@ In this case, the buttons let the user choose one of the three different sine waves to be shown in the plot. """ + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import RadioButtons @@ -20,36 +21,44 @@ fig, ax = plt.subplots() l, = ax.plot(t, s0, lw=2, color='red') -plt.subplots_adjust(left=0.3) +fig.subplots_adjust(left=0.3) axcolor = 'lightgoldenrodyellow' -rax = plt.axes([0.05, 0.7, 0.15, 0.15], facecolor=axcolor) -radio = RadioButtons(rax, ('2 Hz', '4 Hz', '8 Hz')) +rax = fig.add_axes([0.05, 0.7, 0.15, 0.15], facecolor=axcolor) +radio = RadioButtons(rax, ('1 Hz', '2 Hz', '4 Hz'), + label_props={'color': 'cmy', 'fontsize': [12, 14, 16]}, + radio_props={'s': [16, 32, 64]}) def hzfunc(label): - hzdict = {'2 Hz': s0, '4 Hz': s1, '8 Hz': s2} + hzdict = {'1 Hz': s0, '2 Hz': s1, '4 Hz': s2} ydata = hzdict[label] l.set_ydata(ydata) - plt.draw() + fig.canvas.draw() radio.on_clicked(hzfunc) -rax = plt.axes([0.05, 0.4, 0.15, 0.15], facecolor=axcolor) -radio2 = RadioButtons(rax, ('red', 'blue', 'green')) +rax = fig.add_axes([0.05, 0.4, 0.15, 0.15], facecolor=axcolor) +radio2 = RadioButtons( + rax, ('red', 'blue', 'green'), + label_props={'color': ['red', 'blue', 'green']}, + radio_props={ + 'facecolor': ['red', 'blue', 'green'], + 'edgecolor': ['darkred', 'darkblue', 'darkgreen'], + }) def colorfunc(label): l.set_color(label) - plt.draw() + fig.canvas.draw() radio2.on_clicked(colorfunc) -rax = plt.axes([0.05, 0.1, 0.15, 0.15], facecolor=axcolor) -radio3 = RadioButtons(rax, ('-', '--', '-.', 'steps', ':')) +rax = fig.add_axes([0.05, 0.1, 0.15, 0.15], facecolor=axcolor) +radio3 = RadioButtons(rax, ('-', '--', '-.', ':')) def stylefunc(label): l.set_linestyle(label) - plt.draw() + fig.canvas.draw() radio3.on_clicked(stylefunc) plt.show() diff --git a/examples/widgets/range_slider.py b/examples/widgets/range_slider.py index 789df59d9324..bdb69fa80859 100644 --- a/examples/widgets/range_slider.py +++ b/examples/widgets/range_slider.py @@ -26,14 +26,14 @@ img = np.random.randn(N, N) fig, axs = plt.subplots(1, 2, figsize=(10, 5)) -plt.subplots_adjust(bottom=0.25) +fig.subplots_adjust(bottom=0.25) im = axs[0].imshow(img) axs[1].hist(img.flatten(), bins='auto') axs[1].set_title('Histogram of pixel intensities') # Create the RangeSlider -slider_ax = plt.axes([0.20, 0.1, 0.60, 0.03]) +slider_ax = fig.add_axes([0.20, 0.1, 0.60, 0.03]) slider = RangeSlider(slider_ax, "Threshold", img.min(), img.max()) # Create the Vertical lines on the histogram diff --git a/examples/widgets/slider_demo.py b/examples/widgets/slider_demo.py index ffe66279a77b..6f849645f9bf 100644 --- a/examples/widgets/slider_demo.py +++ b/examples/widgets/slider_demo.py @@ -12,6 +12,7 @@ See :doc:`/gallery/widgets/range_slider` for an example of using a ``RangeSlider`` to define a range of values. """ + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import Slider, Button @@ -29,14 +30,14 @@ def f(t, amplitude, frequency): # Create the figure and the line that we will manipulate fig, ax = plt.subplots() -line, = plt.plot(t, f(t, init_amplitude, init_frequency), lw=2) +line, = ax.plot(t, f(t, init_amplitude, init_frequency), lw=2) ax.set_xlabel('Time [s]') # adjust the main plot to make room for the sliders -plt.subplots_adjust(left=0.25, bottom=0.25) +fig.subplots_adjust(left=0.25, bottom=0.25) # Make a horizontal slider to control the frequency. -axfreq = plt.axes([0.25, 0.1, 0.65, 0.03]) +axfreq = fig.add_axes([0.25, 0.1, 0.65, 0.03]) freq_slider = Slider( ax=axfreq, label='Frequency [Hz]', @@ -46,7 +47,7 @@ def f(t, amplitude, frequency): ) # Make a vertically oriented slider to control the amplitude -axamp = plt.axes([0.1, 0.25, 0.0225, 0.63]) +axamp = fig.add_axes([0.1, 0.25, 0.0225, 0.63]) amp_slider = Slider( ax=axamp, label="Amplitude", @@ -68,7 +69,7 @@ def update(val): amp_slider.on_changed(update) # Create a `matplotlib.widgets.Button` to reset the sliders to initial values. -resetax = plt.axes([0.8, 0.025, 0.1, 0.04]) +resetax = fig.add_axes([0.8, 0.025, 0.1, 0.04]) button = Button(resetax, 'Reset', hovercolor='0.975') diff --git a/examples/widgets/slider_snap_demo.py b/examples/widgets/slider_snap_demo.py index f02dd9a6b961..5ace232c12c5 100644 --- a/examples/widgets/slider_snap_demo.py +++ b/examples/widgets/slider_snap_demo.py @@ -15,6 +15,7 @@ See :doc:`/gallery/widgets/range_slider` for an example of using a ``RangeSlider`` to define a range of values. """ + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import Slider, Button @@ -25,11 +26,11 @@ s = a0 * np.sin(2 * np.pi * f0 * t) fig, ax = plt.subplots() -plt.subplots_adjust(bottom=0.25) -l, = plt.plot(t, s, lw=2) +fig.subplots_adjust(bottom=0.25) +l, = ax.plot(t, s, lw=2) -ax_freq = plt.axes([0.25, 0.1, 0.65, 0.03]) -ax_amp = plt.axes([0.25, 0.15, 0.65, 0.03]) +ax_freq = fig.add_axes([0.25, 0.1, 0.65, 0.03]) +ax_amp = fig.add_axes([0.25, 0.15, 0.65, 0.03]) # define the values to use for snapping allowed_amplitudes = np.concatenate([np.linspace(.1, 5, 100), [6, 7, 8, 9]]) @@ -58,7 +59,7 @@ def update(val): sfreq.on_changed(update) samp.on_changed(update) -ax_reset = plt.axes([0.8, 0.025, 0.1, 0.04]) +ax_reset = fig.add_axes([0.8, 0.025, 0.1, 0.04]) button = Button(ax_reset, 'Reset', hovercolor='0.975') diff --git a/examples/widgets/span_selector.py b/examples/widgets/span_selector.py index a9d0cc4960bb..fa75a70d2863 100644 --- a/examples/widgets/span_selector.py +++ b/examples/widgets/span_selector.py @@ -3,9 +3,18 @@ Span Selector ============= -The SpanSelector is a mouse widget to select a xmin/xmax range and plot the -detail view of the selected region in the lower axes +The `.SpanSelector` is a mouse widget that enables selecting a range on an +axis. + +Here, an x-range can be selected on the upper axis; a detailed view of the +selected range is then plotted on the lower axis. + +.. note:: + + If the SpanSelector object is garbage collected you will lose the + interactivity. You must keep a hard reference to it to prevent this. """ + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import SpanSelector @@ -40,14 +49,6 @@ def onselect(xmin, xmax): fig.canvas.draw_idle() -############################################################################# -# .. note:: -# -# If the SpanSelector object is garbage collected you will lose the -# interactivity. You must keep a hard reference to it to prevent this. -# - - span = SpanSelector( ax1, onselect, diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index ba9cd6c90c17..b279c465163e 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -17,10 +17,13 @@ at the ipython shell prompt. -For the most part, direct use of the object-oriented library is encouraged when -programming; pyplot is primarily for working interactively. The exceptions are -the pyplot functions `.pyplot.figure`, `.pyplot.subplot`, `.pyplot.subplots`, -and `.pyplot.savefig`, which can greatly simplify scripting. +For the most part, direct use of the explicit object-oriented library is +encouraged when programming; the implicit pyplot interface is primarily for +working interactively. The exceptions to this suggestion are the pyplot +functions `.pyplot.figure`, `.pyplot.subplot`, `.pyplot.subplots`, and +`.pyplot.savefig`, which can greatly simplify scripting. See +:ref:`api_interfaces` for an explanation of the tradeoffs between the implicit +and explicit interfaces. Modules include: @@ -74,11 +77,30 @@ figure is created, because it is not possible to switch between different GUI backends after that. +The following environment variables can be used to customize the behavior:: + + .. envvar:: MPLBACKEND + + This optional variable can be set to choose the Matplotlib backend. See + :ref:`what-is-a-backend`. + + .. envvar:: MPLCONFIGDIR + + This is the directory used to store user customizations to + Matplotlib, as well as some caches to improve performance. If + :envvar:`MPLCONFIGDIR` is not defined, :file:`{HOME}/.config/matplotlib` + and :file:`{HOME}/.cache/matplotlib` are used on Linux, and + :file:`{HOME}/.matplotlib` on other platforms, if they are + writable. Otherwise, the Python standard library's `tempfile.gettempdir` + is used to find a base directory in which the :file:`matplotlib` + subdirectory is created. + Matplotlib was initially written by John D. Hunter (1968-2012) and is now developed and maintained by a host of others. Occasionally the internal documentation (python docstrings) will refer -to MATLAB®, a registered trademark of The MathWorks, Inc. +to MATLAB®, a registered trademark of The MathWorks, Inc. + """ import atexit @@ -106,9 +128,9 @@ # cbook must import matplotlib only within function # definitions, so it is safe to import from it here. -from . import _api, _version, cbook, docstring, rcsetup -from matplotlib.cbook import MatplotlibDeprecationWarning, sanitize_sequence -from matplotlib.cbook import mplDeprecation # deprecated +from . import _api, _version, cbook, _docstring, rcsetup +from matplotlib.cbook import sanitize_sequence +from matplotlib._api import MatplotlibDeprecationWarning from matplotlib.rcsetup import validate_backend, cycler @@ -160,11 +182,13 @@ def _parse_to_version_info(version_str): def _get_version(): """Return the version string used for __version__.""" - # Only shell out to a git subprocess if really needed, and not on a - # shallow clone, such as those used by CI, as the latter would trigger - # a warning from setuptools_scm. + # Only shell out to a git subprocess if really needed, i.e. when we are in + # a matplotlib git repo but not in a shallow clone, such as those used by + # CI, as the latter would trigger a warning from setuptools_scm. root = Path(__file__).resolve().parents[2] - if (root / ".git").exists() and not (root / ".git/shallow").exists(): + if ((root / ".matplotlib-repo").exists() + and (root / ".git").exists() + and not (root / ".git/shallow").exists()): import setuptools_scm return setuptools_scm.get_version( root=root, @@ -181,9 +205,6 @@ class __getattr__: __version__ = property(lambda self: _get_version()) __version_info__ = property( lambda self: _parse_to_version_info(self.__version__)) - # module-level deprecations - URL_REGEX = _api.deprecated("3.5", obj_type="")(property( - lambda self: re.compile(r'^http://|^https://|^ftp://|^file:'))) def _check_versions(): @@ -196,8 +217,8 @@ def _check_versions(): ("cycler", "0.10"), ("dateutil", "2.7"), ("kiwisolver", "1.0.1"), - ("numpy", "1.17"), - ("pyparsing", "2.2.1"), + ("numpy", "1.20"), + ("pyparsing", "2.3.1"), ]: module = importlib.import_module(modname) if parse_version(module.__version__) < parse_version(minver): @@ -226,12 +247,22 @@ def _ensure_handler(): def set_loglevel(level): """ - Set Matplotlib's root logger and root logger handler level, creating - the handler if it does not exist yet. + Configure Matplotlib's logging levels. + + Matplotlib uses the standard library `logging` framework under the root + logger 'matplotlib'. This is a helper function to: + + - set Matplotlib's root logger level + - set the root logger handler's level, creating the handler + if it does not exist yet Typically, one should call ``set_loglevel("info")`` or ``set_loglevel("debug")`` to get additional debugging information. + Users or applications that are installing their own logging handlers + may want to directly manipulate ``logging.getLogger('matplotlib')`` rather + than use this function. + Parameters ---------- level : {"notset", "debug", "info", "warning", "error", "critical"} @@ -242,6 +273,7 @@ def set_loglevel(level): The first time this function is called, an additional handler is attached to Matplotlib's root handler; this handler is reused every time and this function simply manipulates the logger and handler's level. + """ _log.setLevel(level.upper()) _ensure_handler().setLevel(level.upper()) @@ -278,7 +310,7 @@ def wrapper(**kwargs): return wrapper -_ExecInfo = namedtuple("_ExecInfo", "executable version") +_ExecInfo = namedtuple("_ExecInfo", "executable raw_version version") class ExecutableNotFoundError(FileNotFoundError): @@ -302,8 +334,8 @@ def _get_executable_info(name): ---------- name : str The executable to query. The following values are currently supported: - "dvipng", "gs", "inkscape", "magick", "pdftops". This list is subject - to change without notice. + "dvipng", "gs", "inkscape", "magick", "pdftocairo", "pdftops". This + list is subject to change without notice. Returns ------- @@ -315,7 +347,10 @@ def _get_executable_info(name): ------ ExecutableNotFoundError If the executable is not found or older than the oldest version - supported by Matplotlib. + supported by Matplotlib. For debugging purposes, it is also + possible to "hide" an executable from Matplotlib by adding it to the + :envvar:`_MPLHIDEEXECUTABLES` environment variable (a comma-separated + list), which must be set prior to any calls to this function. ValueError If the executable is not one that we know how to query. """ @@ -339,17 +374,21 @@ def impl(args, regex, min_ver=None, ignore_exit_code=False): raise ExecutableNotFoundError(str(_ose)) from _ose match = re.search(regex, output) if match: - version = parse_version(match.group(1)) + raw_version = match.group(1) + version = parse_version(raw_version) if min_ver is not None and version < parse_version(min_ver): raise ExecutableNotFoundError( f"You have {args[0]} version {version} but the minimum " f"version supported by Matplotlib is {min_ver}") - return _ExecInfo(args[0], version) + return _ExecInfo(args[0], raw_version, version) else: raise ExecutableNotFoundError( f"Failed to determine the version of {args[0]} from " f"{' '.join(args)}, which output {output}") + if name in os.environ.get("_MPLHIDEEXECUTABLES", "").split(","): + raise ExecutableNotFoundError(f"{name} was hidden") + if name == "dvipng": return impl(["dvipng", "-version"], "(?m)^dvipng(?: .*)? (.+)", "1.6") elif name == "gs": @@ -401,11 +440,13 @@ def impl(args, regex, min_ver=None, ignore_exit_code=False): else: path = "convert" info = impl([path, "--version"], r"^Version: ImageMagick (\S*)") - if info.version == parse_version("7.0.10-34"): + if info.raw_version == "7.0.10-34": # https://github.com/ImageMagick/ImageMagick/issues/2720 raise ExecutableNotFoundError( f"You have ImageMagick {info.version}, which is unsupported") return info + elif name == "pdftocairo": + return impl(["pdftocairo", "-v"], "pdftocairo version (.*)") elif name == "pdftops": info = impl(["pdftops", "-v"], "^pdftops version (.*)", ignore_exit_code=True) @@ -421,6 +462,7 @@ def impl(args, regex, min_ver=None, ignore_exit_code=False): raise ValueError("Unknown executable: {!r}".format(name)) +@_api.deprecated("3.6", alternative="a vendored copy of this function") def checkdep_usetex(s): if not s: return False @@ -575,32 +617,21 @@ def gen_candidates(): # rcParams deprecated and automatically mapped to another key. # Values are tuples of (version, new_name, f_old2new, f_new2old). _deprecated_map = {} - # rcParams deprecated; some can manually be mapped to another key. # Values are tuples of (version, new_name_or_None). -_deprecated_ignore_map = { - 'mpl_toolkits.legacy_colorbar': ('3.4', None), -} - +_deprecated_ignore_map = {} # rcParams deprecated; can use None to suppress warnings; remain actually -# listed in the rcParams (not included in _all_deprecated). +# listed in the rcParams. # Values are tuples of (version,) -_deprecated_remain_as_none = { - 'animation.avconv_path': ('3.3',), - 'animation.avconv_args': ('3.3',), - 'animation.html_args': ('3.3',), -} - +_deprecated_remain_as_none = {} -_all_deprecated = {*_deprecated_map, *_deprecated_ignore_map} - -@docstring.Substitution( +@_docstring.Substitution( "\n".join(map("- {}".format, sorted(rcsetup._validators, key=str.lower))) ) class RcParams(MutableMapping, dict): """ - A dictionary object including validation. + A dict-like key-value store for config parameters, including validation. Validating functions are defined and associated with rc parameters in :mod:`matplotlib.rcsetup`. @@ -620,6 +651,47 @@ class RcParams(MutableMapping, dict): def __init__(self, *args, **kwargs): self.update(*args, **kwargs) + def _set(self, key, val): + """ + Directly write data bypassing deprecation and validation logic. + + Notes + ----- + As end user or downstream library you almost always should use + ``rcParams[key] = val`` and not ``_set()``. + + There are only very few special cases that need direct data access. + These cases previously used ``dict.__setitem__(rcParams, key, val)``, + which is now deprecated and replaced by ``rcParams._set(key, val)``. + + Even though private, we guarantee API stability for ``rcParams._set``, + i.e. it is subject to Matplotlib's API and deprecation policy. + + :meta public: + """ + dict.__setitem__(self, key, val) + + def _get(self, key): + """ + Directly read data bypassing deprecation, backend and validation + logic. + + Notes + ----- + As end user or downstream library you almost always should use + ``val = rcParams[key]`` and not ``_get()``. + + There are only very few special cases that need direct data access. + These cases previously used ``dict.__getitem__(rcParams, key, val)``, + which is now deprecated and replaced by ``rcParams._get(key)``. + + Even though private, we guarantee API stability for ``rcParams._get``, + i.e. it is subject to Matplotlib's API and deprecation policy. + + :meta public: + """ + return dict.__getitem__(self, key) + def __setitem__(self, key, val): try: if key in _deprecated_map: @@ -644,7 +716,7 @@ def __setitem__(self, key, val): cval = self.validate[key](val) except ValueError as ve: raise ValueError(f"Key {key}: {ve}") from None - dict.__setitem__(self, key, cval) + self._set(key, cval) except KeyError as err: raise KeyError( f"{key} is not a valid rc parameter (see rcParams.keys() for " @@ -655,23 +727,28 @@ def __getitem__(self, key): version, alt_key, alt_val, inverse_alt = _deprecated_map[key] _api.warn_deprecated( version, name=key, obj_type="rcparam", alternative=alt_key) - return inverse_alt(dict.__getitem__(self, alt_key)) + return inverse_alt(self._get(alt_key)) elif key in _deprecated_ignore_map: version, alt_key = _deprecated_ignore_map[key] _api.warn_deprecated( version, name=key, obj_type="rcparam", alternative=alt_key) - return dict.__getitem__(self, alt_key) if alt_key else None + return self._get(alt_key) if alt_key else None # In theory, this should only ever be used after the global rcParams # has been set up, but better be safe e.g. in presence of breakpoints. elif key == "backend" and self is globals().get("rcParams"): - val = dict.__getitem__(self, key) + val = self._get(key) if val is rcsetup._auto_backend_sentinel: from matplotlib import pyplot as plt plt.switch_backend(rcsetup._auto_backend_sentinel) - return dict.__getitem__(self, key) + return self._get(key) + + def _get_backend_or_none(self): + """Get the requested backend, if any, without triggering resolution.""" + backend = self._get("backend") + return None if backend is rcsetup._auto_backend_sentinel else backend def __repr__(self): class_name = self.__class__.__name__ @@ -710,7 +787,11 @@ def find_all(self, pattern): if pattern_re.search(key)) def copy(self): - return {k: dict.__getitem__(self, k) for k in self} + """Copy this RcParams instance.""" + rccopy = RcParams() + for k in self: # Skip deprecations and revalidation. + rccopy._set(k, self._get(k)) + return rccopy def rc_params(fail_on_error=False): @@ -718,12 +799,6 @@ def rc_params(fail_on_error=False): return rc_params_from_file(matplotlib_fname(), fail_on_error) -@_api.deprecated("3.5") -def is_url(filename): - """Return whether *filename* is an http, https, ftp, or file URL path.""" - return __getattr__("URL_REGEX").match(filename) is not None - - @functools.lru_cache() def _get_ssl_context(): try: @@ -749,10 +824,7 @@ def _open_file_or_url(fname): yield (line.decode('utf-8') for line in f) else: fname = os.path.expanduser(fname) - encoding = locale.getpreferredencoding(do_setlocale=False) - if encoding is None: - encoding = "utf-8" - with open(fname, encoding=encoding) as f: + with open(fname, encoding='utf-8') as f: yield f @@ -779,7 +851,7 @@ def _rc_params_in_file(fname, transform=lambda x: x, fail_on_error=False): try: for line_no, line in enumerate(fd, 1): line = transform(line) - strippedline = line.split('#', 1)[0].strip() + strippedline = cbook._strip_comment(line) if not strippedline: continue tup = strippedline.split(':', 1) @@ -790,16 +862,15 @@ def _rc_params_in_file(fname, transform=lambda x: x, fail_on_error=False): key, val = tup key = key.strip() val = val.strip() + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] # strip double quotes if key in rc_temp: _log.warning('Duplicate key in file %r, line %d (%r)', fname, line_no, line.rstrip('\n')) rc_temp[key] = (val, line, line_no) except UnicodeDecodeError: - _log.warning('Cannot decode configuration file %s with encoding ' - '%s, check LANG and LC_* variables.', - fname, - locale.getpreferredencoding(do_setlocale=False) - or 'utf-8 (default)') + _log.warning('Cannot decode configuration file %r as utf-8.', + fname) raise config = RcParams() @@ -822,7 +893,7 @@ def _rc_params_in_file(fname, transform=lambda x: x, fail_on_error=False): else: # __version__ must be looked up as an attribute to trigger the # module-level __getattr__. - version = ('master' if '.post' in mpl.__version__ + version = ('main' if '.post' in mpl.__version__ else f'v{mpl.__version__}') _log.warning(""" Bad key %(key)s in file %(fname)s, line %(line_no)s (%(line)r) @@ -888,8 +959,8 @@ def rc_params_from_file(fname, fail_on_error=False, use_default_template=True): rcParams = RcParams() # The global instance. dict.update(rcParams, dict.items(rcParamsDefault)) dict.update(rcParams, _rc_params_in_file(matplotlib_fname())) +rcParamsOrig = rcParams.copy() with _api.suppress_matplotlib_deprecation_warning(): - rcParamsOrig = RcParams(rcParams.copy()) # This also checks that all rcParams are indeed listed in the template. # Assigning to rcsetup.defaultParams is left only for backcompat. defaultParams = rcsetup.defaultParams = { @@ -983,7 +1054,7 @@ def rcdefaults(): Restore the `.rcParams` from Matplotlib's internal default style. Style-blacklisted `.rcParams` (defined in - `matplotlib.style.core.STYLE_BLACKLIST`) are not updated. + ``matplotlib.style.core.STYLE_BLACKLIST``) are not updated. See Also -------- @@ -1008,7 +1079,7 @@ def rc_file_defaults(): Restore the `.rcParams` from the original rc file loaded by Matplotlib. Style-blacklisted `.rcParams` (defined in - `matplotlib.style.core.STYLE_BLACKLIST`) are not updated. + ``matplotlib.style.core.STYLE_BLACKLIST``) are not updated. """ # Deprecation warnings were already handled when creating rcParamsOrig, no # need to reemit them here. @@ -1023,7 +1094,7 @@ def rc_file(fname, *, use_default_template=True): Update `.rcParams` from file. Style-blacklisted `.rcParams` (defined in - `matplotlib.style.core.STYLE_BLACKLIST`) are not updated. + ``matplotlib.style.core.STYLE_BLACKLIST``) are not updated. Parameters ---------- @@ -1050,6 +1121,11 @@ def rc_context(rc=None, fname=None): """ Return a context manager for temporarily changing rcParams. + The :rc:`backend` will not be reset by the context manager. + + rcParams changed both through the context manager invocation and + in the body of the context will be reset on context exit. + Parameters ---------- rc : dict @@ -1077,8 +1153,16 @@ def rc_context(rc=None, fname=None): with mpl.rc_context(fname='print.rc'): plt.plot(x, y) # uses 'print.rc' + Setting in the context body:: + + with mpl.rc_context(): + # will be reset + mpl.rcParams['lines.linewidth'] = 5 + plt.plot(x, y) + """ - orig = rcParams.copy() + orig = dict(rcParams.copy()) + del orig['backend'] try: if fname: rc_file(fname) @@ -1093,6 +1177,10 @@ def use(backend, *, force=True): """ Select the backend used for rendering and GUI integration. + If pyplot is already imported, `~matplotlib.pyplot.switch_backend` is used + and if the new backend is different than the current backend, all Figures + will be closed. + Parameters ---------- backend : str @@ -1123,11 +1211,12 @@ def use(backend, *, force=True): -------- :ref:`backends` matplotlib.get_backend + matplotlib.pyplot.switch_backend + """ name = validate_backend(backend) - # we need to use the base-class method here to avoid (prematurely) - # resolving the "auto" backend setting - if dict.__getitem__(rcParams, 'backend') == name: + # don't (prematurely) resolve the "auto" backend setting + if rcParams._get_backend_or_none() == name: # Nothing to do if the requested backend is already set pass else: @@ -1189,12 +1278,6 @@ def is_interactive(): return rcParams['interactive'] -default_test_modules = [ - 'matplotlib.tests', - 'mpl_toolkits.tests', -] - - def _init_tests(): # The version of FreeType to install locally for running the # tests. This must match the value in `setupext.py` @@ -1213,59 +1296,6 @@ def _init_tests(): "" if ft2font.__freetype_build_type__ == 'local' else "not ")) -@_api.deprecated("3.5", alternative='pytest') -def test(verbosity=None, coverage=False, **kwargs): - """Run the matplotlib test suite.""" - - try: - import pytest - except ImportError: - print("matplotlib.test requires pytest to run.") - return -1 - - if not os.path.isdir(os.path.join(os.path.dirname(__file__), 'tests')): - print("Matplotlib test data is not installed") - return -1 - - old_backend = get_backend() - old_recursionlimit = sys.getrecursionlimit() - try: - use('agg') - - args = kwargs.pop('argv', []) - provide_default_modules = True - use_pyargs = True - for arg in args: - if any(arg.startswith(module_path) - for module_path in default_test_modules): - provide_default_modules = False - break - if os.path.exists(arg): - provide_default_modules = False - use_pyargs = False - break - if use_pyargs: - args += ['--pyargs'] - if provide_default_modules: - args += default_test_modules - - if coverage: - args += ['--cov'] - - if verbosity: - args += ['-' + 'v' * verbosity] - - retcode = pytest.main(args, **kwargs) - finally: - if old_backend.lower() != 'agg': - use(old_backend) - - return retcode - - -test.__test__ = False # pytest: this function is not a test - - def _replacer(data, value): """ Either returns ``data[value]`` or passes ``data`` back, converts either to @@ -1444,9 +1474,9 @@ def inner(ax, *args, data=None, **kwargs): _log.debug('interactive is %s', is_interactive()) _log.debug('platform is %s', sys.platform) -_log.debug('loaded modules: %s', list(sys.modules)) # workaround: we must defer colormaps import to after loading rcParams, because # colormap creation depends on rcParams from matplotlib.cm import _colormaps as colormaps +from matplotlib.colors import _color_sequences as color_sequences diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py new file mode 100644 index 000000000000..3d02d7f9c1d6 --- /dev/null +++ b/lib/matplotlib/_afm.py @@ -0,0 +1,532 @@ +""" +A python interface to Adobe Font Metrics Files. + +Although a number of other Python implementations exist, and may be more +complete than this, it was decided not to go with them because they were +either: + +1) copyrighted or used a non-BSD compatible license +2) had too many dependencies and a free standing lib was needed +3) did more than needed and it was easier to write afresh rather than + figure out how to get just what was needed. + +It is pretty easy to use, and has no external dependencies: + +>>> import matplotlib as mpl +>>> from pathlib import Path +>>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') +>>> +>>> from matplotlib.afm import AFM +>>> with afm_path.open('rb') as fh: +... afm = AFM(fh) +>>> afm.string_width_height('What the heck?') +(6220.0, 694) +>>> afm.get_fontname() +'Times-Roman' +>>> afm.get_kern_dist('A', 'f') +0 +>>> afm.get_kern_dist('A', 'y') +-92.0 +>>> afm.get_bbox_char('!') +[130, -9, 238, 676] + +As in the Adobe Font Metrics File Format Specification, all dimensions +are given in units of 1/1000 of the scale factor (point size) of the font +being used. +""" + +from collections import namedtuple +import logging +import re + +from ._mathtext_data import uni2type1 + + +_log = logging.getLogger(__name__) + + +def _to_int(x): + # Some AFM files have floats where we are expecting ints -- there is + # probably a better way to handle this (support floats, round rather than + # truncate). But I don't know what the best approach is now and this + # change to _to_int should at least prevent Matplotlib from crashing on + # these. JDH (2009-11-06) + return int(float(x)) + + +def _to_float(x): + # Some AFM files use "," instead of "." as decimal separator -- this + # shouldn't be ambiguous (unless someone is wicked enough to use "," as + # thousands separator...). + if isinstance(x, bytes): + # Encoding doesn't really matter -- if we have codepoints >127 the call + # to float() will error anyways. + x = x.decode('latin-1') + return float(x.replace(',', '.')) + + +def _to_str(x): + return x.decode('utf8') + + +def _to_list_of_ints(s): + s = s.replace(b',', b' ') + return [_to_int(val) for val in s.split()] + + +def _to_list_of_floats(s): + return [_to_float(val) for val in s.split()] + + +def _to_bool(s): + if s.lower().strip() in (b'false', b'0', b'no'): + return False + else: + return True + + +def _parse_header(fh): + """ + Read the font metrics header (up to the char metrics) and returns + a dictionary mapping *key* to *val*. *val* will be converted to the + appropriate python type as necessary; e.g.: + + * 'False'->False + * '0'->0 + * '-168 -218 1000 898'-> [-168, -218, 1000, 898] + + Dictionary keys are + + StartFontMetrics, FontName, FullName, FamilyName, Weight, + ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, + UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, + XHeight, Ascender, Descender, StartCharMetrics + """ + header_converters = { + b'StartFontMetrics': _to_float, + b'FontName': _to_str, + b'FullName': _to_str, + b'FamilyName': _to_str, + b'Weight': _to_str, + b'ItalicAngle': _to_float, + b'IsFixedPitch': _to_bool, + b'FontBBox': _to_list_of_ints, + b'UnderlinePosition': _to_float, + b'UnderlineThickness': _to_float, + b'Version': _to_str, + # Some AFM files have non-ASCII characters (which are not allowed by + # the spec). Given that there is actually no public API to even access + # this field, just return it as straight bytes. + b'Notice': lambda x: x, + b'EncodingScheme': _to_str, + b'CapHeight': _to_float, # Is the second version a mistake, or + b'Capheight': _to_float, # do some AFM files contain 'Capheight'? -JKS + b'XHeight': _to_float, + b'Ascender': _to_float, + b'Descender': _to_float, + b'StdHW': _to_float, + b'StdVW': _to_float, + b'StartCharMetrics': _to_int, + b'CharacterSet': _to_str, + b'Characters': _to_int, + } + d = {} + first_line = True + for line in fh: + line = line.rstrip() + if line.startswith(b'Comment'): + continue + lst = line.split(b' ', 1) + key = lst[0] + if first_line: + # AFM spec, Section 4: The StartFontMetrics keyword + # [followed by a version number] must be the first line in + # the file, and the EndFontMetrics keyword must be the + # last non-empty line in the file. We just check the + # first header entry. + if key != b'StartFontMetrics': + raise RuntimeError('Not an AFM file') + first_line = False + if len(lst) == 2: + val = lst[1] + else: + val = b'' + try: + converter = header_converters[key] + except KeyError: + _log.error('Found an unknown keyword in AFM header (was %r)' % key) + continue + try: + d[key] = converter(val) + except ValueError: + _log.error('Value error parsing header in AFM: %s, %s', key, val) + continue + if key == b'StartCharMetrics': + break + else: + raise RuntimeError('Bad parse') + return d + + +CharMetrics = namedtuple('CharMetrics', 'width, name, bbox') +CharMetrics.__doc__ = """ + Represents the character metrics of a single character. + + Notes + ----- + The fields do currently only describe a subset of character metrics + information defined in the AFM standard. + """ +CharMetrics.width.__doc__ = """The character width (WX).""" +CharMetrics.name.__doc__ = """The character name (N).""" +CharMetrics.bbox.__doc__ = """ + The bbox of the character (B) as a tuple (*llx*, *lly*, *urx*, *ury*).""" + + +def _parse_char_metrics(fh): + """ + Parse the given filehandle for character metrics information and return + the information as dicts. + + It is assumed that the file cursor is on the line behind + 'StartCharMetrics'. + + Returns + ------- + ascii_d : dict + A mapping "ASCII num of the character" to `.CharMetrics`. + name_d : dict + A mapping "character name" to `.CharMetrics`. + + Notes + ----- + This function is incomplete per the standard, but thus far parses + all the sample afm files tried. + """ + required_keys = {'C', 'WX', 'N', 'B'} + + ascii_d = {} + name_d = {} + for line in fh: + # We are defensively letting values be utf8. The spec requires + # ascii, but there are non-compliant fonts in circulation + line = _to_str(line.rstrip()) # Convert from byte-literal + if line.startswith('EndCharMetrics'): + return ascii_d, name_d + # Split the metric line into a dictionary, keyed by metric identifiers + vals = dict(s.strip().split(' ', 1) for s in line.split(';') if s) + # There may be other metrics present, but only these are needed + if not required_keys.issubset(vals): + raise RuntimeError('Bad char metrics line: %s' % line) + num = _to_int(vals['C']) + wx = _to_float(vals['WX']) + name = vals['N'] + bbox = _to_list_of_floats(vals['B']) + bbox = list(map(int, bbox)) + metrics = CharMetrics(wx, name, bbox) + # Workaround: If the character name is 'Euro', give it the + # corresponding character code, according to WinAnsiEncoding (see PDF + # Reference). + if name == 'Euro': + num = 128 + elif name == 'minus': + num = ord("\N{MINUS SIGN}") # 0x2212 + if num != -1: + ascii_d[num] = metrics + name_d[name] = metrics + raise RuntimeError('Bad parse') + + +def _parse_kern_pairs(fh): + """ + Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and + values are the kern pair value. For example, a kern pairs line like + ``KPX A y -50`` + + will be represented as:: + + d[ ('A', 'y') ] = -50 + + """ + + line = next(fh) + if not line.startswith(b'StartKernPairs'): + raise RuntimeError('Bad start of kern pairs data: %s' % line) + + d = {} + for line in fh: + line = line.rstrip() + if not line: + continue + if line.startswith(b'EndKernPairs'): + next(fh) # EndKernData + return d + vals = line.split() + if len(vals) != 4 or vals[0] != b'KPX': + raise RuntimeError('Bad kern pairs line: %s' % line) + c1, c2, val = _to_str(vals[1]), _to_str(vals[2]), _to_float(vals[3]) + d[(c1, c2)] = val + raise RuntimeError('Bad kern pairs parse') + + +CompositePart = namedtuple('CompositePart', 'name, dx, dy') +CompositePart.__doc__ = """ + Represents the information on a composite element of a composite char.""" +CompositePart.name.__doc__ = """Name of the part, e.g. 'acute'.""" +CompositePart.dx.__doc__ = """x-displacement of the part from the origin.""" +CompositePart.dy.__doc__ = """y-displacement of the part from the origin.""" + + +def _parse_composites(fh): + """ + Parse the given filehandle for composites information return them as a + dict. + + It is assumed that the file cursor is on the line behind 'StartComposites'. + + Returns + ------- + dict + A dict mapping composite character names to a parts list. The parts + list is a list of `.CompositePart` entries describing the parts of + the composite. + + Examples + -------- + A composite definition line:: + + CC Aacute 2 ; PCC A 0 0 ; PCC acute 160 170 ; + + will be represented as:: + + composites['Aacute'] = [CompositePart(name='A', dx=0, dy=0), + CompositePart(name='acute', dx=160, dy=170)] + + """ + composites = {} + for line in fh: + line = line.rstrip() + if not line: + continue + if line.startswith(b'EndComposites'): + return composites + vals = line.split(b';') + cc = vals[0].split() + name, _num_parts = cc[1], _to_int(cc[2]) + pccParts = [] + for s in vals[1:-1]: + pcc = s.split() + part = CompositePart(pcc[1], _to_float(pcc[2]), _to_float(pcc[3])) + pccParts.append(part) + composites[name] = pccParts + + raise RuntimeError('Bad composites parse') + + +def _parse_optional(fh): + """ + Parse the optional fields for kern pair data and composites. + + Returns + ------- + kern_data : dict + A dict containing kerning information. May be empty. + See `._parse_kern_pairs`. + composites : dict + A dict containing composite information. May be empty. + See `._parse_composites`. + """ + optional = { + b'StartKernData': _parse_kern_pairs, + b'StartComposites': _parse_composites, + } + + d = {b'StartKernData': {}, + b'StartComposites': {}} + for line in fh: + line = line.rstrip() + if not line: + continue + key = line.split()[0] + + if key in optional: + d[key] = optional[key](fh) + + return d[b'StartKernData'], d[b'StartComposites'] + + +class AFM: + + def __init__(self, fh): + """Parse the AFM file in file object *fh*.""" + self._header = _parse_header(fh) + self._metrics, self._metrics_by_name = _parse_char_metrics(fh) + self._kern, self._composite = _parse_optional(fh) + + def get_bbox_char(self, c, isord=False): + if not isord: + c = ord(c) + return self._metrics[c].bbox + + def string_width_height(self, s): + """ + Return the string width (including kerning) and string height + as a (*w*, *h*) tuple. + """ + if not len(s): + return 0, 0 + total_width = 0 + namelast = None + miny = 1e9 + maxy = 0 + for c in s: + if c == '\n': + continue + wx, name, bbox = self._metrics[ord(c)] + + total_width += wx + self._kern.get((namelast, name), 0) + l, b, w, h = bbox + miny = min(miny, b) + maxy = max(maxy, b + h) + + namelast = name + + return total_width, maxy - miny + + def get_str_bbox_and_descent(self, s): + """Return the string bounding box and the maximal descent.""" + if not len(s): + return 0, 0, 0, 0, 0 + total_width = 0 + namelast = None + miny = 1e9 + maxy = 0 + left = 0 + if not isinstance(s, str): + s = _to_str(s) + for c in s: + if c == '\n': + continue + name = uni2type1.get(ord(c), f"uni{ord(c):04X}") + try: + wx, _, bbox = self._metrics_by_name[name] + except KeyError: + name = 'question' + wx, _, bbox = self._metrics_by_name[name] + total_width += wx + self._kern.get((namelast, name), 0) + l, b, w, h = bbox + left = min(left, l) + miny = min(miny, b) + maxy = max(maxy, b + h) + + namelast = name + + return left, miny, total_width, maxy - miny, -miny + + def get_str_bbox(self, s): + """Return the string bounding box.""" + return self.get_str_bbox_and_descent(s)[:4] + + def get_name_char(self, c, isord=False): + """Get the name of the character, i.e., ';' is 'semicolon'.""" + if not isord: + c = ord(c) + return self._metrics[c].name + + def get_width_char(self, c, isord=False): + """ + Get the width of the character from the character metric WX field. + """ + if not isord: + c = ord(c) + return self._metrics[c].width + + def get_width_from_char_name(self, name): + """Get the width of the character from a type1 character name.""" + return self._metrics_by_name[name].width + + def get_height_char(self, c, isord=False): + """Get the bounding box (ink) height of character *c* (space is 0).""" + if not isord: + c = ord(c) + return self._metrics[c].bbox[-1] + + def get_kern_dist(self, c1, c2): + """ + Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. + """ + name1, name2 = self.get_name_char(c1), self.get_name_char(c2) + return self.get_kern_dist_from_name(name1, name2) + + def get_kern_dist_from_name(self, name1, name2): + """ + Return the kerning pair distance (possibly 0) for chars + *name1* and *name2*. + """ + return self._kern.get((name1, name2), 0) + + def get_fontname(self): + """Return the font name, e.g., 'Times-Roman'.""" + return self._header[b'FontName'] + + @property + def postscript_name(self): # For consistency with FT2Font. + return self.get_fontname() + + def get_fullname(self): + """Return the font full name, e.g., 'Times-Roman'.""" + name = self._header.get(b'FullName') + if name is None: # use FontName as a substitute + name = self._header[b'FontName'] + return name + + def get_familyname(self): + """Return the font family name, e.g., 'Times'.""" + name = self._header.get(b'FamilyName') + if name is not None: + return name + + # FamilyName not specified so we'll make a guess + name = self.get_fullname() + extras = (r'(?i)([ -](regular|plain|italic|oblique|bold|semibold|' + r'light|ultralight|extra|condensed))+$') + return re.sub(extras, '', name) + + @property + def family_name(self): + """The font family name, e.g., 'Times'.""" + return self.get_familyname() + + def get_weight(self): + """Return the font weight, e.g., 'Bold' or 'Roman'.""" + return self._header[b'Weight'] + + def get_angle(self): + """Return the fontangle as float.""" + return self._header[b'ItalicAngle'] + + def get_capheight(self): + """Return the cap height as float.""" + return self._header[b'CapHeight'] + + def get_xheight(self): + """Return the xheight as float.""" + return self._header[b'XHeight'] + + def get_underline_thickness(self): + """Return the underline thickness as float.""" + return self._header[b'UnderlineThickness'] + + def get_horizontal_stem_width(self): + """ + Return the standard horizontal stem width as float, or *None* if + not specified in AFM file. + """ + return self._header.get(b'StdHW', None) + + def get_vertical_stem_width(self): + """ + Return the standard vertical stem width as float, or *None* if + not specified in AFM file. + """ + return self._header.get(b'StdVW', None) diff --git a/lib/matplotlib/_animation_data.py b/lib/matplotlib/_animation_data.py index d30649cff1c8..4bf2ae3148d2 100644 --- a/lib/matplotlib/_animation_data.py +++ b/lib/matplotlib/_animation_data.py @@ -1,4 +1,4 @@ -# Javascript template for HTMLWriter +# JavaScript template for HTMLWriter JS_INCLUDE = """ diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py index 51425fdb5030..068abb945181 100644 --- a/lib/matplotlib/_api/__init__.py +++ b/lib/matplotlib/_api/__init__.py @@ -120,6 +120,8 @@ def check_in_list(_values, *, _print_supported_values=True, **kwargs): -------- >>> _api.check_in_list(["foo", "bar"], arg=arg, other_arg=other_arg) """ + if not kwargs: + raise TypeError("No argument to check!") values = _values for key, val in kwargs.items(): if val not in values: @@ -160,6 +162,8 @@ def check_shape(_shape, **kwargs): if n is not None else next(dim_labels) for n in target_shape)) + if len(target_shape) == 1: + text_shape += "," raise ValueError( f"{k!r} must be {len(target_shape)}D " @@ -225,6 +229,61 @@ def __getattr__(name): return __getattr__ +def define_aliases(alias_d, cls=None): + """ + Class decorator for defining property aliases. + + Use as :: + + @_api.define_aliases({"property": ["alias", ...], ...}) + class C: ... + + For each property, if the corresponding ``get_property`` is defined in the + class so far, an alias named ``get_alias`` will be defined; the same will + be done for setters. If neither the getter nor the setter exists, an + exception will be raised. + + The alias map is stored as the ``_alias_map`` attribute on the class and + can be used by `.normalize_kwargs` (which assumes that higher priority + aliases come last). + """ + if cls is None: # Return the actual class decorator. + return functools.partial(define_aliases, alias_d) + + def make_alias(name): # Enforce a closure over *name*. + @functools.wraps(getattr(cls, name)) + def method(self, *args, **kwargs): + return getattr(self, name)(*args, **kwargs) + return method + + for prop, aliases in alias_d.items(): + exists = False + for prefix in ["get_", "set_"]: + if prefix + prop in vars(cls): + exists = True + for alias in aliases: + method = make_alias(prefix + prop) + method.__name__ = prefix + alias + method.__doc__ = "Alias for `{}`.".format(prefix + prop) + setattr(cls, prefix + alias, method) + if not exists: + raise ValueError( + "Neither getter nor setter exists for {!r}".format(prop)) + + def get_aliased_and_aliases(d): + return {*d, *(alias for aliases in d.values() for alias in aliases)} + + preexisting_aliases = getattr(cls, "_alias_map", {}) + conflicting = (get_aliased_and_aliases(preexisting_aliases) + & get_aliased_and_aliases(alias_d)) + if conflicting: + # Need to decide on conflict resolution policy. + raise NotImplementedError( + f"Parent class already defines conflicting aliases: {conflicting}") + cls._alias_map = {**preexisting_aliases, **alias_d} + return cls + + def select_matching_signature(funcs, *args, **kwargs): """ Select and call the function that accepts ``*args, **kwargs``. @@ -277,6 +336,36 @@ def my_func(*args, **kwargs): raise +def nargs_error(name, takes, given): + """Generate a TypeError to be raised by function calls with wrong arity.""" + return TypeError(f"{name}() takes {takes} positional arguments but " + f"{given} were given") + + +def kwarg_error(name, kw): + """ + Generate a TypeError to be raised by function calls with wrong kwarg. + + Parameters + ---------- + name : str + The name of the calling function. + kw : str or Iterable[str] + Either the invalid keyword argument name, or an iterable yielding + invalid keyword arguments (e.g., a ``kwargs`` dict). + """ + if not isinstance(kw, str): + kw = next(iter(kw)) + return TypeError(f"{name}() got an unexpected keyword argument '{kw}'") + + +def recursive_subclasses(cls): + """Yield *cls* and direct and indirect subclasses of *cls*.""" + yield cls + for subcls in cls.__subclasses__(): + yield from recursive_subclasses(subcls) + + def warn_external(message, category=None): """ `warnings.warn` wrapper that sets *stacklevel* to "outside Matplotlib". diff --git a/lib/matplotlib/_api/deprecation.py b/lib/matplotlib/_api/deprecation.py index 0eacc3730cdc..7c304173b2e5 100644 --- a/lib/matplotlib/_api/deprecation.py +++ b/lib/matplotlib/_api/deprecation.py @@ -21,11 +21,6 @@ class MatplotlibDeprecationWarning(DeprecationWarning): """A class for issuing deprecation warnings for Matplotlib users.""" -# mplDeprecation is deprecated. Use MatplotlibDeprecationWarning instead. -# remove when removing the re-import from cbook -mplDeprecation = MatplotlibDeprecationWarning - - def _generate_deprecation_warning( since, message='', name='', alternative='', pending=False, obj_type='', addendum='', *, removal=''): @@ -37,7 +32,7 @@ def _generate_deprecation_warning( removal = f"in {removal}" if removal else "two minor releases later" if not message: message = ( - ("\nThe %(name)s %(obj_type)s" if obj_type else "%(name)s") + ("The %(name)s %(obj_type)s" if obj_type else "%(name)s") + (" will be deprecated in a future version" if pending else (" was deprecated in Matplotlib %(since)s" diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index c998681b1db3..9554a156f1ec 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -11,20 +11,7 @@ layout. Axes manually placed via ``figure.add_axes()`` will not. See Tutorial: :doc:`/tutorials/intermediate/constrainedlayout_guide` -""" - -import logging - -import numpy as np - -from matplotlib import _api -import matplotlib.transforms as mtransforms -import matplotlib._layoutgrid as mlayoutgrid - -_log = logging.getLogger(__name__) - -""" General idea: ------------- @@ -33,8 +20,8 @@ often just set to 1 for an equal grid. Subplotspecs that are derived from this gridspec can contain either a -``SubPanel``, a ``GridSpecFromSubplotSpec``, or an axes. The ``SubPanel`` and -``GridSpecFromSubplotSpec`` are dealt with recursively and each contain an +``SubPanel``, a ``GridSpecFromSubplotSpec``, or an ``Axes``. The ``SubPanel`` +and ``GridSpecFromSubplotSpec`` are dealt with recursively and each contain an analogous layout. Each ``GridSpec`` has a ``_layoutgrid`` attached to it. The ``_layoutgrid`` @@ -60,10 +47,22 @@ for more discussion of the algorithm with examples. """ +import logging + +import numpy as np + +from matplotlib import _api, artist as martist +import matplotlib.transforms as mtransforms +import matplotlib._layoutgrid as mlayoutgrid + + +_log = logging.getLogger(__name__) + ###################################################### -def do_constrained_layout(fig, renderer, h_pad, w_pad, - hspace=None, wspace=None): +def do_constrained_layout(fig, h_pad, w_pad, + hspace=None, wspace=None, rect=(0, 0, 1, 1), + compress=False): """ Do the constrained_layout. Called at draw time in ``figure.constrained_layout()`` @@ -86,13 +85,23 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, of 0.1 of the figure width between each column. If h/wspace < h/w_pad, then the pads are used instead. + rect : tuple of 4 floats + Rectangle in figure coordinates to perform constrained layout in + [left, bottom, width, height], each from 0-1. + + compress : bool + Whether to shift Axes so that white space in between them is + removed. This is useful for simple grids of fixed-aspect Axes (e.g. + a grid of images). + Returns ------- layoutgrid : private debugging structure """ + renderer = fig._get_renderer() # make layoutgrid tree... - layoutgrids = make_layoutgrids(fig, None) + layoutgrids = make_layoutgrids(fig, None, rect=rect) if not layoutgrids['hasgrids']: _api.warn_external('There are no gridspecs with layoutgrids. ' 'Possibly did not call parent GridSpec with the' @@ -120,18 +129,27 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, # update all the variables in the layout. layoutgrids[fig].update_variables() + warn_collapsed = ('constrained_layout not applied because ' + 'axes sizes collapsed to zero. Try making ' + 'figure larger or axes decorations smaller.') if check_no_collapsed_axes(layoutgrids, fig): reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad, w_pad=w_pad, hspace=hspace, wspace=wspace) + if compress: + layoutgrids = compress_fixed_aspect(layoutgrids, fig) + layoutgrids[fig].update_variables() + if check_no_collapsed_axes(layoutgrids, fig): + reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad, + w_pad=w_pad, hspace=hspace, wspace=wspace) + else: + _api.warn_external(warn_collapsed) else: - _api.warn_external('constrained_layout not applied because ' - 'axes sizes collapsed to zero. Try making ' - 'figure larger or axes decorations smaller.') + _api.warn_external(warn_collapsed) reset_margins(layoutgrids, fig) return layoutgrids -def make_layoutgrids(fig, layoutgrids): +def make_layoutgrids(fig, layoutgrids, rect=(0, 0, 1, 1)): """ Make the layoutgrid tree. @@ -145,8 +163,9 @@ def make_layoutgrids(fig, layoutgrids): layoutgrids = dict() layoutgrids['hasgrids'] = False if not hasattr(fig, '_parent'): - # top figure - layoutgrids[fig] = mlayoutgrid.LayoutGrid(parent=None, name='figlb') + # top figure; pass rect as parent to allow user-specified + # margins + layoutgrids[fig] = mlayoutgrid.LayoutGrid(parent=rect, name='figlb') else: # subfigure gs = fig._subplotspec.get_gridspec() @@ -167,9 +186,9 @@ def make_layoutgrids(fig, layoutgrids): layoutgrids = make_layoutgrids(sfig, layoutgrids) # for each axes at the local level add its gridspec: - for ax in fig._localaxes.as_list(): - if hasattr(ax, 'get_subplotspec'): - gs = ax.get_subplotspec().get_gridspec() + for ax in fig._localaxes: + gs = ax.get_gridspec() + if gs is not None: layoutgrids = make_layoutgrids_gs(layoutgrids, gs) return layoutgrids @@ -229,20 +248,55 @@ def check_no_collapsed_axes(layoutgrids, fig): ok = check_no_collapsed_axes(layoutgrids, sfig) if not ok: return False - for ax in fig.axes: - if hasattr(ax, 'get_subplotspec'): - gs = ax.get_subplotspec().get_gridspec() - if gs in layoutgrids: - lg = layoutgrids[gs] - for i in range(gs.nrows): - for j in range(gs.ncols): - bb = lg.get_inner_bbox(i, j) - if bb.width <= 0 or bb.height <= 0: - return False + gs = ax.get_gridspec() + if gs in layoutgrids: # also implies gs is not None. + lg = layoutgrids[gs] + for i in range(gs.nrows): + for j in range(gs.ncols): + bb = lg.get_inner_bbox(i, j) + if bb.width <= 0 or bb.height <= 0: + return False return True +def compress_fixed_aspect(layoutgrids, fig): + gs = None + for ax in fig.axes: + if ax.get_subplotspec() is None: + continue + ax.apply_aspect() + sub = ax.get_subplotspec() + _gs = sub.get_gridspec() + if gs is None: + gs = _gs + extraw = np.zeros(gs.ncols) + extrah = np.zeros(gs.nrows) + elif _gs != gs: + raise ValueError('Cannot do compressed layout if axes are not' + 'all from the same gridspec') + orig = ax.get_position(original=True) + actual = ax.get_position(original=False) + dw = orig.width - actual.width + if dw > 0: + extraw[sub.colspan] = np.maximum(extraw[sub.colspan], dw) + dh = orig.height - actual.height + if dh > 0: + extrah[sub.rowspan] = np.maximum(extrah[sub.rowspan], dh) + + if gs is None: + raise ValueError('Cannot do compressed layout if no axes ' + 'are part of a gridspec.') + w = np.sum(extraw) / 2 + layoutgrids[fig].edit_margin_min('left', w) + layoutgrids[fig].edit_margin_min('right', w) + + h = np.sum(extrah) / 2 + layoutgrids[fig].edit_margin_min('top', h) + layoutgrids[fig].edit_margin_min('bottom', h) + return layoutgrids + + def get_margin_from_padding(obj, *, w_pad=0, h_pad=0, hspace=0, wspace=0): @@ -300,8 +354,8 @@ def make_layout_margins(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0, hspace=hspace, wspace=wspace) layoutgrids[sfig].parent.edit_outer_margin_mins(margins, ss) - for ax in fig._localaxes.as_list(): - if not hasattr(ax, 'get_subplotspec') or not ax.get_in_layout(): + for ax in fig._localaxes: + if not ax.get_subplotspec() or not ax.get_in_layout(): continue ss = ax.get_subplotspec() @@ -364,6 +418,25 @@ def make_layout_margins(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0, # pass the new margins down to the layout grid for the solution... layoutgrids[gs].edit_outer_margin_mins(margin, ss) + # make margins for figure-level legends: + for leg in fig.legends: + inv_trans_fig = None + if leg._outside_loc and leg._bbox_to_anchor is None: + if inv_trans_fig is None: + inv_trans_fig = fig.transFigure.inverted().transform_bbox + bbox = inv_trans_fig(leg.get_tightbbox(renderer)) + w = bbox.width + 2 * w_pad + h = bbox.height + 2 * h_pad + legendloc = leg._outside_loc + if legendloc == 'lower': + layoutgrids[fig].edit_margin_min('bottom', h) + elif legendloc == 'upper': + layoutgrids[fig].edit_margin_min('top', h) + if legendloc == 'right': + layoutgrids[fig].edit_margin_min('right', w) + elif legendloc == 'left': + layoutgrids[fig].edit_margin_min('left', w) + def make_margin_suptitles(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0): # Figure out how large the suptitle is and make the @@ -432,8 +505,8 @@ def match_submerged_margins(layoutgrids, fig): for sfig in fig.subfigs: match_submerged_margins(layoutgrids, sfig) - axs = [a for a in fig.get_axes() if (hasattr(a, 'get_subplotspec') - and a.get_in_layout())] + axs = [a for a in fig.get_axes() + if a.get_subplotspec() is not None and a.get_in_layout()] for ax1 in axs: ss1 = ax1.get_subplotspec() @@ -536,17 +609,12 @@ def get_pos_and_bbox(ax, renderer): Position in figure coordinates. bbox : Bbox Tight bounding box in figure coordinates. - """ fig = ax.figure pos = ax.get_position(original=True) # pos is in panel co-ords, but we need in figure for the layout pos = pos.transformed(fig.transSubfigure - fig.transFigure) - try: - tightbbox = ax.get_tightbbox(renderer=renderer, for_layout_only=True) - except TypeError: - tightbbox = ax.get_tightbbox(renderer=renderer) - + tightbbox = martist._get_tightbbox_for_layout_only(ax, renderer) if tightbbox is None: bbox = pos else: @@ -568,15 +636,14 @@ def reposition_axes(layoutgrids, fig, renderer, *, w_pad=w_pad, h_pad=h_pad, wspace=wspace, hspace=hspace) - for ax in fig._localaxes.as_list(): - if not hasattr(ax, 'get_subplotspec') or not ax.get_in_layout(): + for ax in fig._localaxes: + if ax.get_subplotspec() is None or not ax.get_in_layout(): continue # grid bbox is in Figure coordinates, but we specify in panel # coordinates... ss = ax.get_subplotspec() gs = ss.get_gridspec() - nrows, ncols = gs.get_geometry() if gs not in layoutgrids: return @@ -692,10 +759,9 @@ def reset_margins(layoutgrids, fig): for sfig in fig.subfigs: reset_margins(layoutgrids, sfig) for ax in fig.axes: - if hasattr(ax, 'get_subplotspec') and ax.get_in_layout(): - ss = ax.get_subplotspec() - gs = ss.get_gridspec() - if gs in layoutgrids: + if ax.get_in_layout(): + gs = ax.get_gridspec() + if gs in layoutgrids: # also implies gs is not None. layoutgrids[gs].reset_margins() layoutgrids[fig].reset_margins() diff --git a/lib/matplotlib/_docstring.py b/lib/matplotlib/_docstring.py new file mode 100644 index 000000000000..ecd209ca0853 --- /dev/null +++ b/lib/matplotlib/_docstring.py @@ -0,0 +1,97 @@ +import inspect + +from . import _api + + +class Substitution: + """ + A decorator that performs %-substitution on an object's docstring. + + This decorator should be robust even if ``obj.__doc__`` is None (for + example, if -OO was passed to the interpreter). + + Usage: construct a docstring.Substitution with a sequence or dictionary + suitable for performing substitution; then decorate a suitable function + with the constructed object, e.g.:: + + sub_author_name = Substitution(author='Jason') + + @sub_author_name + def some_function(x): + "%(author)s wrote this function" + + # note that some_function.__doc__ is now "Jason wrote this function" + + One can also use positional arguments:: + + sub_first_last_names = Substitution('Edgar Allen', 'Poe') + + @sub_first_last_names + def some_function(x): + "%s %s wrote the Raven" + """ + def __init__(self, *args, **kwargs): + if args and kwargs: + raise TypeError("Only positional or keyword args are allowed") + self.params = args or kwargs + + def __call__(self, func): + if func.__doc__: + func.__doc__ = inspect.cleandoc(func.__doc__) % self.params + return func + + def update(self, *args, **kwargs): + """ + Update ``self.params`` (which must be a dict) with the supplied args. + """ + self.params.update(*args, **kwargs) + + +class _ArtistKwdocLoader(dict): + def __missing__(self, key): + if not key.endswith(":kwdoc"): + raise KeyError(key) + name = key[:-len(":kwdoc")] + from matplotlib.artist import Artist, kwdoc + try: + cls, = [cls for cls in _api.recursive_subclasses(Artist) + if cls.__name__ == name] + except ValueError as e: + raise KeyError(key) from e + return self.setdefault(key, kwdoc(cls)) + + +class _ArtistPropertiesSubstitution(Substitution): + """ + A `.Substitution` with two additional features: + + - Substitutions of the form ``%(classname:kwdoc)s`` (ending with the + literal ":kwdoc" suffix) trigger lookup of an Artist subclass with the + given *classname*, and are substituted with the `.kwdoc` of that class. + - Decorating a class triggers substitution both on the class docstring and + on the class' ``__init__`` docstring (which is a commonly required + pattern for Artist subclasses). + """ + + def __init__(self): + self.params = _ArtistKwdocLoader() + + def __call__(self, obj): + super().__call__(obj) + if isinstance(obj, type) and obj.__init__ != object.__init__: + self(obj.__init__) + return obj + + +def copy(source): + """Copy a docstring from another source function (if present).""" + def do_copy(target): + if source.__doc__: + target.__doc__ = source.__doc__ + return target + return do_copy + + +# Create a decorator that will house the various docstring snippets reused +# throughout Matplotlib. +dedent_interpd = interpd = _ArtistPropertiesSubstitution() diff --git a/lib/matplotlib/_enums.py b/lib/matplotlib/_enums.py index 28650dd369d9..c8c50f7c3028 100644 --- a/lib/matplotlib/_enums.py +++ b/lib/matplotlib/_enums.py @@ -11,7 +11,7 @@ """ from enum import Enum, auto -from matplotlib import docstring +from matplotlib import _docstring class _AutoStringNameEnum(Enum): @@ -151,9 +151,9 @@ class CapStyle(str, _AutoStringNameEnum): CapStyle.demo() """ - butt = 'butt' - projecting = 'projecting' - round = 'round' + butt = auto() + projecting = auto() + round = auto() @staticmethod def demo(): @@ -181,5 +181,5 @@ def demo(): + ", ".join([f"'{cs.name}'" for cs in CapStyle]) \ + "}" -docstring.interpd.update({'JoinStyle': JoinStyle.input_description, +_docstring.interpd.update({'JoinStyle': JoinStyle.input_description, 'CapStyle': CapStyle.input_description}) diff --git a/lib/matplotlib/_fontconfig_pattern.py b/lib/matplotlib/_fontconfig_pattern.py new file mode 100644 index 000000000000..f0ed155c2d62 --- /dev/null +++ b/lib/matplotlib/_fontconfig_pattern.py @@ -0,0 +1,120 @@ +""" +A module for parsing and generating `fontconfig patterns`_. + +.. _fontconfig patterns: + https://www.freedesktop.org/software/fontconfig/fontconfig-user.html +""" + +# This class logically belongs in `matplotlib.font_manager`, but placing it +# there would have created cyclical dependency problems, because it also needs +# to be available from `matplotlib.rcsetup` (for parsing matplotlibrc files). + +from functools import lru_cache, partial +import re + +from pyparsing import ( + Group, Optional, ParseException, Regex, StringEnd, Suppress, ZeroOrMore) + +from matplotlib import _api + + +_family_punc = r'\\\-:,' +_family_unescape = partial(re.compile(r'\\(?=[%s])' % _family_punc).sub, '') +_family_escape = partial(re.compile(r'(?=[%s])' % _family_punc).sub, r'\\') +_value_punc = r'\\=_:,' +_value_unescape = partial(re.compile(r'\\(?=[%s])' % _value_punc).sub, '') +_value_escape = partial(re.compile(r'(?=[%s])' % _value_punc).sub, r'\\') + + +_CONSTANTS = { + 'thin': ('weight', 'light'), + 'extralight': ('weight', 'light'), + 'ultralight': ('weight', 'light'), + 'light': ('weight', 'light'), + 'book': ('weight', 'book'), + 'regular': ('weight', 'regular'), + 'normal': ('weight', 'normal'), + 'medium': ('weight', 'medium'), + 'demibold': ('weight', 'demibold'), + 'semibold': ('weight', 'semibold'), + 'bold': ('weight', 'bold'), + 'extrabold': ('weight', 'extra bold'), + 'black': ('weight', 'black'), + 'heavy': ('weight', 'heavy'), + 'roman': ('slant', 'normal'), + 'italic': ('slant', 'italic'), + 'oblique': ('slant', 'oblique'), + 'ultracondensed': ('width', 'ultra-condensed'), + 'extracondensed': ('width', 'extra-condensed'), + 'condensed': ('width', 'condensed'), + 'semicondensed': ('width', 'semi-condensed'), + 'expanded': ('width', 'expanded'), + 'extraexpanded': ('width', 'extra-expanded'), + 'ultraexpanded': ('width', 'ultra-expanded'), +} + + +@lru_cache # The parser instance is a singleton. +def _make_fontconfig_parser(): + def comma_separated(elem): + return elem + ZeroOrMore(Suppress(",") + elem) + + family = Regex(r"([^%s]|(\\[%s]))*" % (_family_punc, _family_punc)) + size = Regex(r"([0-9]+\.?[0-9]*|\.[0-9]+)") + name = Regex(r"[a-z]+") + value = Regex(r"([^%s]|(\\[%s]))*" % (_value_punc, _value_punc)) + # replace trailing `| name` by oneOf(_CONSTANTS) in mpl 3.9. + prop = Group((name + Suppress("=") + comma_separated(value)) | name) + return ( + Optional(comma_separated(family)("families")) + + Optional("-" + comma_separated(size)("sizes")) + + ZeroOrMore(":" + prop("properties*")) + + StringEnd() + ) + + +# `parse_fontconfig_pattern` is a bottleneck during the tests because it is +# repeatedly called when the rcParams are reset (to validate the default +# fonts). In practice, the cache size doesn't grow beyond a few dozen entries +# during the test suite. +@lru_cache +def parse_fontconfig_pattern(pattern): + """ + Parse a fontconfig *pattern* into a dict that can initialize a + `.font_manager.FontProperties` object. + """ + parser = _make_fontconfig_parser() + try: + parse = parser.parseString(pattern) + except ParseException as err: + # explain becomes a plain method on pyparsing 3 (err.explain(0)). + raise ValueError("\n" + ParseException.explain(err, 0)) from None + parser.resetCache() + props = {} + if "families" in parse: + props["family"] = [*map(_family_unescape, parse["families"])] + if "sizes" in parse: + props["size"] = [*parse["sizes"]] + for prop in parse.get("properties", []): + if len(prop) == 1: + if prop[0] not in _CONSTANTS: + _api.warn_deprecated( + "3.7", message=f"Support for unknown constants " + f"({prop[0]!r}) is deprecated since %(since)s and " + f"will be removed %(removal)s.") + continue + prop = _CONSTANTS[prop[0]] + k, *v = prop + props.setdefault(k, []).extend(map(_value_unescape, v)) + return props + + +def generate_fontconfig_pattern(d): + """Convert a `.FontProperties` to a fontconfig pattern string.""" + kvs = [(k, getattr(d, f"get_{k}")()) + for k in ["style", "variant", "weight", "stretch", "file", "size"]] + # Families is given first without a leading keyword. Other entries (which + # are necessarily scalar) are given as key=value, skipping Nones. + return (",".join(_family_escape(f) for f in d.get_family()) + + "".join(f":{k}={_value_escape(str(v))}" + for k, v in kvs if v is not None)) diff --git a/lib/matplotlib/_layoutgrid.py b/lib/matplotlib/_layoutgrid.py index 8b7b140f600b..12eec6f2b2d6 100644 --- a/lib/matplotlib/_layoutgrid.py +++ b/lib/matplotlib/_layoutgrid.py @@ -20,6 +20,9 @@ import kiwisolver as kiwi import logging import numpy as np + +import matplotlib as mpl +import matplotlib.patches as mpatches from matplotlib.transforms import Bbox _log = logging.getLogger(__name__) @@ -39,7 +42,7 @@ def __init__(self, parent=None, parent_pos=(0, 0), self.parent_pos = parent_pos self.parent_inner = parent_inner self.name = name + seq_id() - if parent is not None: + if isinstance(parent, LayoutGrid): self.name = f'{parent.name}.{self.name}' self.nrows = nrows self.ncols = ncols @@ -51,8 +54,10 @@ def __init__(self, parent=None, parent_pos=(0, 0), self.width_ratios = np.ones(ncols) sn = self.name + '_' - if parent is None: - self.parent = None + if not isinstance(parent, LayoutGrid): + # parent can be a rect if not a LayoutGrid + # allows specifying a rectangle to contain the layout. + self.parent = parent self.solver = kiwi.Solver() else: self.parent = parent @@ -178,12 +183,13 @@ def parent_constraints(self): # parent's left, the last column right equal to the # parent's right... parent = self.parent - if parent is None: - hc = [self.lefts[0] == 0, - self.rights[-1] == 1, + if not isinstance(parent, LayoutGrid): + # specify a rectangle in figure coordinates + hc = [self.lefts[0] == parent[0], + self.rights[-1] == parent[0] + parent[2], # top and bottom reversed order... - self.tops[0] == 1, - self.bottoms[-1] == 0] + self.tops[0] == parent[1] + parent[3], + self.bottoms[-1] == parent[1]] else: rows, cols = self.parent_pos rows = np.atleast_1d(rows) @@ -504,21 +510,12 @@ def seq_id(): return '%06d' % next(_layoutboxobjnum) -def print_children(lb): - """Print the children of the layoutbox.""" - for child in lb.children: - print_children(child) - - -def plot_children(fig, lg=None, level=0, printit=False): +def plot_children(fig, lg=None, level=0): """Simple plotting to show where boxes are.""" - import matplotlib.pyplot as plt - import matplotlib.patches as mpatches - if lg is None: - _layoutgrids = fig.execute_constrained_layout() + _layoutgrids = fig.get_layout_engine().execute(fig) lg = _layoutgrids[fig] - colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] + colors = mpl.rcParams["axes.prop_cycle"].by_key()["color"] col = colors[level] for i in range(lg.nrows): for j in range(lg.ncols): diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index ea652ba91625..3a934c21fd50 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2,29 +2,29 @@ Implementation details for :mod:`.mathtext`. """ +import copy from collections import namedtuple import enum import functools -from io import StringIO import logging import os +import re import types import unicodedata import numpy as np from pyparsing import ( - Combine, Empty, Forward, Group, Literal, oneOf, OneOrMore, - Optional, ParseBaseException, ParseFatalException, ParserElement, - ParseResults, QuotedString, Regex, StringEnd, Suppress, White, ZeroOrMore) + Empty, Forward, Literal, NotAny, oneOf, OneOrMore, Optional, + ParseBaseException, ParseException, ParseExpression, ParseFatalException, + ParserElement, ParseResults, QuotedString, Regex, StringEnd, ZeroOrMore, + pyparsing_common) import matplotlib as mpl from . import _api, cbook from ._mathtext_data import ( - latex_to_bakoma, latex_to_standard, stix_glyph_fixes, stix_virtual_fonts, - tex2uni) -from .afm import AFM + latex_to_bakoma, stix_glyph_fixes, stix_virtual_fonts, tex2uni) from .font_manager import FontProperties, findfont, get_font -from .ft2font import KERNING_DEFAULT +from .ft2font import FT2Image, KERNING_DEFAULT ParserElement.enablePackrat() @@ -35,29 +35,28 @@ # FONTS -def get_unicode_index(symbol, math=True): +@_api.delete_parameter("3.6", "math") +def get_unicode_index(symbol, math=False): # Publicly exported. r""" Return the integer index (from the Unicode table) of *symbol*. Parameters ---------- symbol : str - A single unicode character, a TeX command (e.g. r'\pi') or a Type1 + A single (Unicode) character, a TeX command (e.g. r'\pi') or a Type1 symbol name (e.g. 'phi'). - math : bool, default: True - If False, always treat as a single unicode character. + math : bool, default: False + If True (deprecated), replace ASCII hyphen-minus by Unicode minus. """ - # for a non-math symbol, simply return its unicode index - if not math: - return ord(symbol) # From UTF #25: U+2212 minus sign is the preferred # representation of the unary and binary minus sign rather than # the ASCII-derived U+002D hyphen-minus, because minus sign is # unambiguous and because it is rendered with a more desirable # length, usually longer than a hyphen. - if symbol == '-': + # Remove this block when the 'math' parameter is deleted. + if math and symbol == '-': return 0x2212 - try: # This will succeed if symbol is a single unicode char + try: # This will succeed if symbol is a single Unicode char return ord(symbol) except TypeError: pass @@ -69,6 +68,85 @@ def get_unicode_index(symbol, math=True): .format(symbol)) from err +VectorParse = namedtuple("VectorParse", "width height depth glyphs rects", + module="matplotlib.mathtext") +VectorParse.__doc__ = r""" +The namedtuple type returned by ``MathTextParser("path").parse(...)``. + +This tuple contains the global metrics (*width*, *height*, *depth*), a list of +*glyphs* (including their positions) and of *rect*\angles. +""" + + +RasterParse = namedtuple("RasterParse", "ox oy width height depth image", + module="matplotlib.mathtext") +RasterParse.__doc__ = r""" +The namedtuple type returned by ``MathTextParser("agg").parse(...)``. + +This tuple contains the global metrics (*width*, *height*, *depth*), and a +raster *image*. The offsets *ox*, *oy* are always zero. +""" + + +class Output: + r""" + Result of `ship`\ping a box: lists of positioned glyphs and rectangles. + + This class is not exposed to end users, but converted to a `VectorParse` or + a `RasterParse` by `.MathTextParser.parse`. + """ + + def __init__(self, box): + self.box = box + self.glyphs = [] # (ox, oy, info) + self.rects = [] # (x1, y1, x2, y2) + + def to_vector(self): + w, h, d = map( + np.ceil, [self.box.width, self.box.height, self.box.depth]) + gs = [(info.font, info.fontsize, info.num, ox, h - oy + info.offset) + for ox, oy, info in self.glyphs] + rs = [(x1, h - y2, x2 - x1, y2 - y1) + for x1, y1, x2, y2 in self.rects] + return VectorParse(w, h + d, d, gs, rs) + + def to_raster(self): + # Metrics y's and mathtext y's are oriented in opposite directions, + # hence the switch between ymin and ymax. + xmin = min([*[ox + info.metrics.xmin for ox, oy, info in self.glyphs], + *[x1 for x1, y1, x2, y2 in self.rects], 0]) - 1 + ymin = min([*[oy - info.metrics.ymax for ox, oy, info in self.glyphs], + *[y1 for x1, y1, x2, y2 in self.rects], 0]) - 1 + xmax = max([*[ox + info.metrics.xmax for ox, oy, info in self.glyphs], + *[x2 for x1, y1, x2, y2 in self.rects], 0]) + 1 + ymax = max([*[oy - info.metrics.ymin for ox, oy, info in self.glyphs], + *[y2 for x1, y1, x2, y2 in self.rects], 0]) + 1 + w = xmax - xmin + h = ymax - ymin - self.box.depth + d = ymax - ymin - self.box.height + image = FT2Image(np.ceil(w), np.ceil(h + max(d, 0))) + + # Ideally, we could just use self.glyphs and self.rects here, shifting + # their coordinates by (-xmin, -ymin), but this yields slightly + # different results due to floating point slop; shipping twice is the + # old approach and keeps baseline images backcompat. + shifted = ship(self.box, (-xmin, -ymin)) + + for ox, oy, info in shifted.glyphs: + info.font.draw_glyph_to_bitmap( + image, ox, oy - info.metrics.iceberg, info.glyph, + antialiased=mpl.rcParams['text.antialiased']) + for x1, y1, x2, y2 in shifted.rects: + height = max(int(y2 - y1) - 1, 0) + if height == 0: + center = (y2 + y1) / 2 + y = int(center - (height + 1) / 2) + else: + y = int(y1) + image.draw_rect_filled(int(x1), y, np.ceil(x2), y + height) + return RasterParse(0, 0, w, h + d, d, image) + + class Fonts: """ An abstract base class for a system of fonts to use for mathtext. @@ -78,27 +156,19 @@ class Fonts: to do the actual drawing. """ - def __init__(self, default_font_prop, mathtext_backend): + def __init__(self, default_font_prop, load_glyph_flags): """ Parameters ---------- default_font_prop : `~.font_manager.FontProperties` The default non-math font, or the base font for Unicode (generic) font rendering. - mathtext_backend : `MathtextBackend` subclass - Backend to which rendering is actually delegated. + load_glyph_flags : int + Flags passed to the glyph loader (e.g. ``FT_Load_Glyph`` and + ``FT_Load_Char`` for FreeType-based fonts). """ self.default_font_prop = default_font_prop - self.mathtext_backend = mathtext_backend - self.used_characters = {} - - @_api.deprecated("3.4") - def destroy(self): - """ - Fix any cyclical references before the object is about - to be destroyed. - """ - self.used_characters = None + self.load_glyph_flags = load_glyph_flags def get_kern(self, font1, fontclass1, sym1, fontsize1, font2, fontclass2, sym2, fontsize2, dpi): @@ -109,7 +179,7 @@ def get_kern(self, font1, fontclass1, sym1, fontsize1, """ return 0. - def get_metrics(self, font, font_class, sym, fontsize, dpi, math=True): + def get_metrics(self, font, font_class, sym, fontsize, dpi): r""" Parameters ---------- @@ -128,8 +198,6 @@ def get_metrics(self, font, font_class, sym, fontsize, dpi, math=True): Font size in points. dpi : float Rendering dots-per-inch. - math : bool - Whether we are currently in math mode or not. Returns ------- @@ -147,33 +215,23 @@ def get_metrics(self, font, font_class, sym, fontsize, dpi, math=True): - *slanted*: Whether the glyph should be considered as "slanted" (currently used for kerning sub/superscripts). """ - info = self._get_info(font, font_class, sym, fontsize, dpi, math) + info = self._get_info(font, font_class, sym, fontsize, dpi) return info.metrics - def set_canvas_size(self, w, h, d): - """ - Set the size of the buffer used to render the math expression. - Only really necessary for the bitmap backends. - """ - self.width, self.height, self.depth = np.ceil([w, h, d]) - self.mathtext_backend.set_canvas_size( - self.width, self.height, self.depth) - - @_api.rename_parameter("3.4", "facename", "font") - def render_glyph(self, ox, oy, font, font_class, sym, fontsize, dpi): + def render_glyph( + self, output, ox, oy, font, font_class, sym, fontsize, dpi): """ At position (*ox*, *oy*), draw the glyph specified by the remaining parameters (see `get_metrics` for their detailed description). """ info = self._get_info(font, font_class, sym, fontsize, dpi) - self.used_characters.setdefault(info.font.fname, set()).add(info.num) - self.mathtext_backend.render_glyph(ox, oy, info) + output.glyphs.append((ox, oy, info)) - def render_rect_filled(self, x1, y1, x2, y2): + def render_rect_filled(self, output, x1, y1, x2, y2): """ Draw a filled rectangle from (*x1*, *y1*) to (*x2*, *y2*). """ - self.mathtext_backend.render_rect_filled(x1, y1, x2, y2) + output.rects.append((x1, y1, x2, y2)) def get_xheight(self, font, fontsize, dpi): """ @@ -196,20 +254,6 @@ def get_used_characters(self): """ return self.used_characters - def get_results(self, box): - """ - Get the data needed by the backend to render the math - expression. The return value is backend-specific. - """ - result = self.mathtext_backend.get_results( - box, self.get_used_characters()) - if self.destroy != TruetypeFonts.destroy.__get__(self): - destroy = _api.deprecate_method_override( - __class__.destroy, self, since="3.4") - if destroy: - destroy() - return result - def get_sized_alternatives_for_symbol(self, fontname, sym): """ Override if your font provides multiple sizes of the same @@ -225,21 +269,18 @@ class TruetypeFonts(Fonts): A generic base class for all font setups that use Truetype fonts (through FT2Font). """ - def __init__(self, default_font_prop, mathtext_backend): - super().__init__(default_font_prop, mathtext_backend) - self.glyphd = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Per-instance cache. + self._get_info = functools.lru_cache(None)(self._get_info) self._fonts = {} - filename = findfont(default_font_prop) + filename = findfont(self.default_font_prop) default_font = get_font(filename) self._fonts['default'] = default_font self._fonts['regular'] = default_font - @_api.deprecated("3.4") - def destroy(self): - self.glyphd = None - super().destroy() - def _get_font(self, font): if font in self.fontmap: basename = self.fontmap[font] @@ -258,19 +299,11 @@ def _get_offset(self, font, glyph, fontsize, dpi): return (glyph.height / 64 / 2) + (fontsize/3 * dpi/72) return 0. - def _get_info(self, fontname, font_class, sym, fontsize, dpi, math=True): - key = fontname, font_class, sym, fontsize, dpi - bunch = self.glyphd.get(key) - if bunch is not None: - return bunch - - font, num, symbol_name, fontsize, slanted = \ - self._get_glyph(fontname, font_class, sym, fontsize, math) - + # The return value of _get_info is cached per-instance. + def _get_info(self, fontname, font_class, sym, fontsize, dpi): + font, num, slanted = self._get_glyph(fontname, font_class, sym) font.set_size(fontsize, dpi) - glyph = font.load_char( - num, - flags=self.mathtext_backend.get_hinting_type()) + glyph = font.load_char(num, flags=self.load_glyph_flags) xmin, ymin, xmax, ymax = [val/64.0 for val in glyph.bbox] offset = self._get_offset(font, glyph, fontsize, dpi) @@ -287,17 +320,15 @@ def _get_info(self, fontname, font_class, sym, fontsize, dpi, math=True): slanted = slanted ) - result = self.glyphd[key] = types.SimpleNamespace( + return types.SimpleNamespace( font = font, fontsize = fontsize, postscript_name = font.postscript_name, metrics = metrics, - symbol_name = symbol_name, num = num, glyph = glyph, offset = offset ) - return result def get_xheight(self, fontname, fontsize, dpi): font = self._get_font(fontname) @@ -357,8 +388,7 @@ def __init__(self, *args, **kwargs): _slanted_symbols = set(r"\int \oint".split()) - def _get_glyph(self, fontname, font_class, sym, fontsize, math=True): - symbol_name = None + def _get_glyph(self, fontname, font_class, sym): font = None if fontname in self.fontmap and sym in latex_to_bakoma: basename, num = latex_to_bakoma[sym] @@ -369,17 +399,10 @@ def _get_glyph(self, fontname, font_class, sym, fontsize, math=True): font = self._get_font(fontname) if font is not None: num = ord(sym) - - if font is not None: - gid = font.get_char_index(num) - if gid != 0: - symbol_name = font.get_glyph_name(gid) - - if symbol_name is None: - return self._stix_fallback._get_glyph( - fontname, font_class, sym, fontsize, math) - - return font, num, symbol_name, fontsize, slanted + if font is not None and font.get_char_index(num) != 0: + return font, num, slanted + else: + return self._stix_fallback._get_glyph(fontname, font_class, sym) # The Bakoma fonts contain many pre-sized alternatives for the # delimiters. The AutoSizedChar class will use these alternatives @@ -452,7 +475,14 @@ class UnicodeFonts(TruetypeFonts): This class will "fallback" on the Bakoma fonts when a required symbol can not be found in the font. """ - use_cmex = True # Unused; delete once mathtext becomes private. + + # Some glyphs are not present in the `cmr10` font, and must be brought in + # from `cmsy10`. Map the Unicode indices of those glyphs to the indices at + # which they are found in `cmsy10`. + _cmr10_substitutions = { + 0x00D7: 0x00A3, # Multiplication sign. + 0x2212: 0x00A1, # Minus sign. + } def __init__(self, *args, **kwargs): # This must come first so the backend's owner is set correctly @@ -461,7 +491,7 @@ def __init__(self, *args, **kwargs): 'stixsans': StixSansFonts, 'cm': BakomaFonts }.get(fallback_rc) - self.cm_fallback = font_cls(*args, **kwargs) if font_cls else None + self._fallback_font = font_cls(*args, **kwargs) if font_cls else None super().__init__(*args, **kwargs) self.fontmap = {} @@ -474,7 +504,7 @@ def __init__(self, *args, **kwargs): self.fontmap['ex'] = font # include STIX sized alternatives for glyphs if fallback is STIX - if isinstance(self.cm_fallback, StixFonts): + if isinstance(self._fallback_font, StixFonts): stixsizedaltfonts = { 0: 'STIXGeneral', 1: 'STIXSizeOneSym', @@ -493,14 +523,14 @@ def __init__(self, *args, **kwargs): def _map_virtual_font(self, fontname, font_class, uniindex): return fontname, uniindex - def _get_glyph(self, fontname, font_class, sym, fontsize, math=True): + def _get_glyph(self, fontname, font_class, sym): try: - uniindex = get_unicode_index(sym, math) + uniindex = get_unicode_index(sym) found_symbol = True except ValueError: uniindex = ord('?') found_symbol = False - _log.warning("No TeX to unicode mapping for {!a}.".format(sym)) + _log.warning("No TeX to Unicode mapping for {!a}.".format(sym)) fontname, uniindex = self._map_virtual_font( fontname, font_class, uniindex) @@ -520,61 +550,56 @@ def _get_glyph(self, fontname, font_class, sym, fontsize, math=True): found_symbol = False font = self._get_font(new_fontname) if font is not None: - if font.family_name == "cmr10" and uniindex == 0x2212: - # minus sign exists in cmsy10 (not cmr10) + if (uniindex in self._cmr10_substitutions + and font.family_name == "cmr10"): font = get_font( cbook._get_data_path("fonts/ttf/cmsy10.ttf")) - uniindex = 0xa1 + uniindex = self._cmr10_substitutions[uniindex] glyphindex = font.get_char_index(uniindex) if glyphindex != 0: found_symbol = True if not found_symbol: - if self.cm_fallback: + if self._fallback_font: if (fontname in ('it', 'regular') - and isinstance(self.cm_fallback, StixFonts)): + and isinstance(self._fallback_font, StixFonts)): fontname = 'rm' - g = self.cm_fallback._get_glyph(fontname, font_class, - sym, fontsize) - fname = g[0].family_name - if fname in list(BakomaFonts._fontmap.values()): - fname = "Computer Modern" - _log.info("Substituting symbol %s from %s", sym, fname) + g = self._fallback_font._get_glyph(fontname, font_class, sym) + family = g[0].family_name + if family in list(BakomaFonts._fontmap.values()): + family = "Computer Modern" + _log.info("Substituting symbol %s from %s", sym, family) return g else: if (fontname in ('it', 'regular') and isinstance(self, StixFonts)): - return self._get_glyph('rm', font_class, sym, fontsize) + return self._get_glyph('rm', font_class, sym) _log.warning("Font {!r} does not have a glyph for {!a} " "[U+{:x}], substituting with a dummy " "symbol.".format(new_fontname, sym, uniindex)) - fontname = 'rm' - font = self._get_font(fontname) + font = self._get_font('rm') uniindex = 0xA4 # currency char, for lack of anything better - glyphindex = font.get_char_index(uniindex) slanted = False - symbol_name = font.get_glyph_name(glyphindex) - return font, uniindex, symbol_name, fontsize, slanted + return font, uniindex, slanted def get_sized_alternatives_for_symbol(self, fontname, sym): - if self.cm_fallback: - return self.cm_fallback.get_sized_alternatives_for_symbol( + if self._fallback_font: + return self._fallback_font.get_sized_alternatives_for_symbol( fontname, sym) return [(fontname, sym)] class DejaVuFonts(UnicodeFonts): - use_cmex = False # Unused; delete once mathtext becomes private. def __init__(self, *args, **kwargs): # This must come first so the backend's owner is set correctly if isinstance(self, DejaVuSerifFonts): - self.cm_fallback = StixFonts(*args, **kwargs) + self._fallback_font = StixFonts(*args, **kwargs) else: - self.cm_fallback = StixSansFonts(*args, **kwargs) + self._fallback_font = StixSansFonts(*args, **kwargs) self.bakoma = BakomaFonts(*args, **kwargs) TruetypeFonts.__init__(self, *args, **kwargs) self.fontmap = {} @@ -591,11 +616,10 @@ def __init__(self, *args, **kwargs): self.fontmap[key] = fullpath self.fontmap[name] = fullpath - def _get_glyph(self, fontname, font_class, sym, fontsize, math=True): + def _get_glyph(self, fontname, font_class, sym): # Override prime symbol to use Bakoma. if sym == r'\prime': - return self.bakoma._get_glyph( - fontname, font_class, sym, fontsize, math) + return self.bakoma._get_glyph(fontname, font_class, sym) else: # check whether the glyph is available in the display font uniindex = get_unicode_index(sym) @@ -603,11 +627,9 @@ def _get_glyph(self, fontname, font_class, sym, fontsize, math=True): if font is not None: glyphindex = font.get_char_index(uniindex) if glyphindex != 0: - return super()._get_glyph( - 'ex', font_class, sym, fontsize, math) + return super()._get_glyph('ex', font_class, sym) # otherwise return regular glyph - return super()._get_glyph( - fontname, font_class, sym, fontsize, math) + return super()._get_glyph(fontname, font_class, sym) class DejaVuSerifFonts(DejaVuFonts): @@ -670,8 +692,7 @@ class StixFonts(UnicodeFonts): 4: 'STIXSizeFourSym', 5: 'STIXSizeFiveSym', } - use_cmex = False # Unused; delete once mathtext becomes private. - cm_fallback = False + _fallback_font = False _sans = False def __init__(self, *args, **kwargs): @@ -760,164 +781,6 @@ class StixSansFonts(StixFonts): _sans = True -class StandardPsFonts(Fonts): - """ - Use the standard postscript fonts for rendering to backend_ps - - Unlike the other font classes, BakomaFont and UnicodeFont, this - one requires the Ps backend. - """ - basepath = str(cbook._get_data_path('fonts/afm')) - - fontmap = { - 'cal': 'pzcmi8a', # Zapf Chancery - 'rm': 'pncr8a', # New Century Schoolbook - 'tt': 'pcrr8a', # Courier - 'it': 'pncri8a', # New Century Schoolbook Italic - 'sf': 'phvr8a', # Helvetica - 'bf': 'pncb8a', # New Century Schoolbook Bold - None: 'psyr', # Symbol - } - - def __init__(self, default_font_prop, mathtext_backend=None): - if mathtext_backend is None: - # Circular import, can be dropped after public access to - # StandardPsFonts is removed and mathtext_backend made a required - # parameter. - from . import mathtext - mathtext_backend = mathtext.MathtextBackendPath() - super().__init__(default_font_prop, mathtext_backend) - self.glyphd = {} - self.fonts = {} - - filename = findfont(default_font_prop, fontext='afm', - directory=self.basepath) - if filename is None: - filename = findfont('Helvetica', fontext='afm', - directory=self.basepath) - with open(filename, 'rb') as fd: - default_font = AFM(fd) - default_font.fname = filename - - self.fonts['default'] = default_font - self.fonts['regular'] = default_font - - pswriter = _api.deprecated("3.4")(property(lambda self: StringIO())) - - def _get_font(self, font): - if font in self.fontmap: - basename = self.fontmap[font] - else: - basename = font - - cached_font = self.fonts.get(basename) - if cached_font is None: - fname = os.path.join(self.basepath, basename + ".afm") - with open(fname, 'rb') as fd: - cached_font = AFM(fd) - cached_font.fname = fname - self.fonts[basename] = cached_font - self.fonts[cached_font.get_fontname()] = cached_font - return cached_font - - def _get_info(self, fontname, font_class, sym, fontsize, dpi, math=True): - """Load the cmfont, metrics and glyph with caching.""" - key = fontname, sym, fontsize, dpi - tup = self.glyphd.get(key) - - if tup is not None: - return tup - - # Only characters in the "Letter" class should really be italicized. - # This class includes greek letters, so we're ok - if (fontname == 'it' and - (len(sym) > 1 - or not unicodedata.category(sym).startswith("L"))): - fontname = 'rm' - - found_symbol = False - - if sym in latex_to_standard: - fontname, num = latex_to_standard[sym] - glyph = chr(num) - found_symbol = True - elif len(sym) == 1: - glyph = sym - num = ord(glyph) - found_symbol = True - else: - _log.warning( - "No TeX to built-in Postscript mapping for {!r}".format(sym)) - - slanted = (fontname == 'it') - font = self._get_font(fontname) - - if found_symbol: - try: - symbol_name = font.get_name_char(glyph) - except KeyError: - _log.warning( - "No glyph in standard Postscript font {!r} for {!r}" - .format(font.get_fontname(), sym)) - found_symbol = False - - if not found_symbol: - glyph = '?' - num = ord(glyph) - symbol_name = font.get_name_char(glyph) - - offset = 0 - - scale = 0.001 * fontsize - - xmin, ymin, xmax, ymax = [val * scale - for val in font.get_bbox_char(glyph)] - metrics = types.SimpleNamespace( - advance = font.get_width_char(glyph) * scale, - width = font.get_width_char(glyph) * scale, - height = font.get_height_char(glyph) * scale, - xmin = xmin, - xmax = xmax, - ymin = ymin+offset, - ymax = ymax+offset, - # iceberg is the equivalent of TeX's "height" - iceberg = ymax + offset, - slanted = slanted - ) - - self.glyphd[key] = types.SimpleNamespace( - font = font, - fontsize = fontsize, - postscript_name = font.get_fontname(), - metrics = metrics, - symbol_name = symbol_name, - num = num, - glyph = glyph, - offset = offset - ) - - return self.glyphd[key] - - def get_kern(self, font1, fontclass1, sym1, fontsize1, - font2, fontclass2, sym2, fontsize2, dpi): - if font1 == font2 and fontsize1 == fontsize2: - info1 = self._get_info(font1, fontclass1, sym1, fontsize1, dpi) - info2 = self._get_info(font2, fontclass2, sym2, fontsize2, dpi) - font = info1.font - return (font.get_kern_dist(info1.glyph, info2.glyph) - * 0.001 * fontsize1) - return super().get_kern(font1, fontclass1, sym1, fontsize1, - font2, fontclass2, sym2, fontsize2, dpi) - - def get_xheight(self, font, fontsize, dpi): - font = self._get_font(font) - return font.get_xheight() * 0.001 * fontsize - - def get_underline_thickness(self, font, fontsize, dpi): - font = self._get_font(font) - return font.get_underline_thickness() * 0.001 * fontsize - - ############################################################################## # TeX-LIKE BOX MODEL @@ -930,8 +793,8 @@ def get_underline_thickness(self, font, fontsize, dpi): # # The most relevant "chapters" are: # Data structures for boxes and their friends -# Shipping pages out (Ship class) -# Packaging (hpack and vpack) +# Shipping pages out (ship()) +# Packaging (hpack() and vpack()) # Data structures for math mode # Subroutines for math mode # Typesetting math formulas @@ -942,10 +805,8 @@ def get_underline_thickness(self, font, fontsize, dpi): # Note that (as TeX) y increases downward, unlike many other parts of # matplotlib. -# How much text shrinks when going to the next-smallest level. GROW_FACTOR -# must be the inverse of SHRINK_FACTOR. +# How much text shrinks when going to the next-smallest level. SHRINK_FACTOR = 0.7 -GROW_FACTOR = 1 / SHRINK_FACTOR # The number of different sizes of chars to use, beyond which they will not # get any smaller NUM_SIZE_LEVELS = 6 @@ -973,16 +834,16 @@ class FontConstantsBase: # superscript is present sub2 = 0.5 - # Percentage of x-height that sub/supercripts are offset relative to the + # Percentage of x-height that sub/superscripts are offset relative to the # nucleus edge for non-slanted nuclei delta = 0.025 # Additional percentage of last character height above 2/3 of the - # x-height that supercripts are offset relative to the subscript + # x-height that superscripts are offset relative to the subscript # for slanted nuclei delta_slanted = 0.2 - # Percentage of x-height that supercripts and subscripts are offset for + # Percentage of x-height that superscripts and subscripts are offset for # integrals delta_integral = 0.1 @@ -1049,12 +910,11 @@ class DejaVuSansFontConstants(FontConstantsBase): def _get_font_constant_set(state): constants = _font_constant_mapping.get( - state.font_output._get_font(state.font).family_name, - FontConstantsBase) + state.fontset._get_font(state.font).family_name, FontConstantsBase) # STIX sans isn't really its own fonts, just different code points # in the STIX fonts, so we have to detect this one separately. if (constants is STIXFontConstants and - isinstance(state.font_output, StixSansFonts)): + isinstance(state.fontset, StixSansFonts)): return STIXSansFontConstants return constants @@ -1066,7 +926,7 @@ def __init__(self): self.size = 0 def __repr__(self): - return self.__class__.__name__ + return type(self).__name__ def get_kerning(self, next): return 0.0 @@ -1078,15 +938,8 @@ def shrink(self): """ self.size += 1 - def grow(self): - """ - Grows one level larger. There is no limit to how big - something can get. - """ - self.size -= 1 - - def render(self, x, y): - pass + def render(self, output, x, y): + """Render this node.""" class Box(Node): @@ -1105,13 +958,7 @@ def shrink(self): self.height *= SHRINK_FACTOR self.depth *= SHRINK_FACTOR - def grow(self): - super().grow() - self.width *= GROW_FACTOR - self.height *= GROW_FACTOR - self.depth *= GROW_FACTOR - - def render(self, x1, y1, x2, y2): + def render(self, output, x1, y1, x2, y2): pass @@ -1142,15 +989,14 @@ class Char(Node): `Hlist`. """ - def __init__(self, c, state, math=True): + def __init__(self, c, state): super().__init__() self.c = c - self.font_output = state.font_output + self.fontset = state.fontset self.font = state.font self.font_class = state.font_class self.fontsize = state.fontsize self.dpi = state.dpi - self.math = math # The real width, height and depth will be set during the # pack phase, after we know the real fontsize self._update_metrics() @@ -1159,9 +1005,8 @@ def __repr__(self): return '`%s`' % self.c def _update_metrics(self): - metrics = self._metrics = self.font_output.get_metrics( - self.font, self.font_class, self.c, self.fontsize, self.dpi, - self.math) + metrics = self._metrics = self.fontset.get_metrics( + self.font, self.font_class, self.c, self.fontsize, self.dpi) if self.c == ' ': self.width = metrics.advance else: @@ -1182,18 +1027,15 @@ def get_kerning(self, next): advance = self._metrics.advance - self.width kern = 0. if isinstance(next, Char): - kern = self.font_output.get_kern( + kern = self.fontset.get_kern( self.font, self.font_class, self.c, self.fontsize, next.font, next.font_class, next.c, next.fontsize, self.dpi) return advance + kern - def render(self, x, y): - """ - Render the character to the canvas - """ - self.font_output.render_glyph( - x, y, + def render(self, output, x, y): + self.fontset.render_glyph( + output, x, y, self.font, self.font_class, self.c, self.fontsize, self.dpi) def shrink(self): @@ -1204,13 +1046,6 @@ def shrink(self): self.height *= SHRINK_FACTOR self.depth *= SHRINK_FACTOR - def grow(self): - super().grow() - self.fontsize *= GROW_FACTOR - self.width *= GROW_FACTOR - self.height *= GROW_FACTOR - self.depth *= GROW_FACTOR - class Accent(Char): """ @@ -1219,7 +1054,7 @@ class Accent(Char): TrueType fonts. """ def _update_metrics(self): - metrics = self._metrics = self.font_output.get_metrics( + metrics = self._metrics = self.fontset.get_metrics( self.font, self.font_class, self.c, self.fontsize, self.dpi) self.width = metrics.xmax - metrics.xmin self.height = metrics.ymax - metrics.ymin @@ -1229,16 +1064,9 @@ def shrink(self): super().shrink() self._update_metrics() - def grow(self): - super().grow() - self._update_metrics() - - def render(self, x, y): - """ - Render the character to the canvas. - """ - self.font_output.render_glyph( - x - self._metrics.xmin, y + self._metrics.ymin, + def render(self, output, x, y): + self.fontset.render_glyph( + output, x - self._metrics.xmin, y + self._metrics.ymin, self.font, self.font_class, self.c, self.fontsize, self.dpi) @@ -1255,27 +1083,16 @@ def __init__(self, elements): self.glue_order = 0 # The order of infinity (0 - 3) for the glue def __repr__(self): - return '[%s <%.02f %.02f %.02f %.02f> %s]' % ( + return '%s[%s]' % ( super().__repr__(), self.width, self.height, self.depth, self.shift_amount, - ' '.join([repr(x) for x in self.children])) - - @staticmethod - def _determine_order(totals): - """ - Determine the highest order of glue used by the members of this list. - - Helper function used by vpack and hpack. - """ - for i in range(len(totals))[::-1]: - if totals[i] != 0: - return i - return 0 + ', '.join([repr(x) for x in self.children])) def _set_glue(self, x, sign, totals, error_type): - o = self._determine_order(totals) - self.glue_order = o + self.glue_order = o = next( + # Highest order of glue used by the members of this list. + (i for i in range(len(totals))[::-1] if totals[i] != 0), 0) self.glue_sign = sign if totals[o] != 0.: self.glue_set = x / totals[o] @@ -1285,7 +1102,7 @@ def _set_glue(self, x, sign, totals, error_type): if o == 0: if len(self.children): _log.warning("%s %s: %r", - error_type, self.__class__.__name__, self) + error_type, type(self).__name__, self) def shrink(self): for child in self.children: @@ -1295,13 +1112,6 @@ def shrink(self): self.shift_amount *= SHRINK_FACTOR self.glue_set *= SHRINK_FACTOR - def grow(self): - for child in self.children: - child.grow() - super().grow() - self.shift_amount *= GROW_FACTOR - self.glue_set *= GROW_FACTOR - class Hlist(List): """A horizontal list of boxes.""" @@ -1310,7 +1120,7 @@ def __init__(self, elements, w=0., m='additional', do_kern=True): super().__init__(elements) if do_kern: self.kern() - self.hpack() + self.hpack(w=w, m=m) def kern(self): """ @@ -1411,9 +1221,9 @@ def hpack(self, w=0., m='additional'): self.glue_ratio = 0. return if x > 0.: - self._set_glue(x, 1, total_stretch, "Overfull") + self._set_glue(x, 1, total_stretch, "Overful") else: - self._set_glue(x, -1, total_shrink, "Underfull") + self._set_glue(x, -1, total_shrink, "Underful") class Vlist(List): @@ -1421,7 +1231,7 @@ class Vlist(List): def __init__(self, elements, h=0., m='additional'): super().__init__(elements) - self.vpack() + self.vpack(h=h, m=m) def vpack(self, h=0., m='additional', l=np.inf): """ @@ -1433,8 +1243,8 @@ def vpack(self, h=0., m='additional', l=np.inf): h : float, default: 0 A height. m : {'exactly', 'additional'}, default: 'additional' - Whether to produce a box whose height is 'exactly' *w*; or a box - with the natural height of the contents, plus *w* ('additional'). + Whether to produce a box whose height is 'exactly' *h*; or a box + with the natural height of the contents, plus *h* ('additional'). l : float, default: np.inf The maximum height. @@ -1490,9 +1300,9 @@ def vpack(self, h=0., m='additional', l=np.inf): return if x > 0.: - self._set_glue(x, 1, total_stretch, "Overfull") + self._set_glue(x, 1, total_stretch, "Overful") else: - self._set_glue(x, -1, total_shrink, "Underfull") + self._set_glue(x, -1, total_shrink, "Underful") class Rule(Box): @@ -1508,10 +1318,10 @@ class Rule(Box): def __init__(self, width, height, depth, state): super().__init__(width, height, depth) - self.font_output = state.font_output + self.fontset = state.fontset - def render(self, x, y, w, h): - self.font_output.render_rect_filled(x, y, x + w, y + h) + def render(self, output, x, y, w, h): + self.fontset.render_rect_filled(output, x, y, x + w, y + h) class Hrule(Rule): @@ -1519,8 +1329,7 @@ class Hrule(Rule): def __init__(self, state, thickness=None): if thickness is None: - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) + thickness = state.get_current_underline_thickness() height = depth = thickness * 0.5 super().__init__(np.inf, height, depth, state) @@ -1529,8 +1338,7 @@ class Vrule(Rule): """Convenience class to create a vertical rule.""" def __init__(self, state): - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) + thickness = state.get_current_underline_thickness() super().__init__(thickness, np.inf, np.inf, state) @@ -1572,11 +1380,6 @@ def shrink(self): g = self.glue_spec self.glue_spec = g._replace(width=g.width * SHRINK_FACTOR) - def grow(self): - super().grow() - g = self.glue_spec - self.glue_spec = g._replace(width=g.width * GROW_FACTOR) - class HCentered(Hlist): """ @@ -1624,25 +1427,6 @@ def shrink(self): if self.size < NUM_SIZE_LEVELS: self.width *= SHRINK_FACTOR - def grow(self): - super().grow() - self.width *= GROW_FACTOR - - -class SubSuperCluster(Hlist): - """ - A hack to get around that fact that this code does a two-pass parse like - TeX. This lets us store enough information in the hlist itself, namely the - nucleus, sub- and super-script, such that if another script follows that - needs to be attached, it can be reconfigured on the fly. - """ - - def __init__(self): - self.nucleus = None - self.sub = None - self.super = None - super().__init__([]) - class AutoHeightChar(Hlist): """ @@ -1654,10 +1438,10 @@ class AutoHeightChar(Hlist): """ def __init__(self, c, height, depth, state, always=False, factor=None): - alternatives = state.font_output.get_sized_alternatives_for_symbol( + alternatives = state.fontset.get_sized_alternatives_for_symbol( state.font, c) - xHeight = state.font_output.get_xheight( + xHeight = state.fontset.get_xheight( state.font, state.fontsize, state.dpi) state = state.copy() @@ -1671,7 +1455,7 @@ def __init__(self, c, height, depth, state, always=False, factor=None): break shift = 0 - if state.font != 0: + if state.font != 0 or len(alternatives) == 1: if factor is None: factor = target_total / (char.height + char.depth) state.fontsize *= factor @@ -1693,7 +1477,7 @@ class AutoWidthChar(Hlist): """ def __init__(self, c, width, state, always=False, char_class=Char): - alternatives = state.font_output.get_sized_alternatives_for_symbol( + alternatives = state.fontset.get_sized_alternatives_for_symbol( state.font, c) state = state.copy() @@ -1711,81 +1495,72 @@ def __init__(self, c, width, state, always=False, char_class=Char): self.width = char.width -class Ship: +def ship(box, xy=(0, 0)): """ - Ship boxes to output once they have been set up, this sends them to output. + Ship out *box* at offset *xy*, converting it to an `Output`. - Since boxes can be inside of boxes inside of boxes, the main work of `Ship` + Since boxes can be inside of boxes inside of boxes, the main work of `ship` is done by two mutually recursive routines, `hlist_out` and `vlist_out`, which traverse the `Hlist` nodes and `Vlist` nodes inside of horizontal and vertical boxes. The global variables used in TeX to store state as it - processes have become member variables here. + processes have become local variables here. """ + ox, oy = xy + cur_v = 0. + cur_h = 0. + off_h = ox + off_v = oy + box.height + output = Output(box) - def __call__(self, ox, oy, box): - self.max_push = 0 # Deepest nesting of push commands so far - self.cur_s = 0 - self.cur_v = 0. - self.cur_h = 0. - self.off_h = ox - self.off_v = oy + box.height - self.hlist_out(box) - - @staticmethod def clamp(value): - if value < -1000000000.: - return -1000000000. - if value > 1000000000.: - return 1000000000. - return value - - def hlist_out(self, box): - cur_g = 0 - cur_glue = 0. - glue_order = box.glue_order - glue_sign = box.glue_sign - base_line = self.cur_v - left_edge = self.cur_h - self.cur_s += 1 - self.max_push = max(self.cur_s, self.max_push) - clamp = self.clamp + return -1e9 if value < -1e9 else +1e9 if value > +1e9 else value + + def hlist_out(box): + nonlocal cur_v, cur_h, off_h, off_v + + cur_g = 0 + cur_glue = 0. + glue_order = box.glue_order + glue_sign = box.glue_sign + base_line = cur_v + left_edge = cur_h for p in box.children: if isinstance(p, Char): - p.render(self.cur_h + self.off_h, self.cur_v + self.off_v) - self.cur_h += p.width + p.render(output, cur_h + off_h, cur_v + off_v) + cur_h += p.width elif isinstance(p, Kern): - self.cur_h += p.width + cur_h += p.width elif isinstance(p, List): # node623 if len(p.children) == 0: - self.cur_h += p.width + cur_h += p.width else: - edge = self.cur_h - self.cur_v = base_line + p.shift_amount + edge = cur_h + cur_v = base_line + p.shift_amount if isinstance(p, Hlist): - self.hlist_out(p) + hlist_out(p) else: # p.vpack(box.height + box.depth, 'exactly') - self.vlist_out(p) - self.cur_h = edge + p.width - self.cur_v = base_line + vlist_out(p) + cur_h = edge + p.width + cur_v = base_line elif isinstance(p, Box): # node624 rule_height = p.height - rule_depth = p.depth - rule_width = p.width + rule_depth = p.depth + rule_width = p.width if np.isinf(rule_height): rule_height = box.height if np.isinf(rule_depth): rule_depth = box.depth if rule_height > 0 and rule_width > 0: - self.cur_v = base_line + rule_depth - p.render(self.cur_h + self.off_h, - self.cur_v + self.off_v, + cur_v = base_line + rule_depth + p.render(output, + cur_h + off_h, cur_v + off_v, rule_width, rule_height) - self.cur_v = base_line - self.cur_h += rule_width + cur_v = base_line + cur_h += rule_width elif isinstance(p, Glue): # node625 glue_spec = p.glue_spec @@ -1799,38 +1574,36 @@ def hlist_out(self, box): cur_glue += glue_spec.shrink cur_g = round(clamp(box.glue_set * cur_glue)) rule_width += cur_g - self.cur_h += rule_width - self.cur_s -= 1 - - def vlist_out(self, box): - cur_g = 0 - cur_glue = 0. - glue_order = box.glue_order - glue_sign = box.glue_sign - self.cur_s += 1 - self.max_push = max(self.max_push, self.cur_s) - left_edge = self.cur_h - self.cur_v -= box.height - top_edge = self.cur_v - clamp = self.clamp + cur_h += rule_width + + def vlist_out(box): + nonlocal cur_v, cur_h, off_h, off_v + + cur_g = 0 + cur_glue = 0. + glue_order = box.glue_order + glue_sign = box.glue_sign + left_edge = cur_h + cur_v -= box.height + top_edge = cur_v for p in box.children: if isinstance(p, Kern): - self.cur_v += p.width + cur_v += p.width elif isinstance(p, List): if len(p.children) == 0: - self.cur_v += p.height + p.depth + cur_v += p.height + p.depth else: - self.cur_v += p.height - self.cur_h = left_edge + p.shift_amount - save_v = self.cur_v + cur_v += p.height + cur_h = left_edge + p.shift_amount + save_v = cur_v p.width = box.width if isinstance(p, Hlist): - self.hlist_out(p) + hlist_out(p) else: - self.vlist_out(p) - self.cur_v = save_v + p.depth - self.cur_h = left_edge + vlist_out(p) + cur_v = save_v + p.depth + cur_h = left_edge elif isinstance(p, Box): rule_height = p.height rule_depth = p.depth @@ -1839,9 +1612,9 @@ def vlist_out(self, box): rule_width = box.width rule_height += rule_depth if rule_height > 0 and rule_depth > 0: - self.cur_v += rule_height - p.render(self.cur_h + self.off_h, - self.cur_v + self.off_v, + cur_v += rule_height + p.render(output, + cur_h + off_h, cur_v + off_v, rule_width, rule_height) elif isinstance(p, Glue): glue_spec = p.glue_spec @@ -1855,14 +1628,13 @@ def vlist_out(self, box): cur_glue += glue_spec.shrink cur_g = round(clamp(box.glue_set * cur_glue)) rule_height += cur_g - self.cur_v += rule_height + cur_v += rule_height elif isinstance(p, Char): raise RuntimeError( "Internal mathtext error: Char node found in vlist") - self.cur_s -= 1 - -ship = Ship() + hlist_out(box) + return output ############################################################################## @@ -1874,9 +1646,69 @@ def Error(msg): def raise_error(s, loc, toks): raise ParseFatalException(s, loc, msg) - empty = Empty() - empty.setParseAction(raise_error) - return empty + return Empty().setParseAction(raise_error) + + +class ParserState: + """ + Parser state. + + States are pushed and popped from a stack as necessary, and the "current" + state is always at the top of the stack. + + Upon entering and leaving a group { } or math/non-math, the stack is pushed + and popped accordingly. + """ + + def __init__(self, fontset, font, font_class, fontsize, dpi): + self.fontset = fontset + self._font = font + self.font_class = font_class + self.fontsize = fontsize + self.dpi = dpi + + def copy(self): + return copy.copy(self) + + @property + def font(self): + return self._font + + @font.setter + def font(self, name): + if name in ('rm', 'it', 'bf'): + self.font_class = name + self._font = name + + def get_current_underline_thickness(self): + """Return the underline thickness for this state.""" + return self.fontset.get_underline_thickness( + self.font, self.fontsize, self.dpi) + + +def cmd(expr, args): + r""" + Helper to define TeX commands. + + ``cmd("\cmd", args)`` is equivalent to + ``"\cmd" - (args | Error("Expected \cmd{arg}{...}"))`` where the names in + the error message are taken from element names in *args*. If *expr* + already includes arguments (e.g. "\cmd{arg}{...}"), then they are stripped + when constructing the parse element, but kept (and *expr* is used as is) in + the error message. + """ + + def names(elt): + if isinstance(elt, ParseExpression): + for expr in elt.exprs: + yield from names(expr) + elif elt.resultsName: + yield elt.resultsName + + csname = expr.split("{", 1)[0] + err = (csname + "".join("{%s}" % name for name in names(args)) + if expr == csname else expr) + return csname - (args | Error(f"Expected {err}")) class Parser: @@ -1889,51 +1721,53 @@ class Parser: """ class _MathStyle(enum.Enum): - DISPLAYSTYLE = enum.auto() - TEXTSTYLE = enum.auto() - SCRIPTSTYLE = enum.auto() - SCRIPTSCRIPTSTYLE = enum.auto() - - _binary_operators = set(''' - + * - - \\pm \\sqcap \\rhd - \\mp \\sqcup \\unlhd - \\times \\vee \\unrhd - \\div \\wedge \\oplus - \\ast \\setminus \\ominus - \\star \\wr \\otimes - \\circ \\diamond \\oslash - \\bullet \\bigtriangleup \\odot - \\cdot \\bigtriangledown \\bigcirc - \\cap \\triangleleft \\dagger - \\cup \\triangleright \\ddagger - \\uplus \\lhd \\amalg'''.split()) - - _relation_symbols = set(''' + DISPLAYSTYLE = 0 + TEXTSTYLE = 1 + SCRIPTSTYLE = 2 + SCRIPTSCRIPTSTYLE = 3 + + _binary_operators = set( + '+ * - \N{MINUS SIGN}' + r''' + \pm \sqcap \rhd + \mp \sqcup \unlhd + \times \vee \unrhd + \div \wedge \oplus + \ast \setminus \ominus + \star \wr \otimes + \circ \diamond \oslash + \bullet \bigtriangleup \odot + \cdot \bigtriangledown \bigcirc + \cap \triangleleft \dagger + \cup \triangleright \ddagger + \uplus \lhd \amalg + \dotplus \dotminus'''.split()) + + _relation_symbols = set(r''' = < > : - \\leq \\geq \\equiv \\models - \\prec \\succ \\sim \\perp - \\preceq \\succeq \\simeq \\mid - \\ll \\gg \\asymp \\parallel - \\subset \\supset \\approx \\bowtie - \\subseteq \\supseteq \\cong \\Join - \\sqsubset \\sqsupset \\neq \\smile - \\sqsubseteq \\sqsupseteq \\doteq \\frown - \\in \\ni \\propto \\vdash - \\dashv \\dots \\dotplus \\doteqdot'''.split()) - - _arrow_symbols = set(''' - \\leftarrow \\longleftarrow \\uparrow - \\Leftarrow \\Longleftarrow \\Uparrow - \\rightarrow \\longrightarrow \\downarrow - \\Rightarrow \\Longrightarrow \\Downarrow - \\leftrightarrow \\longleftrightarrow \\updownarrow - \\Leftrightarrow \\Longleftrightarrow \\Updownarrow - \\mapsto \\longmapsto \\nearrow - \\hookleftarrow \\hookrightarrow \\searrow - \\leftharpoonup \\rightharpoonup \\swarrow - \\leftharpoondown \\rightharpoondown \\nwarrow - \\rightleftharpoons \\leadsto'''.split()) + \leq \geq \equiv \models + \prec \succ \sim \perp + \preceq \succeq \simeq \mid + \ll \gg \asymp \parallel + \subset \supset \approx \bowtie + \subseteq \supseteq \cong \Join + \sqsubset \sqsupset \neq \smile + \sqsubseteq \sqsupseteq \doteq \frown + \in \ni \propto \vdash + \dashv \dots \doteqdot'''.split()) + + _arrow_symbols = set(r''' + \leftarrow \longleftarrow \uparrow + \Leftarrow \Longleftarrow \Uparrow + \rightarrow \longrightarrow \downarrow + \Rightarrow \Longrightarrow \Downarrow + \leftrightarrow \longleftrightarrow \updownarrow + \Leftrightarrow \Longleftrightarrow \Updownarrow + \mapsto \longmapsto \nearrow + \hookleftarrow \hookrightarrow \searrow + \leftharpoonup \rightharpoonup \swarrow + \leftharpoondown \rightharpoondown \nwarrow + \rightleftharpoons \leadsto'''.split()) _spaced_symbols = _binary_operators | _relation_symbols | _arrow_symbols @@ -1944,8 +1778,7 @@ class _MathStyle(enum.Enum): \bigwedge \bigodot \bigotimes \bigoplus \biguplus '''.split()) - _overunder_functions = set( - "lim liminf limsup sup max min".split()) + _overunder_functions = set("lim liminf limsup sup max min".split()) _dropsub_symbols = set(r'''\int \oint'''.split()) @@ -1956,209 +1789,142 @@ class _MathStyle(enum.Enum): liminf sin cos exp limsup sinh cosh gcd ln sup cot hom log tan coth inf max tanh""".split()) - _ambi_delim = set(""" - | \\| / \\backslash \\uparrow \\downarrow \\updownarrow \\Uparrow - \\Downarrow \\Updownarrow . \\vert \\Vert \\\\|""".split()) - - _left_delim = set(r"( [ \{ < \lfloor \langle \lceil".split()) - - _right_delim = set(r") ] \} > \rfloor \rangle \rceil".split()) + _ambi_delims = set(r""" + | \| / \backslash \uparrow \downarrow \updownarrow \Uparrow + \Downarrow \Updownarrow . \vert \Vert""".split()) + _left_delims = set(r"( [ \{ < \lfloor \langle \lceil".split()) + _right_delims = set(r") ] \} > \rfloor \rangle \rceil".split()) + _delims = _left_delims | _right_delims | _ambi_delims def __init__(self): p = types.SimpleNamespace() - # All forward declarations are here + + def set_names_and_parse_actions(): + for key, val in vars(p).items(): + if not key.startswith('_'): + # Set names on everything -- very useful for debugging + val.setName(key) + # Set actions + if hasattr(self, key): + val.setParseAction(getattr(self, key)) + + # Root definitions. + + # In TeX parlance, a csname is a control sequence name (a "\foo"). + def csnames(group, names): + ends_with_alpha = [] + ends_with_nonalpha = [] + for name in names: + if name[-1].isalpha(): + ends_with_alpha.append(name) + else: + ends_with_nonalpha.append(name) + return Regex(r"\\(?P<{}>(?:{})(?![A-Za-z]){})".format( + group, + "|".join(map(re.escape, ends_with_alpha)), + "".join(f"|{s}" for s in map(re.escape, ends_with_nonalpha)), + )) + + p.float_literal = Regex(r"[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)") + p.space = oneOf(self._space_widths)("space") + + p.style_literal = oneOf( + [str(e.value) for e in self._MathStyle])("style_literal") + + p.symbol = Regex( + r"[a-zA-Z0-9 +\-*/<>=:,.;!\?&'@()\[\]|\U00000080-\U0001ffff]" + r"|\\[%${}\[\]_|]" + + r"|\\(?:{})(?![A-Za-z])".format( + "|".join(map(re.escape, tex2uni))) + )("sym").leaveWhitespace() + p.unknown_symbol = Regex(r"\\[A-Za-z]*")("name") + + p.font = csnames("font", self._fontnames) + p.start_group = ( + Optional(r"\math" + oneOf(self._fontnames)("font")) + "{") + p.end_group = Literal("}") + + p.delim = oneOf(self._delims) + + set_names_and_parse_actions() # for root definitions. + + # Mutually recursive definitions. (Minimizing the number of Forward + # elements is important for speed.) p.accent = Forward() - p.ambi_delim = Forward() - p.apostrophe = Forward() p.auto_delim = Forward() p.binom = Forward() - p.bslash = Forward() - p.c_over_c = Forward() p.customspace = Forward() - p.end_group = Forward() - p.float_literal = Forward() - p.font = Forward() p.frac = Forward() p.dfrac = Forward() p.function = Forward() p.genfrac = Forward() p.group = Forward() - p.int_literal = Forward() - p.latexfont = Forward() - p.lbracket = Forward() - p.left_delim = Forward() - p.lbrace = Forward() - p.main = Forward() - p.math = Forward() - p.math_string = Forward() - p.non_math = Forward() p.operatorname = Forward() p.overline = Forward() p.overset = Forward() p.placeable = Forward() - p.rbrace = Forward() - p.rbracket = Forward() p.required_group = Forward() - p.right_delim = Forward() - p.right_delim_safe = Forward() p.simple = Forward() - p.simple_group = Forward() - p.single_symbol = Forward() - p.accentprefixed = Forward() - p.space = Forward() + p.optional_group = Forward() p.sqrt = Forward() - p.start_group = Forward() p.subsuper = Forward() - p.subsuperop = Forward() - p.symbol = Forward() - p.symbol_name = Forward() p.token = Forward() p.underset = Forward() - p.unknown_symbol = Forward() - - # Set names on everything -- very useful for debugging - for key, val in vars(p).items(): - if not key.startswith('_'): - val.setName(key) - - p.float_literal <<= Regex(r"[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)") - p.int_literal <<= Regex("[-+]?[0-9]+") - - p.lbrace <<= Literal('{').suppress() - p.rbrace <<= Literal('}').suppress() - p.lbracket <<= Literal('[').suppress() - p.rbracket <<= Literal(']').suppress() - p.bslash <<= Literal('\\') - - p.space <<= oneOf(list(self._space_widths)) - p.customspace <<= ( - Suppress(Literal(r'\hspace')) - - ((p.lbrace + p.float_literal + p.rbrace) - | Error(r"Expected \hspace{n}")) - ) - - unicode_range = "\U00000080-\U0001ffff" - p.single_symbol <<= Regex( - r"([a-zA-Z0-9 +\-*/<>=:,.;!\?&'@()\[\]|%s])|(\\[%%${}\[\]_|])" % - unicode_range) - p.accentprefixed <<= Suppress(p.bslash) + oneOf(self._accentprefixed) - p.symbol_name <<= ( - Combine(p.bslash + oneOf(list(tex2uni))) - + Suppress(Regex("(?=[^A-Za-z]|$)").leaveWhitespace()) - ) - p.symbol <<= (p.single_symbol | p.symbol_name).leaveWhitespace() - - p.apostrophe <<= Regex("'+") - - p.c_over_c <<= ( - Suppress(p.bslash) - + oneOf(list(self._char_over_chars)) - ) - - p.accent <<= Group( - Suppress(p.bslash) - + oneOf([*self._accent_map, *self._wide_accents]) - + Suppress(Optional(White())) - - p.placeable - ) - p.function <<= ( - Suppress(p.bslash) - + oneOf(list(self._function_names)) - ) + set_names_and_parse_actions() # for mutually recursive definitions. - p.start_group <<= Optional(p.latexfont) + p.lbrace - p.end_group <<= p.rbrace.copy() - p.simple_group <<= Group(p.lbrace + ZeroOrMore(p.token) + p.rbrace) - p.required_group <<= Group(p.lbrace + OneOrMore(p.token) + p.rbrace) - p.group <<= Group( - p.start_group + ZeroOrMore(p.token) + p.end_group - ) + p.customspace <<= cmd(r"\hspace", "{" + p.float_literal("space") + "}") - p.font <<= Suppress(p.bslash) + oneOf(list(self._fontnames)) - p.latexfont <<= ( - Suppress(p.bslash) - + oneOf(['math' + x for x in self._fontnames]) - ) + p.accent <<= ( + csnames("accent", [*self._accent_map, *self._wide_accents]) + - p.placeable("sym")) - p.frac <<= Group( - Suppress(Literal(r"\frac")) - - ((p.required_group + p.required_group) - | Error(r"Expected \frac{num}{den}")) - ) + p.function <<= csnames("name", self._function_names) + p.operatorname <<= cmd( + r"\operatorname", + "{" + ZeroOrMore(p.simple | p.unknown_symbol)("name") + "}") - p.dfrac <<= Group( - Suppress(Literal(r"\dfrac")) - - ((p.required_group + p.required_group) - | Error(r"Expected \dfrac{num}{den}")) - ) + p.group <<= p.start_group + ZeroOrMore(p.token)("group") + p.end_group - p.binom <<= Group( - Suppress(Literal(r"\binom")) - - ((p.required_group + p.required_group) - | Error(r"Expected \binom{num}{den}")) - ) + p.optional_group <<= "{" + ZeroOrMore(p.token)("group") + "}" + p.required_group <<= "{" + OneOrMore(p.token)("group") + "}" - p.ambi_delim <<= oneOf(list(self._ambi_delim)) - p.left_delim <<= oneOf(list(self._left_delim)) - p.right_delim <<= oneOf(list(self._right_delim)) - p.right_delim_safe <<= oneOf([*(self._right_delim - {'}'}), r'\}']) - - p.genfrac <<= Group( - Suppress(Literal(r"\genfrac")) - - (((p.lbrace - + Optional(p.ambi_delim | p.left_delim, default='') - + p.rbrace) - + (p.lbrace - + Optional(p.ambi_delim | p.right_delim_safe, default='') - + p.rbrace) - + (p.lbrace + p.float_literal + p.rbrace) - + p.simple_group + p.required_group + p.required_group) - | Error("Expected " - r"\genfrac{ldelim}{rdelim}{rulesize}{style}{num}{den}")) - ) + p.frac <<= cmd( + r"\frac", p.required_group("num") + p.required_group("den")) + p.dfrac <<= cmd( + r"\dfrac", p.required_group("num") + p.required_group("den")) + p.binom <<= cmd( + r"\binom", p.required_group("num") + p.required_group("den")) - p.sqrt <<= Group( - Suppress(Literal(r"\sqrt")) - - ((Group(Optional( - p.lbracket + OneOrMore(~p.rbracket + p.token) + p.rbracket)) - + p.required_group) - | Error("Expected \\sqrt{value}")) - ) + p.genfrac <<= cmd( + r"\genfrac", + "{" + Optional(p.delim)("ldelim") + "}" + + "{" + Optional(p.delim)("rdelim") + "}" + + "{" + p.float_literal("rulesize") + "}" + + "{" + Optional(p.style_literal)("style") + "}" + + p.required_group("num") + + p.required_group("den")) - p.overline <<= Group( - Suppress(Literal(r"\overline")) - - (p.required_group | Error("Expected \\overline{value}")) - ) + p.sqrt <<= cmd( + r"\sqrt{value}", + Optional("[" + OneOrMore(NotAny("]") + p.token)("root") + "]") + + p.required_group("value")) - p.overset <<= Group( - Suppress(Literal(r"\overset")) - - ((p.simple_group + p.simple_group) - | Error("Expected \\overset{body}{annotation}")) - ) + p.overline <<= cmd(r"\overline", p.required_group("body")) - p.underset <<= Group( - Suppress(Literal(r"\underset")) - - ((p.simple_group + p.simple_group) - | Error("Expected \\underset{body}{annotation}")) - ) - - p.unknown_symbol <<= Combine(p.bslash + Regex("[A-Za-z]*")) - - p.operatorname <<= Group( - Suppress(Literal(r"\operatorname")) - - ((p.lbrace + ZeroOrMore(p.simple | p.unknown_symbol) + p.rbrace) - | Error("Expected \\operatorname{value}")) - ) + p.overset <<= cmd( + r"\overset", + p.optional_group("annotation") + p.optional_group("body")) + p.underset <<= cmd( + r"\underset", + p.optional_group("annotation") + p.optional_group("body")) p.placeable <<= ( - p.accentprefixed # Must be before accent so named symbols that are - # prefixed with an accent name work - | p.accent # Must be before symbol as all accents are symbols - | p.symbol # Must be third to catch all named symbols and single + p.accent # Must be before symbol as all accents are symbols + | p.symbol # Must be second to catch all named symbols and single # chars not in a group - | p.c_over_c | p.function + | p.operatorname | p.group | p.frac | p.dfrac @@ -2168,7 +1934,6 @@ def __init__(self): | p.underset | p.sqrt | p.overline - | p.operatorname ) p.simple <<= ( @@ -2178,14 +1943,12 @@ def __init__(self): | p.subsuper ) - p.subsuperop <<= oneOf(["_", "^"]) - - p.subsuper <<= Group( - (Optional(p.placeable) - + OneOrMore(p.subsuperop - p.placeable) - + Optional(p.apostrophe)) - | (p.placeable + Optional(p.apostrophe)) - | p.apostrophe + p.subsuper <<= ( + (Optional(p.placeable)("nucleus") + + OneOrMore(oneOf(["_", "^"]) - p.placeable)("subsuper") + + Regex("'*")("apostrophes")) + | Regex("'+")("apostrophes") + | (p.placeable("nucleus") + Regex("'*")("apostrophes")) ) p.token <<= ( @@ -2195,34 +1958,26 @@ def __init__(self): ) p.auto_delim <<= ( - Suppress(Literal(r"\left")) - - ((p.left_delim | p.ambi_delim) - | Error("Expected a delimiter")) - + Group(ZeroOrMore(p.simple | p.auto_delim)) - + Suppress(Literal(r"\right")) - - ((p.right_delim | p.ambi_delim) - | Error("Expected a delimiter")) + r"\left" - (p.delim("left") | Error("Expected a delimiter")) + + ZeroOrMore(p.simple | p.auto_delim)("mid") + + r"\right" - (p.delim("right") | Error("Expected a delimiter")) ) - p.math <<= OneOrMore(p.token) - - p.math_string <<= QuotedString('$', '\\', unquoteResults=False) - - p.non_math <<= Regex(r"(?:(?:\\[$])|[^$])*").leaveWhitespace() - - p.main <<= ( + # Leaf definitions. + p.math = OneOrMore(p.token) + p.math_string = QuotedString('$', '\\', unquoteResults=False) + p.non_math = Regex(r"(?:(?:\\[$])|[^$])*").leaveWhitespace() + p.main = ( p.non_math + ZeroOrMore(p.math_string + p.non_math) + StringEnd() ) - - # Set actions - for key, val in vars(p).items(): - if not key.startswith('_'): - if hasattr(self, key): - val.setParseAction(getattr(self, key)) + set_names_and_parse_actions() # for leaf definitions. self._expression = p.main self._math_expression = p.math + # To add space to nucleus operators after sub/superscripts + self._in_subscript_or_superscript = False + def parse(self, s, fonts_object, fontsize, dpi): """ Parse expression *s* using the given *fonts_object* for @@ -2231,56 +1986,20 @@ def parse(self, s, fonts_object, fontsize, dpi): Returns the parse tree of `Node` instances. """ self._state_stack = [ - self.State(fonts_object, 'default', 'rm', fontsize, dpi)] + ParserState(fonts_object, 'default', 'rm', fontsize, dpi)] self._em_width_cache = {} try: result = self._expression.parseString(s) except ParseBaseException as err: - raise ValueError("\n".join(["", - err.line, - " " * (err.column - 1) + "^", - str(err)])) from err + # explain becomes a plain method on pyparsing 3 (err.explain(0)). + raise ValueError("\n" + ParseException.explain(err, 0)) from None self._state_stack = None + self._in_subscript_or_superscript = False + # prevent operator spacing from leaking into a new expression self._em_width_cache = {} self._expression.resetCache() return result[0] - # The state of the parser is maintained in a stack. Upon - # entering and leaving a group { } or math/non-math, the stack - # is pushed and popped accordingly. The current state always - # exists in the top element of the stack. - class State: - """ - Stores the state of the parser. - - States are pushed and popped from a stack as necessary, and - the "current" state is always at the top of the stack. - """ - def __init__(self, font_output, font, font_class, fontsize, dpi): - self.font_output = font_output - self._font = font - self.font_class = font_class - self.fontsize = fontsize - self.dpi = dpi - - def copy(self): - return Parser.State( - self.font_output, - self.font, - self.font_class, - self.fontsize, - self.dpi) - - @property - def font(self): - return self._font - - @font.setter - def font(self, name): - if name in ('rm', 'it', 'bf'): - self.font_class = name - self._font = name - def get_state(self): """Get the current `State` of the parser.""" return self._state_stack[-1] @@ -2306,21 +2025,27 @@ def math(self, s, loc, toks): def non_math(self, s, loc, toks): s = toks[0].replace(r'\$', '$') - symbols = [Char(c, self.get_state(), math=False) for c in s] + symbols = [Char(c, self.get_state()) for c in s] hlist = Hlist(symbols) # We're going into math now, so set font to 'it' self.push_state() self.get_state().font = mpl.rcParams['mathtext.default'] return [hlist] + float_literal = staticmethod(pyparsing_common.convertToFloat) + def _make_space(self, percentage): - # All spaces are relative to em width + # In TeX, an em (the unit usually used to measure horizontal lengths) + # is not the width of the character 'm'; it is the same in different + # font styles (e.g. roman or italic). Mathtext, however, uses 'm' in + # the italic style so that horizontal spaces don't depend on the + # current font style. state = self.get_state() key = (state.font, state.fontsize, state.dpi) width = self._em_width_cache.get(key) if width is None: - metrics = state.font_output.get_metrics( - state.font, mpl.rcParams['mathtext.default'], 'm', + metrics = state.fontset.get_metrics( + 'it', mpl.rcParams['mathtext.default'], 'm', state.fontsize, state.dpi) width = metrics.advance self._em_width_cache[key] = width @@ -2342,16 +2067,22 @@ def _make_space(self, percentage): } def space(self, s, loc, toks): - tok, = toks - num = self._space_widths[tok] + num = self._space_widths[toks["space"]] box = self._make_space(num) return [box] def customspace(self, s, loc, toks): - return [self._make_space(float(toks[0]))] + return [self._make_space(toks["space"])] def symbol(self, s, loc, toks): - c, = toks + c = toks["sym"] + if c == "-": + # "U+2212 minus sign is the preferred representation of the unary + # and binary minus sign rather than the ASCII-derived U+002D + # hyphen-minus, because minus sign is unambiguous and because it + # is rendered with a more desirable length, usually longer than a + # hyphen." (https://www.unicode.org/reports/tr25/) + c = "\N{MINUS SIGN}" try: char = Char(c, self.get_state()) except ValueError as err: @@ -2365,7 +2096,7 @@ def symbol(self, s, loc, toks): # Binary operators at start of string should not be spaced if (c in self._binary_operators and (len(s[:loc].split()) == 0 or prev_char == '{' or - prev_char in self._left_delim)): + prev_char in self._left_delims)): return [char] else: return [Hlist([self._make_space(0.2), @@ -2373,70 +2104,23 @@ def symbol(self, s, loc, toks): self._make_space(0.2)], do_kern=True)] elif c in self._punctuation_symbols: + prev_char = next((c for c in s[:loc][::-1] if c != ' '), '') + next_char = next((c for c in s[loc + 1:] if c != ' '), '') # Do not space commas between brackets if c == ',': - prev_char = next((c for c in s[:loc][::-1] if c != ' '), '') - next_char = next((c for c in s[loc + 1:] if c != ' '), '') if prev_char == '{' and next_char == '}': return [char] # Do not space dots as decimal separators - if c == '.' and s[loc - 1].isdigit() and s[loc + 1].isdigit(): + if c == '.' and prev_char.isdigit() and next_char.isdigit(): return [char] else: return [Hlist([char, self._make_space(0.2)], do_kern=True)] return [char] - accentprefixed = symbol - def unknown_symbol(self, s, loc, toks): - c, = toks - raise ParseFatalException(s, loc, "Unknown symbol: %s" % c) - - _char_over_chars = { - # The first 2 entries in the tuple are (font, char, sizescale) for - # the two symbols under and over. The third element is the space - # (in multiples of underline height) - r'AA': (('it', 'A', 1.0), (None, '\\circ', 0.5), 0.0), - } - - def c_over_c(self, s, loc, toks): - sym, = toks - state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) - - under_desc, over_desc, space = \ - self._char_over_chars.get(sym, (None, None, 0.0)) - if under_desc is None: - raise ParseFatalException("Error parsing symbol") - - over_state = state.copy() - if over_desc[0] is not None: - over_state.font = over_desc[0] - over_state.fontsize *= over_desc[2] - over = Accent(over_desc[1], over_state) - - under_state = state.copy() - if under_desc[0] is not None: - under_state.font = under_desc[0] - under_state.fontsize *= under_desc[2] - under = Char(under_desc[1], under_state) - - width = max(over.width, under.width) - - over_centered = HCentered([over]) - over_centered.hpack(width, 'exactly') - - under_centered = HCentered([under]) - under_centered.hpack(width, 'exactly') - - return Vlist([ - over_centered, - Vbox(0., thickness * space), - under_centered - ]) + raise ParseFatalException(s, loc, f"Unknown symbol: {toks['name']}") _accent_map = { r'hat': r'\circumflexaccent', @@ -2463,17 +2147,11 @@ def c_over_c(self, s, loc, toks): _wide_accents = set(r"widehat widetilde widebar".split()) - # make a lambda and call it to get the namespace right - _accentprefixed = (lambda am: [ - p for p in tex2uni - if any(p.startswith(a) and a != p for a in am) - ])(set(_accent_map)) - def accent(self, s, loc, toks): state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) - (accent, sym), = toks + thickness = state.get_current_underline_thickness() + accent = toks["accent"] + sym = toks["sym"] if accent in self._wide_accents: accent_box = AutoWidthChar( '\\' + accent, sym.width, state, char_class=Accent) @@ -2492,7 +2170,7 @@ def accent(self, s, loc, toks): def function(self, s, loc, toks): hlist = self.operatorname(s, loc, toks) - hlist.function_name, = toks + hlist.function_name = toks["name"] return hlist def operatorname(self, s, loc, toks): @@ -2501,7 +2179,8 @@ def operatorname(self, s, loc, toks): state.font = 'rm' hlist_list = [] # Change the font of Chars, but leave Kerns alone - for c in toks[0]: + name = toks["name"] + for c in name: if isinstance(c, Char): c.font = 'rm' c._update_metrics() @@ -2510,38 +2189,47 @@ def operatorname(self, s, loc, toks): hlist_list.append(Char(c, state)) else: hlist_list.append(c) - next_char_loc = loc + len(toks[0]) + 1 - if isinstance(toks[0], ParseResults): + next_char_loc = loc + len(name) + 1 + if isinstance(name, ParseResults): next_char_loc += len('operatorname{}') next_char = next((c for c in s[next_char_loc:] if c != ' '), '') - delimiters = self._left_delim | self._ambi_delim | self._right_delim - delimiters |= {'^', '_'} + delimiters = self._delims | {'^', '_'} if (next_char not in delimiters and - toks[0] not in self._overunder_functions): + name not in self._overunder_functions): # Add thin space except when followed by parenthesis, bracket, etc. hlist_list += [self._make_space(self._space_widths[r'\,'])] self.pop_state() + # if followed by a super/subscript, set flag to true + # This flag tells subsuper to add space after this operator + if next_char in {'^', '_'}: + self._in_subscript_or_superscript = True + else: + self._in_subscript_or_superscript = False + return Hlist(hlist_list) def start_group(self, s, loc, toks): self.push_state() # Deal with LaTeX-style font tokens - if len(toks): - self.get_state().font = toks[0][4:] + if toks.get("font"): + self.get_state().font = toks.get("font") return [] def group(self, s, loc, toks): - grp = Hlist(toks[0]) + grp = Hlist(toks.get("group", [])) return [grp] - required_group = simple_group = group + + def required_group(self, s, loc, toks): + return Hlist(toks.get("group", [])) + + optional_group = required_group def end_group(self, s, loc, toks): self.pop_state() return [] def font(self, s, loc, toks): - name, = toks - self.get_state().font = name + self.get_state().font = toks["font"] return [] def is_overunder(self, nucleus): @@ -2565,72 +2253,36 @@ def is_between_brackets(self, s, loc): return False def subsuper(self, s, loc, toks): - assert len(toks) == 1 - - nucleus = None - sub = None - super = None - - # Pick all of the apostrophes out, including first apostrophes that - # have been parsed as characters - napostrophes = 0 - new_toks = [] - for tok in toks[0]: - if isinstance(tok, str) and tok not in ('^', '_'): - napostrophes += len(tok) - elif isinstance(tok, Char) and tok.c == "'": - napostrophes += 1 - else: - new_toks.append(tok) - toks = new_toks - - if len(toks) == 0: - assert napostrophes - nucleus = Hbox(0.0) - elif len(toks) == 1: - if not napostrophes: - return toks[0] # .asList() - else: - nucleus = toks[0] - elif len(toks) in (2, 3): - # single subscript or superscript - nucleus = toks[0] if len(toks) == 3 else Hbox(0.0) - op, next = toks[-2:] + nucleus = toks.get("nucleus", Hbox(0)) + subsuper = toks.get("subsuper", []) + napostrophes = len(toks.get("apostrophes", [])) + + if not subsuper and not napostrophes: + return nucleus + + sub = super = None + while subsuper: + op, arg, *subsuper = subsuper if op == '_': - sub = next - else: - super = next - elif len(toks) in (4, 5): - # subscript and superscript - nucleus = toks[0] if len(toks) == 5 else Hbox(0.0) - op1, next1, op2, next2 = toks[-4:] - if op1 == op2: - if op1 == '_': + if sub is not None: raise ParseFatalException("Double subscript") - else: - raise ParseFatalException("Double superscript") - if op1 == '_': - sub = next1 - super = next2 + sub = arg else: - super = next1 - sub = next2 - else: - raise ParseFatalException( - "Subscript/superscript sequence is too long. " - "Use braces { } to remove ambiguity.") + if super is not None: + raise ParseFatalException("Double superscript") + super = arg state = self.get_state() - rule_thickness = state.font_output.get_underline_thickness( + rule_thickness = state.fontset.get_underline_thickness( state.font, state.fontsize, state.dpi) - xHeight = state.font_output.get_xheight( + xHeight = state.fontset.get_xheight( state.font, state.fontsize, state.dpi) if napostrophes: if super is None: super = Hlist([]) for i in range(napostrophes): - super.children.extend(self.symbol(s, loc, ['\\prime'])) + super.children.extend(self.symbol(s, loc, {"sym": "\\prime"})) # kern() and hpack() needed to get the metrics right after # extending super.kern() @@ -2660,9 +2312,9 @@ def subsuper(self, s, loc, toks): hlist = HCentered([sub]) hlist.hpack(width, 'exactly') vlist.extend([Vbox(0, vgap), hlist]) - shift = hlist.height + vgap + shift = hlist.height + vgap + nucleus.depth vlist = Vlist(vlist) - vlist.shift_amount = shift + nucleus.depth + vlist.shift_amount = shift result = Hlist([vlist]) return [result] @@ -2751,18 +2403,22 @@ def subsuper(self, s, loc, toks): if not self.is_dropsub(last_char): x.width += constants.script_space * xHeight - result = Hlist([nucleus, x]) + # Do we need to add a space after the nucleus? + # To find out, check the flag set by operatorname + spaced_nucleus = [nucleus, x] + if self._in_subscript_or_superscript: + spaced_nucleus += [self._make_space(self._space_widths[r'\,'])] + self._in_subscript_or_superscript = False + + result = Hlist(spaced_nucleus) return [result] def _genfrac(self, ldelim, rdelim, rule, style, num, den): state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) - - rule = float(rule) + thickness = state.get_current_underline_thickness() - if style is not self._MathStyle.DISPLAYSTYLE: + for _ in range(style.value): num.shrink() den.shrink() cnum = HCentered([num]) @@ -2779,7 +2435,7 @@ def _genfrac(self, ldelim, rdelim, rule, style, num, den): # Shift so the fraction line sits in the middle of the # equals sign - metrics = state.font_output.get_metrics( + metrics = state.fontset.get_metrics( state.font, mpl.rcParams['mathtext.default'], '=', state.fontsize, state.dpi) shift = (cden.height - @@ -2796,36 +2452,34 @@ def _genfrac(self, ldelim, rdelim, rule, style, num, den): return self._auto_sized_delimiter(ldelim, result, rdelim) return result + def style_literal(self, s, loc, toks): + return self._MathStyle(int(toks["style_literal"])) + def genfrac(self, s, loc, toks): - args, = toks - return self._genfrac(*args) + return self._genfrac( + toks.get("ldelim", ""), toks.get("rdelim", ""), + toks["rulesize"], toks.get("style", self._MathStyle.TEXTSTYLE), + toks["num"], toks["den"]) def frac(self, s, loc, toks): - state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) - (num, den), = toks - return self._genfrac('', '', thickness, self._MathStyle.TEXTSTYLE, - num, den) + return self._genfrac( + "", "", self.get_state().get_current_underline_thickness(), + self._MathStyle.TEXTSTYLE, toks["num"], toks["den"]) def dfrac(self, s, loc, toks): - state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) - (num, den), = toks - return self._genfrac('', '', thickness, self._MathStyle.DISPLAYSTYLE, - num, den) + return self._genfrac( + "", "", self.get_state().get_current_underline_thickness(), + self._MathStyle.DISPLAYSTYLE, toks["num"], toks["den"]) def binom(self, s, loc, toks): - (num, den), = toks - return self._genfrac('(', ')', 0.0, self._MathStyle.TEXTSTYLE, - num, den) + return self._genfrac( + "(", ")", 0, + self._MathStyle.TEXTSTYLE, toks["num"], toks["den"]) def _genset(self, s, loc, toks): - (annotation, body), = toks - state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) + annotation = toks["annotation"] + body = toks["body"] + thickness = self.get_state().get_current_underline_thickness() annotation.shrink() cannotation = HCentered([annotation]) @@ -2855,10 +2509,10 @@ def _genset(self, s, loc, toks): overset = underset = _genset def sqrt(self, s, loc, toks): - (root, body), = toks + root = toks.get("root") + body = toks["value"] state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) + thickness = state.get_current_underline_thickness() # Determine the height of the body, and add a little extra to # the height so it doesn't seem cramped @@ -2895,11 +2549,10 @@ def sqrt(self, s, loc, toks): return [hlist] def overline(self, s, loc, toks): - (body,), = toks + body = toks["body"] state = self.get_state() - thickness = state.font_output.get_underline_thickness( - state.font, state.fontsize, state.dpi) + thickness = state.get_current_underline_thickness() height = body.height - body.shift_amount + thickness * 3.0 depth = body.depth + body.shift_amount @@ -2937,6 +2590,8 @@ def _auto_sized_delimiter(self, front, middle, back): return hlist def auto_delim(self, s, loc, toks): - front, middle, back = toks - - return self._auto_sized_delimiter(front, middle.asList(), back) + return self._auto_sized_delimiter( + toks["left"], + # if "mid" in toks ... can be removed when requiring pyparsing 3. + toks["mid"].asList() if "mid" in toks else [], + toks["right"]) diff --git a/lib/matplotlib/_mathtext_data.py b/lib/matplotlib/_mathtext_data.py index 1536bdc5293d..ef571b90712e 100644 --- a/lib/matplotlib/_mathtext_data.py +++ b/lib/matplotlib/_mathtext_data.py @@ -132,7 +132,7 @@ ']' : ('cmr10', 0x5d), '*' : ('cmsy10', 0xa4), - '-' : ('cmsy10', 0xa1), + '\N{MINUS SIGN}' : ('cmsy10', 0xa1), '\\Downarrow' : ('cmsy10', 0x2b), '\\Im' : ('cmsy10', 0x3d), '\\Leftarrow' : ('cmsy10', 0x28), @@ -236,173 +236,6 @@ '\\_' : ('cmtt10', 0x5f) } -latex_to_cmex = { # Unused; delete once mathtext becomes private. - r'\__sqrt__' : 112, - r'\bigcap' : 92, - r'\bigcup' : 91, - r'\bigodot' : 75, - r'\bigoplus' : 77, - r'\bigotimes' : 79, - r'\biguplus' : 93, - r'\bigvee' : 95, - r'\bigwedge' : 94, - r'\coprod' : 97, - r'\int' : 90, - r'\leftangle' : 173, - r'\leftbrace' : 169, - r'\oint' : 73, - r'\prod' : 89, - r'\rightangle' : 174, - r'\rightbrace' : 170, - r'\sum' : 88, - r'\widehat' : 98, - r'\widetilde' : 101, -} - -latex_to_standard = { - r'\cong' : ('psyr', 64), - r'\Delta' : ('psyr', 68), - r'\Phi' : ('psyr', 70), - r'\Gamma' : ('psyr', 89), - r'\alpha' : ('psyr', 97), - r'\beta' : ('psyr', 98), - r'\chi' : ('psyr', 99), - r'\delta' : ('psyr', 100), - r'\varepsilon' : ('psyr', 101), - r'\phi' : ('psyr', 102), - r'\gamma' : ('psyr', 103), - r'\eta' : ('psyr', 104), - r'\iota' : ('psyr', 105), - r'\varphi' : ('psyr', 106), - r'\kappa' : ('psyr', 108), - r'\nu' : ('psyr', 110), - r'\pi' : ('psyr', 112), - r'\theta' : ('psyr', 113), - r'\rho' : ('psyr', 114), - r'\sigma' : ('psyr', 115), - r'\tau' : ('psyr', 116), - r'\upsilon' : ('psyr', 117), - r'\varpi' : ('psyr', 118), - r'\omega' : ('psyr', 119), - r'\xi' : ('psyr', 120), - r'\psi' : ('psyr', 121), - r'\zeta' : ('psyr', 122), - r'\sim' : ('psyr', 126), - r'\leq' : ('psyr', 163), - r'\infty' : ('psyr', 165), - r'\clubsuit' : ('psyr', 167), - r'\diamondsuit' : ('psyr', 168), - r'\heartsuit' : ('psyr', 169), - r'\spadesuit' : ('psyr', 170), - r'\leftrightarrow' : ('psyr', 171), - r'\leftarrow' : ('psyr', 172), - r'\uparrow' : ('psyr', 173), - r'\rightarrow' : ('psyr', 174), - r'\downarrow' : ('psyr', 175), - r'\pm' : ('psyr', 176), - r'\geq' : ('psyr', 179), - r'\times' : ('psyr', 180), - r'\propto' : ('psyr', 181), - r'\partial' : ('psyr', 182), - r'\bullet' : ('psyr', 183), - r'\div' : ('psyr', 184), - r'\neq' : ('psyr', 185), - r'\equiv' : ('psyr', 186), - r'\approx' : ('psyr', 187), - r'\ldots' : ('psyr', 188), - r'\aleph' : ('psyr', 192), - r'\Im' : ('psyr', 193), - r'\Re' : ('psyr', 194), - r'\wp' : ('psyr', 195), - r'\otimes' : ('psyr', 196), - r'\oplus' : ('psyr', 197), - r'\oslash' : ('psyr', 198), - r'\cap' : ('psyr', 199), - r'\cup' : ('psyr', 200), - r'\supset' : ('psyr', 201), - r'\supseteq' : ('psyr', 202), - r'\subset' : ('psyr', 204), - r'\subseteq' : ('psyr', 205), - r'\in' : ('psyr', 206), - r'\notin' : ('psyr', 207), - r'\angle' : ('psyr', 208), - r'\nabla' : ('psyr', 209), - r'\textregistered' : ('psyr', 210), - r'\copyright' : ('psyr', 211), - r'\texttrademark' : ('psyr', 212), - r'\Pi' : ('psyr', 213), - r'\prod' : ('psyr', 213), - r'\surd' : ('psyr', 214), - r'\__sqrt__' : ('psyr', 214), - r'\cdot' : ('psyr', 215), - r'\urcorner' : ('psyr', 216), - r'\vee' : ('psyr', 217), - r'\wedge' : ('psyr', 218), - r'\Leftrightarrow' : ('psyr', 219), - r'\Leftarrow' : ('psyr', 220), - r'\Uparrow' : ('psyr', 221), - r'\Rightarrow' : ('psyr', 222), - r'\Downarrow' : ('psyr', 223), - r'\Diamond' : ('psyr', 224), - r'\Sigma' : ('psyr', 229), - r'\sum' : ('psyr', 229), - r'\forall' : ('psyr', 34), - r'\exists' : ('psyr', 36), - r'\lceil' : ('psyr', 233), - r'\lbrace' : ('psyr', 123), - r'\Psi' : ('psyr', 89), - r'\bot' : ('psyr', 0o136), - r'\Omega' : ('psyr', 0o127), - r'\leftbracket' : ('psyr', 0o133), - r'\rightbracket' : ('psyr', 0o135), - r'\leftbrace' : ('psyr', 123), - r'\leftparen' : ('psyr', 0o50), - r'\prime' : ('psyr', 0o242), - r'\sharp' : ('psyr', 0o43), - r'\slash' : ('psyr', 0o57), - r'\Lambda' : ('psyr', 0o114), - r'\neg' : ('psyr', 0o330), - r'\Upsilon' : ('psyr', 0o241), - r'\rightbrace' : ('psyr', 0o175), - r'\rfloor' : ('psyr', 0o373), - r'\lambda' : ('psyr', 0o154), - r'\to' : ('psyr', 0o256), - r'\Xi' : ('psyr', 0o130), - r'\emptyset' : ('psyr', 0o306), - r'\lfloor' : ('psyr', 0o353), - r'\rightparen' : ('psyr', 0o51), - r'\rceil' : ('psyr', 0o371), - r'\ni' : ('psyr', 0o47), - r'\epsilon' : ('psyr', 0o145), - r'\Theta' : ('psyr', 0o121), - r'\langle' : ('psyr', 0o341), - r'\leftangle' : ('psyr', 0o341), - r'\rangle' : ('psyr', 0o361), - r'\rightangle' : ('psyr', 0o361), - r'\rbrace' : ('psyr', 0o175), - r'\circ' : ('psyr', 0o260), - r'\diamond' : ('psyr', 0o340), - r'\mu' : ('psyr', 0o155), - r'\mid' : ('psyr', 0o352), - r'\imath' : ('pncri8a', 105), - r'\%' : ('pncr8a', 37), - r'\$' : ('pncr8a', 36), - r'\{' : ('pncr8a', 123), - r'\}' : ('pncr8a', 125), - r'\backslash' : ('pncr8a', 92), - r'\ast' : ('pncr8a', 42), - r'\#' : ('pncr8a', 35), - - r'\circumflexaccent' : ('pncri8a', 124), # for \hat - r'\combiningbreve' : ('pncri8a', 81), # for \breve - r'\combininggraveaccent' : ('pncri8a', 114), # for \grave - r'\combiningacuteaccent' : ('pncri8a', 63), # for \accute - r'\combiningdiaeresis' : ('pncri8a', 91), # for \ddot - r'\combiningtilde' : ('pncri8a', 75), # for \tilde - r'\combiningrightarrowabove' : ('pncri8a', 110), # for \vec - r'\combiningdotabove' : ('pncri8a', 26), # for \dot -} - # Automatically generated. type12uni = { @@ -693,7 +526,7 @@ 'succnsim' : 8937, 'gimel' : 8503, 'vert' : 124, - '|' : 124, + '|' : 8214, 'varrho' : 1009, 'P' : 182, 'approxident' : 8779, @@ -1116,6 +949,7 @@ 'cwopencirclearrow' : 8635, 'geqq' : 8807, 'rightleftarrows' : 8644, + 'aa' : 229, 'ac' : 8766, 'ae' : 230, 'int' : 8747, diff --git a/lib/matplotlib/_pylab_helpers.py b/lib/matplotlib/_pylab_helpers.py index 24fcb81fc9b5..d32a69d4ff99 100644 --- a/lib/matplotlib/_pylab_helpers.py +++ b/lib/matplotlib/_pylab_helpers.py @@ -4,7 +4,6 @@ import atexit from collections import OrderedDict -import gc class Gcf: @@ -66,10 +65,6 @@ def destroy(cls, num): manager.canvas.mpl_disconnect(manager._cidgcf) manager.destroy() del manager, num - # Full cyclic garbage collection may be too expensive to do on every - # figure destruction, so we collect only the youngest two generations. - # see: https://github.com/matplotlib/matplotlib/pull/3045 - gc.collect(1) @classmethod def destroy_fig(cls, fig): @@ -82,14 +77,10 @@ def destroy_fig(cls, fig): @classmethod def destroy_all(cls): """Destroy all figures.""" - # Reimport gc in case the module globals have already been removed - # during interpreter shutdown. - import gc for manager in list(cls.figs.values()): manager.canvas.mpl_disconnect(manager._cidgcf) manager.destroy() cls.figs.clear() - gc.collect(1) @classmethod def has_fignum(cls, num): diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index 75d84997be9f..18bfb550c90b 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -9,7 +9,7 @@ LayoutItem = dataclasses.make_dataclass( - "LayoutItem", ["char", "glyph_idx", "x", "prev_kern"]) + "LayoutItem", ["ft_object", "char", "glyph_idx", "x", "prev_kern"]) def warn_on_missing_glyph(codepoint): @@ -57,12 +57,18 @@ def layout(string, font, *, kern_mode=KERNING_DEFAULT): """ x = 0 prev_glyph_idx = None + char_to_font = font._get_fontmap(string) + base_font = font for char in string: + # This has done the fallback logic + font = char_to_font.get(char, base_font) glyph_idx = font.get_char_index(ord(char)) - kern = (font.get_kerning(prev_glyph_idx, glyph_idx, kern_mode) / 64 - if prev_glyph_idx is not None else 0.) + kern = ( + base_font.get_kerning(prev_glyph_idx, glyph_idx, kern_mode) / 64 + if prev_glyph_idx is not None else 0. + ) x += kern glyph = font.load_glyph(glyph_idx, flags=LOAD_NO_HINTING) - yield LayoutItem(char, glyph_idx, x, kern) + yield LayoutItem(font, char, glyph_idx, x, kern) x += glyph.linearHoriAdvance / 65536 prev_glyph_idx = glyph_idx diff --git a/lib/matplotlib/_tight_bbox.py b/lib/matplotlib/_tight_bbox.py new file mode 100644 index 000000000000..e0fba389d120 --- /dev/null +++ b/lib/matplotlib/_tight_bbox.py @@ -0,0 +1,81 @@ +""" +Helper module for the *bbox_inches* parameter in `.Figure.savefig`. +""" + +from matplotlib.transforms import Bbox, TransformedBbox, Affine2D + + +def adjust_bbox(fig, bbox_inches, fixed_dpi=None): + """ + Temporarily adjust the figure so that only the specified area + (bbox_inches) is saved. + + It modifies fig.bbox, fig.bbox_inches, + fig.transFigure._boxout, and fig.patch. While the figure size + changes, the scale of the original figure is conserved. A + function which restores the original values are returned. + """ + origBbox = fig.bbox + origBboxInches = fig.bbox_inches + _boxout = fig.transFigure._boxout + + old_aspect = [] + locator_list = [] + sentinel = object() + for ax in fig.axes: + locator_list.append(ax.get_axes_locator()) + current_pos = ax.get_position(original=False).frozen() + ax.set_axes_locator(lambda a, r, _pos=current_pos: _pos) + # override the method that enforces the aspect ratio on the Axes + if 'apply_aspect' in ax.__dict__: + old_aspect.append(ax.apply_aspect) + else: + old_aspect.append(sentinel) + ax.apply_aspect = lambda pos=None: None + + def restore_bbox(): + for ax, loc, aspect in zip(fig.axes, locator_list, old_aspect): + ax.set_axes_locator(loc) + if aspect is sentinel: + # delete our no-op function which un-hides the original method + del ax.apply_aspect + else: + ax.apply_aspect = aspect + + fig.bbox = origBbox + fig.bbox_inches = origBboxInches + fig.transFigure._boxout = _boxout + fig.transFigure.invalidate() + fig.patch.set_bounds(0, 0, 1, 1) + + if fixed_dpi is None: + fixed_dpi = fig.dpi + tr = Affine2D().scale(fixed_dpi) + dpi_scale = fixed_dpi / fig.dpi + + fig.bbox_inches = Bbox.from_bounds(0, 0, *bbox_inches.size) + x0, y0 = tr.transform(bbox_inches.p0) + w1, h1 = fig.bbox.size * dpi_scale + fig.transFigure._boxout = Bbox.from_bounds(-x0, -y0, w1, h1) + fig.transFigure.invalidate() + + fig.bbox = TransformedBbox(fig.bbox_inches, tr) + + fig.patch.set_bounds(x0 / w1, y0 / h1, + fig.bbox.width / w1, fig.bbox.height / h1) + + return restore_bbox + + +def process_figure_for_rasterizing(fig, bbox_inches_restore, fixed_dpi=None): + """ + A function that needs to be called when figure dpi changes during the + drawing (e.g., rasterizing). It recovers the bbox and re-adjust it with + the new dpi. + """ + + bbox_inches, restore_bbox = bbox_inches_restore + restore_bbox() + r = adjust_bbox(fig, bbox_inches, fixed_dpi) + + return bbox_inches, r diff --git a/lib/matplotlib/_tight_layout.py b/lib/matplotlib/_tight_layout.py new file mode 100644 index 000000000000..e99ba49bd284 --- /dev/null +++ b/lib/matplotlib/_tight_layout.py @@ -0,0 +1,301 @@ +""" +Routines to adjust subplot params so that subplots are +nicely fit in the figure. In doing so, only axis labels, tick labels, axes +titles and offsetboxes that are anchored to axes are currently considered. + +Internally, this module assumes that the margins (left margin, etc.) which are +differences between ``Axes.get_tightbbox`` and ``Axes.bbox`` are independent of +Axes position. This may fail if ``Axes.adjustable`` is ``datalim`` as well as +such cases as when left or right margin are affected by xlabel. +""" + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, artist as martist +from matplotlib.font_manager import FontProperties +from matplotlib.transforms import Bbox + + +def _auto_adjust_subplotpars( + fig, renderer, shape, span_pairs, subplot_list, + ax_bbox_list=None, pad=1.08, h_pad=None, w_pad=None, rect=None): + """ + Return a dict of subplot parameters to adjust spacing between subplots + or ``None`` if resulting axes would have zero height or width. + + Note that this function ignores geometry information of subplot itself, but + uses what is given by the *shape* and *subplot_list* parameters. Also, the + results could be incorrect if some subplots have ``adjustable=datalim``. + + Parameters + ---------- + shape : tuple[int, int] + Number of rows and columns of the grid. + span_pairs : list[tuple[slice, slice]] + List of rowspans and colspans occupied by each subplot. + subplot_list : list of subplots + List of subplots that will be used to calculate optimal subplot_params. + pad : float + Padding between the figure edge and the edges of subplots, as a + fraction of the font size. + h_pad, w_pad : float + Padding (height/width) between edges of adjacent subplots, as a + fraction of the font size. Defaults to *pad*. + rect : tuple + (left, bottom, right, top), default: None. + """ + rows, cols = shape + + font_size_inch = (FontProperties( + size=mpl.rcParams["font.size"]).get_size_in_points() / 72) + pad_inch = pad * font_size_inch + vpad_inch = h_pad * font_size_inch if h_pad is not None else pad_inch + hpad_inch = w_pad * font_size_inch if w_pad is not None else pad_inch + + if len(span_pairs) != len(subplot_list) or len(subplot_list) == 0: + raise ValueError + + if rect is None: + margin_left = margin_bottom = margin_right = margin_top = None + else: + margin_left, margin_bottom, _right, _top = rect + margin_right = 1 - _right if _right else None + margin_top = 1 - _top if _top else None + + vspaces = np.zeros((rows + 1, cols)) + hspaces = np.zeros((rows, cols + 1)) + + if ax_bbox_list is None: + ax_bbox_list = [ + Bbox.union([ax.get_position(original=True) for ax in subplots]) + for subplots in subplot_list] + + for subplots, ax_bbox, (rowspan, colspan) in zip( + subplot_list, ax_bbox_list, span_pairs): + if all(not ax.get_visible() for ax in subplots): + continue + + bb = [] + for ax in subplots: + if ax.get_visible(): + bb += [martist._get_tightbbox_for_layout_only(ax, renderer)] + + tight_bbox_raw = Bbox.union(bb) + tight_bbox = fig.transFigure.inverted().transform_bbox(tight_bbox_raw) + + hspaces[rowspan, colspan.start] += ax_bbox.xmin - tight_bbox.xmin # l + hspaces[rowspan, colspan.stop] += tight_bbox.xmax - ax_bbox.xmax # r + vspaces[rowspan.start, colspan] += tight_bbox.ymax - ax_bbox.ymax # t + vspaces[rowspan.stop, colspan] += ax_bbox.ymin - tight_bbox.ymin # b + + fig_width_inch, fig_height_inch = fig.get_size_inches() + + # margins can be negative for axes with aspect applied, so use max(, 0) to + # make them nonnegative. + if not margin_left: + margin_left = max(hspaces[:, 0].max(), 0) + pad_inch/fig_width_inch + suplabel = fig._supylabel + if suplabel and suplabel.get_in_layout(): + rel_width = fig.transFigure.inverted().transform_bbox( + suplabel.get_window_extent(renderer)).width + margin_left += rel_width + pad_inch/fig_width_inch + if not margin_right: + margin_right = max(hspaces[:, -1].max(), 0) + pad_inch/fig_width_inch + if not margin_top: + margin_top = max(vspaces[0, :].max(), 0) + pad_inch/fig_height_inch + if fig._suptitle and fig._suptitle.get_in_layout(): + rel_height = fig.transFigure.inverted().transform_bbox( + fig._suptitle.get_window_extent(renderer)).height + margin_top += rel_height + pad_inch/fig_height_inch + if not margin_bottom: + margin_bottom = max(vspaces[-1, :].max(), 0) + pad_inch/fig_height_inch + suplabel = fig._supxlabel + if suplabel and suplabel.get_in_layout(): + rel_height = fig.transFigure.inverted().transform_bbox( + suplabel.get_window_extent(renderer)).height + margin_bottom += rel_height + pad_inch/fig_height_inch + + if margin_left + margin_right >= 1: + _api.warn_external('Tight layout not applied. The left and right ' + 'margins cannot be made large enough to ' + 'accommodate all axes decorations.') + return None + if margin_bottom + margin_top >= 1: + _api.warn_external('Tight layout not applied. The bottom and top ' + 'margins cannot be made large enough to ' + 'accommodate all axes decorations.') + return None + + kwargs = dict(left=margin_left, + right=1 - margin_right, + bottom=margin_bottom, + top=1 - margin_top) + + if cols > 1: + hspace = hspaces[:, 1:-1].max() + hpad_inch / fig_width_inch + # axes widths: + h_axes = (1 - margin_right - margin_left - hspace * (cols - 1)) / cols + if h_axes < 0: + _api.warn_external('Tight layout not applied. tight_layout ' + 'cannot make axes width small enough to ' + 'accommodate all axes decorations') + return None + else: + kwargs["wspace"] = hspace / h_axes + if rows > 1: + vspace = vspaces[1:-1, :].max() + vpad_inch / fig_height_inch + v_axes = (1 - margin_top - margin_bottom - vspace * (rows - 1)) / rows + if v_axes < 0: + _api.warn_external('Tight layout not applied. tight_layout ' + 'cannot make axes height small enough to ' + 'accommodate all axes decorations.') + return None + else: + kwargs["hspace"] = vspace / v_axes + + return kwargs + + +def get_subplotspec_list(axes_list, grid_spec=None): + """ + Return a list of subplotspec from the given list of axes. + + For an instance of axes that does not support subplotspec, None is inserted + in the list. + + If grid_spec is given, None is inserted for those not from the given + grid_spec. + """ + subplotspec_list = [] + for ax in axes_list: + axes_or_locator = ax.get_axes_locator() + if axes_or_locator is None: + axes_or_locator = ax + + if hasattr(axes_or_locator, "get_subplotspec"): + subplotspec = axes_or_locator.get_subplotspec() + if subplotspec is not None: + subplotspec = subplotspec.get_topmost_subplotspec() + gs = subplotspec.get_gridspec() + if grid_spec is not None: + if gs != grid_spec: + subplotspec = None + elif gs.locally_modified_subplot_params(): + subplotspec = None + else: + subplotspec = None + + subplotspec_list.append(subplotspec) + + return subplotspec_list + + +def get_tight_layout_figure(fig, axes_list, subplotspec_list, renderer, + pad=1.08, h_pad=None, w_pad=None, rect=None): + """ + Return subplot parameters for tight-layouted-figure with specified padding. + + Parameters + ---------- + fig : Figure + axes_list : list of Axes + subplotspec_list : list of `.SubplotSpec` + The subplotspecs of each axes. + renderer : renderer + pad : float + Padding between the figure edge and the edges of subplots, as a + fraction of the font size. + h_pad, w_pad : float + Padding (height/width) between edges of adjacent subplots. Defaults to + *pad*. + rect : tuple (left, bottom, right, top), default: None. + rectangle in normalized figure coordinates + that the whole subplots area (including labels) will fit into. + Defaults to using the entire figure. + + Returns + ------- + subplotspec or None + subplotspec kwargs to be passed to `.Figure.subplots_adjust` or + None if tight_layout could not be accomplished. + """ + + # Multiple axes can share same subplotspec (e.g., if using axes_grid1); + # we need to group them together. + ss_to_subplots = {ss: [] for ss in subplotspec_list} + for ax, ss in zip(axes_list, subplotspec_list): + ss_to_subplots[ss].append(ax) + if ss_to_subplots.pop(None, None): + _api.warn_external( + "This figure includes Axes that are not compatible with " + "tight_layout, so results might be incorrect.") + if not ss_to_subplots: + return {} + subplot_list = list(ss_to_subplots.values()) + ax_bbox_list = [ss.get_position(fig) for ss in ss_to_subplots] + + max_nrows = max(ss.get_gridspec().nrows for ss in ss_to_subplots) + max_ncols = max(ss.get_gridspec().ncols for ss in ss_to_subplots) + + span_pairs = [] + for ss in ss_to_subplots: + # The intent here is to support axes from different gridspecs where + # one's nrows (or ncols) is a multiple of the other (e.g. 2 and 4), + # but this doesn't actually work because the computed wspace, in + # relative-axes-height, corresponds to different physical spacings for + # the 2-row grid and the 4-row grid. Still, this code is left, mostly + # for backcompat. + rows, cols = ss.get_gridspec().get_geometry() + div_row, mod_row = divmod(max_nrows, rows) + div_col, mod_col = divmod(max_ncols, cols) + if mod_row != 0: + _api.warn_external('tight_layout not applied: number of rows ' + 'in subplot specifications must be ' + 'multiples of one another.') + return {} + if mod_col != 0: + _api.warn_external('tight_layout not applied: number of ' + 'columns in subplot specifications must be ' + 'multiples of one another.') + return {} + span_pairs.append(( + slice(ss.rowspan.start * div_row, ss.rowspan.stop * div_row), + slice(ss.colspan.start * div_col, ss.colspan.stop * div_col))) + + kwargs = _auto_adjust_subplotpars(fig, renderer, + shape=(max_nrows, max_ncols), + span_pairs=span_pairs, + subplot_list=subplot_list, + ax_bbox_list=ax_bbox_list, + pad=pad, h_pad=h_pad, w_pad=w_pad) + + # kwargs can be none if tight_layout fails... + if rect is not None and kwargs is not None: + # if rect is given, the whole subplots area (including + # labels) will fit into the rect instead of the + # figure. Note that the rect argument of + # *auto_adjust_subplotpars* specify the area that will be + # covered by the total area of axes.bbox. Thus we call + # auto_adjust_subplotpars twice, where the second run + # with adjusted rect parameters. + + left, bottom, right, top = rect + if left is not None: + left += kwargs["left"] + if bottom is not None: + bottom += kwargs["bottom"] + if right is not None: + right -= (1 - kwargs["right"]) + if top is not None: + top -= (1 - kwargs["top"]) + + kwargs = _auto_adjust_subplotpars(fig, renderer, + shape=(max_nrows, max_ncols), + span_pairs=span_pairs, + subplot_list=subplot_list, + ax_bbox_list=ax_bbox_list, + pad=pad, h_pad=h_pad, w_pad=w_pad, + rect=(left, bottom, right, top)) + + return kwargs diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py new file mode 100644 index 000000000000..0413cb0016a0 --- /dev/null +++ b/lib/matplotlib/_type1font.py @@ -0,0 +1,877 @@ +""" +A class representing a Type 1 font. + +This version reads pfa and pfb files and splits them for embedding in +pdf files. It also supports SlantFont and ExtendFont transformations, +similarly to pdfTeX and friends. There is no support yet for subsetting. + +Usage:: + + font = Type1Font(filename) + clear_part, encrypted_part, finale = font.parts + slanted_font = font.transform({'slant': 0.167}) + extended_font = font.transform({'extend': 1.2}) + +Sources: + +* Adobe Technical Note #5040, Supporting Downloadable PostScript + Language Fonts. + +* Adobe Type 1 Font Format, Adobe Systems Incorporated, third printing, + v1.1, 1993. ISBN 0-201-57044-0. +""" + +import binascii +import functools +import logging +import re +import string +import struct + +import numpy as np + +from matplotlib.cbook import _format_approx +from . import _api + +_log = logging.getLogger(__name__) + + +class _Token: + """ + A token in a PostScript stream. + + Attributes + ---------- + pos : int + Position, i.e. offset from the beginning of the data. + raw : str + Raw text of the token. + kind : str + Description of the token (for debugging or testing). + """ + __slots__ = ('pos', 'raw') + kind = '?' + + def __init__(self, pos, raw): + _log.debug('type1font._Token %s at %d: %r', self.kind, pos, raw) + self.pos = pos + self.raw = raw + + def __str__(self): + return f"<{self.kind} {self.raw} @{self.pos}>" + + def endpos(self): + """Position one past the end of the token""" + return self.pos + len(self.raw) + + def is_keyword(self, *names): + """Is this a name token with one of the names?""" + return False + + def is_slash_name(self): + """Is this a name token that starts with a slash?""" + return False + + def is_delim(self): + """Is this a delimiter token?""" + return False + + def is_number(self): + """Is this a number token?""" + return False + + def value(self): + return self.raw + + +class _NameToken(_Token): + kind = 'name' + + def is_slash_name(self): + return self.raw.startswith('/') + + def value(self): + return self.raw[1:] + + +class _BooleanToken(_Token): + kind = 'boolean' + + def value(self): + return self.raw == 'true' + + +class _KeywordToken(_Token): + kind = 'keyword' + + def is_keyword(self, *names): + return self.raw in names + + +class _DelimiterToken(_Token): + kind = 'delimiter' + + def is_delim(self): + return True + + def opposite(self): + return {'[': ']', ']': '[', + '{': '}', '}': '{', + '<<': '>>', '>>': '<<' + }[self.raw] + + +class _WhitespaceToken(_Token): + kind = 'whitespace' + + +class _StringToken(_Token): + kind = 'string' + _escapes_re = re.compile(r'\\([\\()nrtbf]|[0-7]{1,3})') + _replacements = {'\\': '\\', '(': '(', ')': ')', 'n': '\n', + 'r': '\r', 't': '\t', 'b': '\b', 'f': '\f'} + _ws_re = re.compile('[\0\t\r\f\n ]') + + @classmethod + def _escape(cls, match): + group = match.group(1) + try: + return cls._replacements[group] + except KeyError: + return chr(int(group, 8)) + + @functools.lru_cache() + def value(self): + if self.raw[0] == '(': + return self._escapes_re.sub(self._escape, self.raw[1:-1]) + else: + data = self._ws_re.sub('', self.raw[1:-1]) + if len(data) % 2 == 1: + data += '0' + return binascii.unhexlify(data) + + +class _BinaryToken(_Token): + kind = 'binary' + + def value(self): + return self.raw[1:] + + +class _NumberToken(_Token): + kind = 'number' + + def is_number(self): + return True + + def value(self): + if '.' not in self.raw: + return int(self.raw) + else: + return float(self.raw) + + +def _tokenize(data: bytes, skip_ws: bool): + """ + A generator that produces _Token instances from Type-1 font code. + + The consumer of the generator may send an integer to the tokenizer to + indicate that the next token should be _BinaryToken of the given length. + + Parameters + ---------- + data : bytes + The data of the font to tokenize. + + skip_ws : bool + If true, the generator will drop any _WhitespaceTokens from the output. + """ + + text = data.decode('ascii', 'replace') + whitespace_or_comment_re = re.compile(r'[\0\t\r\f\n ]+|%[^\r\n]*') + token_re = re.compile(r'/{0,2}[^]\0\t\r\f\n ()<>{}/%[]+') + instring_re = re.compile(r'[()\\]') + hex_re = re.compile(r'^<[0-9a-fA-F\0\t\r\f\n ]*>$') + oct_re = re.compile(r'[0-7]{1,3}') + pos = 0 + next_binary = None + + while pos < len(text): + if next_binary is not None: + n = next_binary + next_binary = (yield _BinaryToken(pos, data[pos:pos+n])) + pos += n + continue + match = whitespace_or_comment_re.match(text, pos) + if match: + if not skip_ws: + next_binary = (yield _WhitespaceToken(pos, match.group())) + pos = match.end() + elif text[pos] == '(': + # PostScript string rules: + # - parentheses must be balanced + # - backslashes escape backslashes and parens + # - also codes \n\r\t\b\f and octal escapes are recognized + # - other backslashes do not escape anything + start = pos + pos += 1 + depth = 1 + while depth: + match = instring_re.search(text, pos) + if match is None: + raise ValueError( + f'Unterminated string starting at {start}') + pos = match.end() + if match.group() == '(': + depth += 1 + elif match.group() == ')': + depth -= 1 + else: # a backslash + char = text[pos] + if char in r'\()nrtbf': + pos += 1 + else: + octal = oct_re.match(text, pos) + if octal: + pos = octal.end() + else: + pass # non-escaping backslash + next_binary = (yield _StringToken(start, text[start:pos])) + elif text[pos:pos + 2] in ('<<', '>>'): + next_binary = (yield _DelimiterToken(pos, text[pos:pos + 2])) + pos += 2 + elif text[pos] == '<': + start = pos + try: + pos = text.index('>', pos) + 1 + except ValueError as e: + raise ValueError(f'Unterminated hex string starting at {start}' + ) from e + if not hex_re.match(text[start:pos]): + raise ValueError(f'Malformed hex string starting at {start}') + next_binary = (yield _StringToken(pos, text[start:pos])) + else: + match = token_re.match(text, pos) + if match: + raw = match.group() + if raw.startswith('/'): + next_binary = (yield _NameToken(pos, raw)) + elif match.group() in ('true', 'false'): + next_binary = (yield _BooleanToken(pos, raw)) + else: + try: + float(raw) + next_binary = (yield _NumberToken(pos, raw)) + except ValueError: + next_binary = (yield _KeywordToken(pos, raw)) + pos = match.end() + else: + next_binary = (yield _DelimiterToken(pos, text[pos])) + pos += 1 + + +class _BalancedExpression(_Token): + pass + + +def _expression(initial, tokens, data): + """ + Consume some number of tokens and return a balanced PostScript expression. + + Parameters + ---------- + initial : _Token + The token that triggered parsing a balanced expression. + tokens : iterator of _Token + Following tokens. + data : bytes + Underlying data that the token positions point to. + + Returns + ------- + _BalancedExpression + """ + delim_stack = [] + token = initial + while True: + if token.is_delim(): + if token.raw in ('[', '{'): + delim_stack.append(token) + elif token.raw in (']', '}'): + if not delim_stack: + raise RuntimeError(f"unmatched closing token {token}") + match = delim_stack.pop() + if match.raw != token.opposite(): + raise RuntimeError( + f"opening token {match} closed by {token}" + ) + if not delim_stack: + break + else: + raise RuntimeError(f'unknown delimiter {token}') + elif not delim_stack: + break + token = next(tokens) + return _BalancedExpression( + initial.pos, + data[initial.pos:token.endpos()].decode('ascii', 'replace') + ) + + +class Type1Font: + """ + A class representing a Type-1 font, for use by backends. + + Attributes + ---------- + parts : tuple + A 3-tuple of the cleartext part, the encrypted part, and the finale of + zeros. + + decrypted : bytes + The decrypted form of ``parts[1]``. + + prop : dict[str, Any] + A dictionary of font properties. Noteworthy keys include: + + - FontName: PostScript name of the font + - Encoding: dict from numeric codes to glyph names + - FontMatrix: bytes object encoding a matrix + - UniqueID: optional font identifier, dropped when modifying the font + - CharStrings: dict from glyph names to byte code + - Subrs: array of byte code subroutines + - OtherSubrs: bytes object encoding some PostScript code + """ + __slots__ = ('parts', 'decrypted', 'prop', '_pos', '_abbr') + # the _pos dict contains (begin, end) indices to parts[0] + decrypted + # so that they can be replaced when transforming the font; + # but since sometimes a definition appears in both parts[0] and decrypted, + # _pos[name] is an array of such pairs + # + # _abbr maps three standard abbreviations to their particular names in + # this font (e.g. 'RD' is named '-|' in some fonts) + + def __init__(self, input): + """ + Initialize a Type-1 font. + + Parameters + ---------- + input : str or 3-tuple + Either a pfb file name, or a 3-tuple of already-decoded Type-1 + font `~.Type1Font.parts`. + """ + if isinstance(input, tuple) and len(input) == 3: + self.parts = input + else: + with open(input, 'rb') as file: + data = self._read(file) + self.parts = self._split(data) + + self.decrypted = self._decrypt(self.parts[1], 'eexec') + self._abbr = {'RD': 'RD', 'ND': 'ND', 'NP': 'NP'} + self._parse() + + def _read(self, file): + """Read the font from a file, decoding into usable parts.""" + rawdata = file.read() + if not rawdata.startswith(b'\x80'): + return rawdata + + data = b'' + while rawdata: + if not rawdata.startswith(b'\x80'): + raise RuntimeError('Broken pfb file (expected byte 128, ' + 'got %d)' % rawdata[0]) + type = rawdata[1] + if type in (1, 2): + length, = struct.unpack('> 8)) + key = ((key+byte) * 52845 + 22719) & 0xffff + + return bytes(plaintext[ndiscard:]) + + @staticmethod + def _encrypt(plaintext, key, ndiscard=4): + """ + Encrypt plaintext using the Type-1 font algorithm. + + The algorithm is described in Adobe's "Adobe Type 1 Font Format". + The key argument can be an integer, or one of the strings + 'eexec' and 'charstring', which map to the key specified for the + corresponding part of Type-1 fonts. + + The ndiscard argument should be an integer, usually 4. That + number of bytes is prepended to the plaintext before encryption. + This function prepends NUL bytes for reproducibility, even though + the original algorithm uses random bytes, presumably to avoid + cryptanalysis. + """ + + key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key) + ciphertext = [] + for byte in b'\0' * ndiscard + plaintext: + c = byte ^ (key >> 8) + ciphertext.append(c) + key = ((key + c) * 52845 + 22719) & 0xffff + + return bytes(ciphertext) + + def _parse(self): + """ + Find the values of various font properties. This limited kind + of parsing is described in Chapter 10 "Adobe Type Manager + Compatibility" of the Type-1 spec. + """ + # Start with reasonable defaults + prop = {'Weight': 'Regular', 'ItalicAngle': 0.0, 'isFixedPitch': False, + 'UnderlinePosition': -100, 'UnderlineThickness': 50} + pos = {} + data = self.parts[0] + self.decrypted + + source = _tokenize(data, True) + while True: + # See if there is a key to be assigned a value + # e.g. /FontName in /FontName /Helvetica def + try: + token = next(source) + except StopIteration: + break + if token.is_delim(): + # skip over this - we want top-level keys only + _expression(token, source, data) + if token.is_slash_name(): + key = token.value() + keypos = token.pos + else: + continue + + # Some values need special parsing + if key in ('Subrs', 'CharStrings', 'Encoding', 'OtherSubrs'): + prop[key], endpos = { + 'Subrs': self._parse_subrs, + 'CharStrings': self._parse_charstrings, + 'Encoding': self._parse_encoding, + 'OtherSubrs': self._parse_othersubrs + }[key](source, data) + pos.setdefault(key, []).append((keypos, endpos)) + continue + + try: + token = next(source) + except StopIteration: + break + + if isinstance(token, _KeywordToken): + # constructs like + # FontDirectory /Helvetica known {...} {...} ifelse + # mean the key was not really a key + continue + + if token.is_delim(): + value = _expression(token, source, data).raw + else: + value = token.value() + + # look for a 'def' possibly preceded by access modifiers + try: + kw = next( + kw for kw in source + if not kw.is_keyword('readonly', 'noaccess', 'executeonly') + ) + except StopIteration: + break + + # sometimes noaccess def and readonly def are abbreviated + if kw.is_keyword('def', self._abbr['ND'], self._abbr['NP']): + prop[key] = value + pos.setdefault(key, []).append((keypos, kw.endpos())) + + # detect the standard abbreviations + if value == '{noaccess def}': + self._abbr['ND'] = key + elif value == '{noaccess put}': + self._abbr['NP'] = key + elif value == '{string currentfile exch readstring pop}': + self._abbr['RD'] = key + + # Fill in the various *Name properties + if 'FontName' not in prop: + prop['FontName'] = (prop.get('FullName') or + prop.get('FamilyName') or + 'Unknown') + if 'FullName' not in prop: + prop['FullName'] = prop['FontName'] + if 'FamilyName' not in prop: + extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' + '(ultra)?light|extra|condensed))+$') + prop['FamilyName'] = re.sub(extras, '', prop['FullName']) + # Decrypt the encrypted parts + ndiscard = prop.get('lenIV', 4) + cs = prop['CharStrings'] + for key, value in cs.items(): + cs[key] = self._decrypt(value, 'charstring', ndiscard) + if 'Subrs' in prop: + prop['Subrs'] = [ + self._decrypt(value, 'charstring', ndiscard) + for value in prop['Subrs'] + ] + + self.prop = prop + self._pos = pos + + def _parse_subrs(self, tokens, _data): + count_token = next(tokens) + if not count_token.is_number(): + raise RuntimeError( + f"Token following /Subrs must be a number, was {count_token}" + ) + count = count_token.value() + array = [None] * count + next(t for t in tokens if t.is_keyword('array')) + for _ in range(count): + next(t for t in tokens if t.is_keyword('dup')) + index_token = next(tokens) + if not index_token.is_number(): + raise RuntimeError( + "Token following dup in Subrs definition must be a " + f"number, was {index_token}" + ) + nbytes_token = next(tokens) + if not nbytes_token.is_number(): + raise RuntimeError( + "Second token following dup in Subrs definition must " + f"be a number, was {nbytes_token}" + ) + token = next(tokens) + if not token.is_keyword(self._abbr['RD']): + raise RuntimeError( + f"Token preceding subr must be {self._abbr['RD']}, " + f"was {token}" + ) + binary_token = tokens.send(1+nbytes_token.value()) + array[index_token.value()] = binary_token.value() + + return array, next(tokens).endpos() + + @staticmethod + def _parse_charstrings(tokens, _data): + count_token = next(tokens) + if not count_token.is_number(): + raise RuntimeError( + "Token following /CharStrings must be a number, " + f"was {count_token}" + ) + count = count_token.value() + charstrings = {} + next(t for t in tokens if t.is_keyword('begin')) + while True: + token = next(t for t in tokens + if t.is_keyword('end') or t.is_slash_name()) + if token.raw == 'end': + return charstrings, token.endpos() + glyphname = token.value() + nbytes_token = next(tokens) + if not nbytes_token.is_number(): + raise RuntimeError( + f"Token following /{glyphname} in CharStrings definition " + f"must be a number, was {nbytes_token}" + ) + next(tokens) # usually RD or |- + binary_token = tokens.send(1+nbytes_token.value()) + charstrings[glyphname] = binary_token.value() + + @staticmethod + def _parse_encoding(tokens, _data): + # this only works for encodings that follow the Adobe manual + # but some old fonts include non-compliant data - we log a warning + # and return a possibly incomplete encoding + encoding = {} + while True: + token = next(t for t in tokens + if t.is_keyword('StandardEncoding', 'dup', 'def')) + if token.is_keyword('StandardEncoding'): + return _StandardEncoding, token.endpos() + if token.is_keyword('def'): + return encoding, token.endpos() + index_token = next(tokens) + if not index_token.is_number(): + _log.warning( + f"Parsing encoding: expected number, got {index_token}" + ) + continue + name_token = next(tokens) + if not name_token.is_slash_name(): + _log.warning( + f"Parsing encoding: expected slash-name, got {name_token}" + ) + continue + encoding[index_token.value()] = name_token.value() + + @staticmethod + def _parse_othersubrs(tokens, data): + init_pos = None + while True: + token = next(tokens) + if init_pos is None: + init_pos = token.pos + if token.is_delim(): + _expression(token, tokens, data) + elif token.is_keyword('def', 'ND', '|-'): + return data[init_pos:token.endpos()], token.endpos() + + def transform(self, effects): + """ + Return a new font that is slanted and/or extended. + + Parameters + ---------- + effects : dict + A dict with optional entries: + + - 'slant' : float, default: 0 + Tangent of the angle that the font is to be slanted to the + right. Negative values slant to the left. + - 'extend' : float, default: 1 + Scaling factor for the font width. Values less than 1 condense + the glyphs. + + Returns + ------- + `Type1Font` + """ + fontname = self.prop['FontName'] + italicangle = self.prop['ItalicAngle'] + + array = [ + float(x) for x in (self.prop['FontMatrix'] + .lstrip('[').rstrip(']').split()) + ] + oldmatrix = np.eye(3, 3) + oldmatrix[0:3, 0] = array[::2] + oldmatrix[0:3, 1] = array[1::2] + modifier = np.eye(3, 3) + + if 'slant' in effects: + slant = effects['slant'] + fontname += '_Slant_%d' % int(1000 * slant) + italicangle = round( + float(italicangle) - np.arctan(slant) / np.pi * 180, + 5 + ) + modifier[1, 0] = slant + + if 'extend' in effects: + extend = effects['extend'] + fontname += '_Extend_%d' % int(1000 * extend) + modifier[0, 0] = extend + + newmatrix = np.dot(modifier, oldmatrix) + array[::2] = newmatrix[0:3, 0] + array[1::2] = newmatrix[0:3, 1] + fontmatrix = ( + '[%s]' % ' '.join(_format_approx(x, 6) for x in array) + ) + replacements = ( + [(x, '/FontName/%s def' % fontname) + for x in self._pos['FontName']] + + [(x, '/ItalicAngle %a def' % italicangle) + for x in self._pos['ItalicAngle']] + + [(x, '/FontMatrix %s readonly def' % fontmatrix) + for x in self._pos['FontMatrix']] + + [(x, '') for x in self._pos.get('UniqueID', [])] + ) + + data = bytearray(self.parts[0]) + data.extend(self.decrypted) + len0 = len(self.parts[0]) + for (pos0, pos1), value in sorted(replacements, reverse=True): + data[pos0:pos1] = value.encode('ascii', 'replace') + if pos0 < len(self.parts[0]): + if pos1 >= len(self.parts[0]): + raise RuntimeError( + f"text to be replaced with {value} spans " + "the eexec boundary" + ) + len0 += len(value) - pos1 + pos0 + + data = bytes(data) + return Type1Font(( + data[:len0], + self._encrypt(data[len0:], 'eexec'), + self.parts[2] + )) + + +_StandardEncoding = { + **{ord(letter): letter for letter in string.ascii_letters}, + 0: '.notdef', + 32: 'space', + 33: 'exclam', + 34: 'quotedbl', + 35: 'numbersign', + 36: 'dollar', + 37: 'percent', + 38: 'ampersand', + 39: 'quoteright', + 40: 'parenleft', + 41: 'parenright', + 42: 'asterisk', + 43: 'plus', + 44: 'comma', + 45: 'hyphen', + 46: 'period', + 47: 'slash', + 48: 'zero', + 49: 'one', + 50: 'two', + 51: 'three', + 52: 'four', + 53: 'five', + 54: 'six', + 55: 'seven', + 56: 'eight', + 57: 'nine', + 58: 'colon', + 59: 'semicolon', + 60: 'less', + 61: 'equal', + 62: 'greater', + 63: 'question', + 64: 'at', + 91: 'bracketleft', + 92: 'backslash', + 93: 'bracketright', + 94: 'asciicircum', + 95: 'underscore', + 96: 'quoteleft', + 123: 'braceleft', + 124: 'bar', + 125: 'braceright', + 126: 'asciitilde', + 161: 'exclamdown', + 162: 'cent', + 163: 'sterling', + 164: 'fraction', + 165: 'yen', + 166: 'florin', + 167: 'section', + 168: 'currency', + 169: 'quotesingle', + 170: 'quotedblleft', + 171: 'guillemotleft', + 172: 'guilsinglleft', + 173: 'guilsinglright', + 174: 'fi', + 175: 'fl', + 177: 'endash', + 178: 'dagger', + 179: 'daggerdbl', + 180: 'periodcentered', + 182: 'paragraph', + 183: 'bullet', + 184: 'quotesinglbase', + 185: 'quotedblbase', + 186: 'quotedblright', + 187: 'guillemotright', + 188: 'ellipsis', + 189: 'perthousand', + 191: 'questiondown', + 193: 'grave', + 194: 'acute', + 195: 'circumflex', + 196: 'tilde', + 197: 'macron', + 198: 'breve', + 199: 'dotaccent', + 200: 'dieresis', + 202: 'ring', + 203: 'cedilla', + 205: 'hungarumlaut', + 206: 'ogonek', + 207: 'caron', + 208: 'emdash', + 225: 'AE', + 227: 'ordfeminine', + 232: 'Lslash', + 233: 'Oslash', + 234: 'OE', + 235: 'ordmasculine', + 241: 'ae', + 245: 'dotlessi', + 248: 'lslash', + 249: 'oslash', + 250: 'oe', + 251: 'germandbls', +} diff --git a/lib/matplotlib/afm.py b/lib/matplotlib/afm.py index 3d02d7f9c1d6..d95c88a0e2b4 100644 --- a/lib/matplotlib/afm.py +++ b/lib/matplotlib/afm.py @@ -1,532 +1,3 @@ -""" -A python interface to Adobe Font Metrics Files. - -Although a number of other Python implementations exist, and may be more -complete than this, it was decided not to go with them because they were -either: - -1) copyrighted or used a non-BSD compatible license -2) had too many dependencies and a free standing lib was needed -3) did more than needed and it was easier to write afresh rather than - figure out how to get just what was needed. - -It is pretty easy to use, and has no external dependencies: - ->>> import matplotlib as mpl ->>> from pathlib import Path ->>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') ->>> ->>> from matplotlib.afm import AFM ->>> with afm_path.open('rb') as fh: -... afm = AFM(fh) ->>> afm.string_width_height('What the heck?') -(6220.0, 694) ->>> afm.get_fontname() -'Times-Roman' ->>> afm.get_kern_dist('A', 'f') -0 ->>> afm.get_kern_dist('A', 'y') --92.0 ->>> afm.get_bbox_char('!') -[130, -9, 238, 676] - -As in the Adobe Font Metrics File Format Specification, all dimensions -are given in units of 1/1000 of the scale factor (point size) of the font -being used. -""" - -from collections import namedtuple -import logging -import re - -from ._mathtext_data import uni2type1 - - -_log = logging.getLogger(__name__) - - -def _to_int(x): - # Some AFM files have floats where we are expecting ints -- there is - # probably a better way to handle this (support floats, round rather than - # truncate). But I don't know what the best approach is now and this - # change to _to_int should at least prevent Matplotlib from crashing on - # these. JDH (2009-11-06) - return int(float(x)) - - -def _to_float(x): - # Some AFM files use "," instead of "." as decimal separator -- this - # shouldn't be ambiguous (unless someone is wicked enough to use "," as - # thousands separator...). - if isinstance(x, bytes): - # Encoding doesn't really matter -- if we have codepoints >127 the call - # to float() will error anyways. - x = x.decode('latin-1') - return float(x.replace(',', '.')) - - -def _to_str(x): - return x.decode('utf8') - - -def _to_list_of_ints(s): - s = s.replace(b',', b' ') - return [_to_int(val) for val in s.split()] - - -def _to_list_of_floats(s): - return [_to_float(val) for val in s.split()] - - -def _to_bool(s): - if s.lower().strip() in (b'false', b'0', b'no'): - return False - else: - return True - - -def _parse_header(fh): - """ - Read the font metrics header (up to the char metrics) and returns - a dictionary mapping *key* to *val*. *val* will be converted to the - appropriate python type as necessary; e.g.: - - * 'False'->False - * '0'->0 - * '-168 -218 1000 898'-> [-168, -218, 1000, 898] - - Dictionary keys are - - StartFontMetrics, FontName, FullName, FamilyName, Weight, - ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, - UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, - XHeight, Ascender, Descender, StartCharMetrics - """ - header_converters = { - b'StartFontMetrics': _to_float, - b'FontName': _to_str, - b'FullName': _to_str, - b'FamilyName': _to_str, - b'Weight': _to_str, - b'ItalicAngle': _to_float, - b'IsFixedPitch': _to_bool, - b'FontBBox': _to_list_of_ints, - b'UnderlinePosition': _to_float, - b'UnderlineThickness': _to_float, - b'Version': _to_str, - # Some AFM files have non-ASCII characters (which are not allowed by - # the spec). Given that there is actually no public API to even access - # this field, just return it as straight bytes. - b'Notice': lambda x: x, - b'EncodingScheme': _to_str, - b'CapHeight': _to_float, # Is the second version a mistake, or - b'Capheight': _to_float, # do some AFM files contain 'Capheight'? -JKS - b'XHeight': _to_float, - b'Ascender': _to_float, - b'Descender': _to_float, - b'StdHW': _to_float, - b'StdVW': _to_float, - b'StartCharMetrics': _to_int, - b'CharacterSet': _to_str, - b'Characters': _to_int, - } - d = {} - first_line = True - for line in fh: - line = line.rstrip() - if line.startswith(b'Comment'): - continue - lst = line.split(b' ', 1) - key = lst[0] - if first_line: - # AFM spec, Section 4: The StartFontMetrics keyword - # [followed by a version number] must be the first line in - # the file, and the EndFontMetrics keyword must be the - # last non-empty line in the file. We just check the - # first header entry. - if key != b'StartFontMetrics': - raise RuntimeError('Not an AFM file') - first_line = False - if len(lst) == 2: - val = lst[1] - else: - val = b'' - try: - converter = header_converters[key] - except KeyError: - _log.error('Found an unknown keyword in AFM header (was %r)' % key) - continue - try: - d[key] = converter(val) - except ValueError: - _log.error('Value error parsing header in AFM: %s, %s', key, val) - continue - if key == b'StartCharMetrics': - break - else: - raise RuntimeError('Bad parse') - return d - - -CharMetrics = namedtuple('CharMetrics', 'width, name, bbox') -CharMetrics.__doc__ = """ - Represents the character metrics of a single character. - - Notes - ----- - The fields do currently only describe a subset of character metrics - information defined in the AFM standard. - """ -CharMetrics.width.__doc__ = """The character width (WX).""" -CharMetrics.name.__doc__ = """The character name (N).""" -CharMetrics.bbox.__doc__ = """ - The bbox of the character (B) as a tuple (*llx*, *lly*, *urx*, *ury*).""" - - -def _parse_char_metrics(fh): - """ - Parse the given filehandle for character metrics information and return - the information as dicts. - - It is assumed that the file cursor is on the line behind - 'StartCharMetrics'. - - Returns - ------- - ascii_d : dict - A mapping "ASCII num of the character" to `.CharMetrics`. - name_d : dict - A mapping "character name" to `.CharMetrics`. - - Notes - ----- - This function is incomplete per the standard, but thus far parses - all the sample afm files tried. - """ - required_keys = {'C', 'WX', 'N', 'B'} - - ascii_d = {} - name_d = {} - for line in fh: - # We are defensively letting values be utf8. The spec requires - # ascii, but there are non-compliant fonts in circulation - line = _to_str(line.rstrip()) # Convert from byte-literal - if line.startswith('EndCharMetrics'): - return ascii_d, name_d - # Split the metric line into a dictionary, keyed by metric identifiers - vals = dict(s.strip().split(' ', 1) for s in line.split(';') if s) - # There may be other metrics present, but only these are needed - if not required_keys.issubset(vals): - raise RuntimeError('Bad char metrics line: %s' % line) - num = _to_int(vals['C']) - wx = _to_float(vals['WX']) - name = vals['N'] - bbox = _to_list_of_floats(vals['B']) - bbox = list(map(int, bbox)) - metrics = CharMetrics(wx, name, bbox) - # Workaround: If the character name is 'Euro', give it the - # corresponding character code, according to WinAnsiEncoding (see PDF - # Reference). - if name == 'Euro': - num = 128 - elif name == 'minus': - num = ord("\N{MINUS SIGN}") # 0x2212 - if num != -1: - ascii_d[num] = metrics - name_d[name] = metrics - raise RuntimeError('Bad parse') - - -def _parse_kern_pairs(fh): - """ - Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and - values are the kern pair value. For example, a kern pairs line like - ``KPX A y -50`` - - will be represented as:: - - d[ ('A', 'y') ] = -50 - - """ - - line = next(fh) - if not line.startswith(b'StartKernPairs'): - raise RuntimeError('Bad start of kern pairs data: %s' % line) - - d = {} - for line in fh: - line = line.rstrip() - if not line: - continue - if line.startswith(b'EndKernPairs'): - next(fh) # EndKernData - return d - vals = line.split() - if len(vals) != 4 or vals[0] != b'KPX': - raise RuntimeError('Bad kern pairs line: %s' % line) - c1, c2, val = _to_str(vals[1]), _to_str(vals[2]), _to_float(vals[3]) - d[(c1, c2)] = val - raise RuntimeError('Bad kern pairs parse') - - -CompositePart = namedtuple('CompositePart', 'name, dx, dy') -CompositePart.__doc__ = """ - Represents the information on a composite element of a composite char.""" -CompositePart.name.__doc__ = """Name of the part, e.g. 'acute'.""" -CompositePart.dx.__doc__ = """x-displacement of the part from the origin.""" -CompositePart.dy.__doc__ = """y-displacement of the part from the origin.""" - - -def _parse_composites(fh): - """ - Parse the given filehandle for composites information return them as a - dict. - - It is assumed that the file cursor is on the line behind 'StartComposites'. - - Returns - ------- - dict - A dict mapping composite character names to a parts list. The parts - list is a list of `.CompositePart` entries describing the parts of - the composite. - - Examples - -------- - A composite definition line:: - - CC Aacute 2 ; PCC A 0 0 ; PCC acute 160 170 ; - - will be represented as:: - - composites['Aacute'] = [CompositePart(name='A', dx=0, dy=0), - CompositePart(name='acute', dx=160, dy=170)] - - """ - composites = {} - for line in fh: - line = line.rstrip() - if not line: - continue - if line.startswith(b'EndComposites'): - return composites - vals = line.split(b';') - cc = vals[0].split() - name, _num_parts = cc[1], _to_int(cc[2]) - pccParts = [] - for s in vals[1:-1]: - pcc = s.split() - part = CompositePart(pcc[1], _to_float(pcc[2]), _to_float(pcc[3])) - pccParts.append(part) - composites[name] = pccParts - - raise RuntimeError('Bad composites parse') - - -def _parse_optional(fh): - """ - Parse the optional fields for kern pair data and composites. - - Returns - ------- - kern_data : dict - A dict containing kerning information. May be empty. - See `._parse_kern_pairs`. - composites : dict - A dict containing composite information. May be empty. - See `._parse_composites`. - """ - optional = { - b'StartKernData': _parse_kern_pairs, - b'StartComposites': _parse_composites, - } - - d = {b'StartKernData': {}, - b'StartComposites': {}} - for line in fh: - line = line.rstrip() - if not line: - continue - key = line.split()[0] - - if key in optional: - d[key] = optional[key](fh) - - return d[b'StartKernData'], d[b'StartComposites'] - - -class AFM: - - def __init__(self, fh): - """Parse the AFM file in file object *fh*.""" - self._header = _parse_header(fh) - self._metrics, self._metrics_by_name = _parse_char_metrics(fh) - self._kern, self._composite = _parse_optional(fh) - - def get_bbox_char(self, c, isord=False): - if not isord: - c = ord(c) - return self._metrics[c].bbox - - def string_width_height(self, s): - """ - Return the string width (including kerning) and string height - as a (*w*, *h*) tuple. - """ - if not len(s): - return 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - for c in s: - if c == '\n': - continue - wx, name, bbox = self._metrics[ord(c)] - - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return total_width, maxy - miny - - def get_str_bbox_and_descent(self, s): - """Return the string bounding box and the maximal descent.""" - if not len(s): - return 0, 0, 0, 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - left = 0 - if not isinstance(s, str): - s = _to_str(s) - for c in s: - if c == '\n': - continue - name = uni2type1.get(ord(c), f"uni{ord(c):04X}") - try: - wx, _, bbox = self._metrics_by_name[name] - except KeyError: - name = 'question' - wx, _, bbox = self._metrics_by_name[name] - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - left = min(left, l) - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return left, miny, total_width, maxy - miny, -miny - - def get_str_bbox(self, s): - """Return the string bounding box.""" - return self.get_str_bbox_and_descent(s)[:4] - - def get_name_char(self, c, isord=False): - """Get the name of the character, i.e., ';' is 'semicolon'.""" - if not isord: - c = ord(c) - return self._metrics[c].name - - def get_width_char(self, c, isord=False): - """ - Get the width of the character from the character metric WX field. - """ - if not isord: - c = ord(c) - return self._metrics[c].width - - def get_width_from_char_name(self, name): - """Get the width of the character from a type1 character name.""" - return self._metrics_by_name[name].width - - def get_height_char(self, c, isord=False): - """Get the bounding box (ink) height of character *c* (space is 0).""" - if not isord: - c = ord(c) - return self._metrics[c].bbox[-1] - - def get_kern_dist(self, c1, c2): - """ - Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. - """ - name1, name2 = self.get_name_char(c1), self.get_name_char(c2) - return self.get_kern_dist_from_name(name1, name2) - - def get_kern_dist_from_name(self, name1, name2): - """ - Return the kerning pair distance (possibly 0) for chars - *name1* and *name2*. - """ - return self._kern.get((name1, name2), 0) - - def get_fontname(self): - """Return the font name, e.g., 'Times-Roman'.""" - return self._header[b'FontName'] - - @property - def postscript_name(self): # For consistency with FT2Font. - return self.get_fontname() - - def get_fullname(self): - """Return the font full name, e.g., 'Times-Roman'.""" - name = self._header.get(b'FullName') - if name is None: # use FontName as a substitute - name = self._header[b'FontName'] - return name - - def get_familyname(self): - """Return the font family name, e.g., 'Times'.""" - name = self._header.get(b'FamilyName') - if name is not None: - return name - - # FamilyName not specified so we'll make a guess - name = self.get_fullname() - extras = (r'(?i)([ -](regular|plain|italic|oblique|bold|semibold|' - r'light|ultralight|extra|condensed))+$') - return re.sub(extras, '', name) - - @property - def family_name(self): - """The font family name, e.g., 'Times'.""" - return self.get_familyname() - - def get_weight(self): - """Return the font weight, e.g., 'Bold' or 'Roman'.""" - return self._header[b'Weight'] - - def get_angle(self): - """Return the fontangle as float.""" - return self._header[b'ItalicAngle'] - - def get_capheight(self): - """Return the cap height as float.""" - return self._header[b'CapHeight'] - - def get_xheight(self): - """Return the xheight as float.""" - return self._header[b'XHeight'] - - def get_underline_thickness(self): - """Return the underline thickness as float.""" - return self._header[b'UnderlineThickness'] - - def get_horizontal_stem_width(self): - """ - Return the standard horizontal stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdHW', None) - - def get_vertical_stem_width(self): - """ - Return the standard vertical stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdVW', None) +from matplotlib._afm import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated("3.6", name=__name__, obj_type="module") diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 879aa945b00d..864e180f42bf 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -17,6 +17,7 @@ # * Can blit be enabled for movies? # * Need to consider event sources to allow clicking through multiple figures + import abc import base64 import contextlib @@ -38,18 +39,14 @@ from matplotlib._animation_data import ( DISPLAY_TEMPLATE, INCLUDED_FRAMES, JS_INCLUDE, STYLE_INCLUDE) from matplotlib import _api, cbook - +import matplotlib.colors as mcolors _log = logging.getLogger(__name__) # Process creation flag for subprocess to prevent it raising a terminal -# window. See for example: -# https://stackoverflow.com/q/24130623/ -if sys.platform == 'win32': - subprocess_creation_flags = CREATE_NO_WINDOW = 0x08000000 -else: - # Apparently None won't work here - subprocess_creation_flags = 0 +# window. See for example https://stackoverflow.com/q/24130623/ +subprocess_creation_flags = ( + subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0) # Other potential writing methods: # * http://pymedia.org/ @@ -250,7 +247,7 @@ class MovieWriter(AbstractMovieWriter): The format used in writing frame data, defaults to 'rgba'. fig : `~matplotlib.figure.Figure` The figure to capture data from. - This must be provided by the sub-classes. + This must be provided by the subclasses. """ # Builtin writer subclasses additionally define the _exec_key and _args_key @@ -334,29 +331,6 @@ def _run(self): def finish(self): """Finish any processing for writing the movie.""" - overridden_cleanup = _api.deprecate_method_override( - __class__.cleanup, self, since="3.4", alternative="finish()") - if overridden_cleanup is not None: - overridden_cleanup() - else: - self._cleanup() # Inline _cleanup() once cleanup() is removed. - - def grab_frame(self, **savefig_kwargs): - # docstring inherited - _log.debug('MovieWriter.grab_frame: Grabbing frame.') - # Readjust the figure size in case it has been changed by the user. - # All frames must have the same size to save the movie correctly. - self.fig.set_size_inches(self._w, self._h) - # Save the figure data to the sink, using the frame format and dpi. - self.fig.savefig(self._proc.stdin, format=self.frame_format, - dpi=self.dpi, **savefig_kwargs) - - def _args(self): - """Assemble list of encoder-specific command-line arguments.""" - return NotImplementedError("args needs to be implemented by subclass.") - - def _cleanup(self): # Inline to finish() once cleanup() is removed. - """Clean-up and collect the process used to write the movie file.""" out, err = self._proc.communicate() # Use the encoding/errors that universal_newlines would use. out = TextIOWrapper(BytesIO(out)).read() @@ -373,9 +347,19 @@ def _cleanup(self): # Inline to finish() once cleanup() is removed. raise subprocess.CalledProcessError( self._proc.returncode, self._proc.args, out, err) - @_api.deprecated("3.4") - def cleanup(self): - self._cleanup() + def grab_frame(self, **savefig_kwargs): + # docstring inherited + _log.debug('MovieWriter.grab_frame: Grabbing frame.') + # Readjust the figure size in case it has been changed by the user. + # All frames must have the same size to save the movie correctly. + self.fig.set_size_inches(self._w, self._h) + # Save the figure data to the sink, using the frame format and dpi. + self.fig.savefig(self._proc.stdin, format=self.frame_format, + dpi=self.dpi, **savefig_kwargs) + + def _args(self): + """Assemble list of encoder-specific command-line arguments.""" + return NotImplementedError("args needs to be implemented by subclass.") @classmethod def bin_path(cls): @@ -418,7 +402,7 @@ def setup(self, fig, outfile, dpi=None, frame_prefix=None): frame_prefix : str, optional The filename prefix to use for temporary files. If *None* (the default), files are written to a temporary directory which is - deleted by `cleanup`; if not *None*, no temporary files are + deleted by `finish`; if not *None*, no temporary files are deleted. """ self.fig = fig @@ -481,14 +465,15 @@ def grab_frame(self, **savefig_kwargs): def finish(self): # Call run here now that all frame grabbing is done. All temp files # are available to be assembled. - self._run() - super().finish() # Will call clean-up - - def _cleanup(self): # Inline to finish() once cleanup() is removed. - super()._cleanup() - if self._tmpdir: - _log.debug('MovieWriter: clearing temporary path=%s', self._tmpdir) - self._tmpdir.cleanup() + try: + self._run() + super().finish() + finally: + if self._tmpdir: + _log.debug( + 'MovieWriter: clearing temporary path=%s', self._tmpdir + ) + self._tmpdir.cleanup() @writers.register('pillow') @@ -520,8 +505,8 @@ class FFMpegBase: """ Mixin class for FFMpeg output. - To be useful this must be multiply-inherited from with a - `MovieWriterBase` sub-class. + This is a base class for the concrete `FFMpegWriter` and `FFMpegFileWriter` + classes. """ _exec_key = 'animation.ffmpeg_path' @@ -618,23 +603,42 @@ class ImageMagickBase: """ Mixin class for ImageMagick output. - To be useful this must be multiply-inherited from with a - `MovieWriterBase` sub-class. + This is a base class for the concrete `ImageMagickWriter` and + `ImageMagickFileWriter` classes, which define an ``input_names`` attribute + (or property) specifying the input names passed to ImageMagick. """ _exec_key = 'animation.convert_path' _args_key = 'animation.convert_args' + @_api.deprecated("3.6") @property def delay(self): return 100. / self.fps + @_api.deprecated("3.6") @property def output_args(self): extra_args = (self.extra_args if self.extra_args is not None else mpl.rcParams[self._args_key]) return [*extra_args, self.outfile] + def _args(self): + # ImageMagick does not recognize "raw". + fmt = "rgba" if self.frame_format == "raw" else self.frame_format + extra_args = (self.extra_args if self.extra_args is not None + else mpl.rcParams[self._args_key]) + return [ + self.bin_path(), + "-size", "%ix%i" % self.frame_size, + "-depth", "8", + "-delay", str(100 / self.fps), + "-loop", "0", + f"{fmt}:{self.input_names}", + *extra_args, + self.outfile, + ] + @classmethod def bin_path(cls): binpath = super().bin_path() @@ -656,18 +660,13 @@ def isAvailable(cls): @writers.register('imagemagick') class ImageMagickWriter(ImageMagickBase, MovieWriter): """ - Pipe-based animated gif. + Pipe-based animated gif writer. Frames are streamed directly to ImageMagick via a pipe and written in a single pass. - """ - def _args(self): - return ([self.bin_path(), - '-size', '%ix%i' % self.frame_size, '-depth', '8', - '-delay', str(self.delay), '-loop', '0', - '%s:-' % self.frame_format] - + self.output_args) + + input_names = "-" # stdin # Combine ImageMagick options with temp file-based writing @@ -681,23 +680,15 @@ class ImageMagickFileWriter(ImageMagickBase, FileMovieWriter): """ supported_formats = ['png', 'jpeg', 'tiff', 'raw', 'rgba'] - - def _args(self): - # Force format: ImageMagick does not recognize 'raw'. - fmt = 'rgba:' if self.frame_format == 'raw' else '' - return ([self.bin_path(), - '-size', '%ix%i' % self.frame_size, '-depth', '8', - '-delay', str(self.delay), '-loop', '0', - '%s%s*.%s' % (fmt, self.temp_prefix, self.frame_format)] - + self.output_args) + input_names = property( + lambda self: f'{self.temp_prefix}*.{self.frame_format}') # Taken directly from jakevdp's JSAnimation package at # http://github.com/jakevdp/JSAnimation -def _included_frames(paths, frame_format): - """paths should be a list of Paths""" - return INCLUDED_FRAMES.format(Nframes=len(paths), - frame_dir=paths[0].parent, +def _included_frames(frame_count, frame_format, frame_dir): + return INCLUDED_FRAMES.format(Nframes=frame_count, + frame_dir=frame_dir, frame_format=frame_format) @@ -744,7 +735,7 @@ def __init__(self, fps=30, codec=None, bitrate=None, extra_args=None, super().__init__(fps, codec, bitrate, extra_args, metadata) - def setup(self, fig, outfile, dpi, frame_dir=None): + def setup(self, fig, outfile, dpi=None, frame_dir=None): outfile = Path(outfile) _api.check_in_list(['.html', '.htm'], outfile_extension=outfile.suffix) @@ -791,11 +782,13 @@ def finish(self): if self.embed_frames: fill_frames = _embedded_frames(self._saved_frames, self.frame_format) - Nframes = len(self._saved_frames) + frame_count = len(self._saved_frames) else: # temp names is filled by FileMovieWriter - fill_frames = _included_frames(self._temp_paths, self.frame_format) - Nframes = len(self._temp_paths) + frame_count = len(self._temp_paths) + fill_frames = _included_frames( + frame_count, self.frame_format, + self._temp_paths[0].parent.relative_to(self.outfile.parent)) mode_dict = dict(once_checked='', loop_checked='', reflect_checked='') @@ -806,17 +799,16 @@ def finish(self): with open(self.outfile, 'w') as of: of.write(JS_INCLUDE + STYLE_INCLUDE) of.write(DISPLAY_TEMPLATE.format(id=uuid.uuid4().hex, - Nframes=Nframes, + Nframes=frame_count, fill_frames=fill_frames, interval=interval, **mode_dict)) - # duplicate the temporary file clean up logic from - # FileMovieWriter.cleanup. We can not call the inherited - # versions of finish or cleanup because both assume that - # there is a subprocess that we either need to call to merge - # many frames together or that there is a subprocess call that - # we need to clean up. + # Duplicate the temporary file clean up logic from + # FileMovieWriter.finish. We can not call the inherited version of + # finish because it assumes that there is a subprocess that we either + # need to call to merge many frames together or that there is a + # subprocess call that we need to clean up. if self._tmpdir: _log.debug('MovieWriter: clearing temporary path=%s', self._tmpdir) self._tmpdir.cleanup() @@ -848,7 +840,8 @@ class Animation: system notifications. blit : bool, default: False - Whether blitting is used to optimize drawing. + Whether blitting is used to optimize drawing. If the backend does not + support blitting, then this parameter has no effect. See Also -------- @@ -888,7 +881,7 @@ def __del__(self): 'Animation was deleted without rendering anything. This is ' 'most likely not intended. To prevent deletion, assign the ' 'Animation to a variable, e.g. `anim`, that exists until you ' - 'have outputted the Animation using `plt.show()` or ' + 'output the Animation using `plt.show()` or ' '`anim.save()`.' ) @@ -964,7 +957,7 @@ class to use, such as 'ffmpeg'. extra_anim : list, default: [] Additional `Animation` objects that should be included in the saved movie file. These need to be from the same - `matplotlib.figure.Figure` instance. Also, animation frames will + `.Figure` instance. Also, animation frames will just be simply combined, so there should be a 1:1 correspondence between the frames from the different animations. @@ -985,8 +978,7 @@ def func(current_frame: int, total_frames: int) -> Any Example code to write the progress to stdout:: - progress_callback =\ - lambda i, n: print(f'Saving frame {i} of {n}') + progress_callback = lambda i, n: print(f'Saving frame {i}/{n}') Notes ----- @@ -1010,6 +1002,9 @@ def func(current_frame: int, total_frames: int) -> Any if savefig_kwargs is None: savefig_kwargs = {} + else: + # we are going to mutate this below + savefig_kwargs = dict(savefig_kwargs) if fps is None and hasattr(self, '_interval'): # Convert interval in ms to frames per second @@ -1033,9 +1028,8 @@ def func(current_frame: int, total_frames: int) -> Any all_anim = [self] if extra_anim is not None: - all_anim.extend(anim - for anim - in extra_anim if anim._fig is self._fig) + all_anim.extend(anim for anim in extra_anim + if anim._fig is self._fig) # If we have the name of a writer, instantiate an instance of the # registered class. @@ -1065,6 +1059,18 @@ def func(current_frame: int, total_frames: int) -> Any _log.info("Disabling savefig.bbox = 'tight', as it may cause " "frame size to vary, which is inappropriate for " "animation.") + + facecolor = savefig_kwargs.get('facecolor', + mpl.rcParams['savefig.facecolor']) + if facecolor == 'auto': + facecolor = self._fig.get_facecolor() + + def _pre_composite_to_white(color): + r, g, b, a = mcolors.to_rgba(color) + return a * np.array([r, g, b]) + 1 - a + + savefig_kwargs['facecolor'] = _pre_composite_to_white(facecolor) + savefig_kwargs['transparent'] = False # just to be safe! # canvas._is_saving = True makes the draw_event animation-starting # callback a no-op; canvas.manager = None prevents resizing the GUI # widget (both are likewise done in savefig()). @@ -1077,7 +1083,7 @@ def func(current_frame: int, total_frames: int) -> Any frame_number = 0 # TODO: Currently only FuncAnimation has a save_count # attribute. Can we generalize this to all Animations? - save_count_list = [getattr(a, 'save_count', None) + save_count_list = [getattr(a, '_save_count', None) for a in all_anim] if None in save_count_list: total_frames = None @@ -1154,11 +1160,11 @@ def _blit_draw(self, artists): # Handles blitted drawing, which renders only the artists given instead # of the entire figure. updated_ax = {a.axes for a in artists} - # Enumerate artists to cache axes' backgrounds. We do not draw + # Enumerate artists to cache Axes backgrounds. We do not draw # artists yet to not cache foreground from plots with shared axes for ax in updated_ax: # If we haven't cached the background for the current view of this - # axes object, do so now. This might not always be reliable, but + # Axes object, do so now. This might not always be reliable, but # it's an attempt to automate the process. cur_view = ax._get_view() view, bg = self._blit_cache.get(ax, (object(), None)) @@ -1168,12 +1174,12 @@ def _blit_draw(self, artists): # Make a separate pass to draw foreground. for a in artists: a.axes.draw_artist(a) - # After rendering all the needed artists, blit each axes individually. + # After rendering all the needed artists, blit each Axes individually. for ax in updated_ax: ax.figure.canvas.blit(ax.bbox) def _blit_clear(self, artists): - # Get a list of the axes that need clearing from the artists that + # Get a list of the Axes that need clearing from the artists that # have been drawn. Grab the appropriate saved background from the # cache and restore. axes = {a.axes for a in artists} @@ -1188,13 +1194,19 @@ def _blit_clear(self, artists): self._blit_cache.pop(ax) def _setup_blit(self): - # Setting up the blit requires: a cache of the background for the - # axes + # Setting up the blit requires: a cache of the background for the Axes self._blit_cache = dict() self._drawn_artists = [] + # _post_draw needs to be called first to initialize the renderer + self._post_draw(None, self._blit) + # Then we need to clear the Frame for the initial draw + # This is typically handled in _on_resize because QT and Tk + # emit a resize event on launch, but the macosx backend does not, + # thus we force it here for everyone for consistency + self._init_draw() + # Connect to future resize events self._resize_id = self._fig.canvas.mpl_connect('resize_event', self._on_resize) - self._post_draw(None, self._blit) def _on_resize(self, event): # On resize, we need to disable the resize event handling so we don't @@ -1224,7 +1236,7 @@ def to_html5_video(self, embed_limit=None): This saves the animation as an h264 video, encoded in base64 directly into the HTML5 video tag. This respects :rc:`animation.writer` and :rc:`animation.bitrate`. This also makes use of the - ``interval`` to control the speed, and uses the ``repeat`` + *interval* to control the speed, and uses the *repeat* parameter to decide whether to loop. Parameters @@ -1287,7 +1299,7 @@ def to_html5_video(self, embed_limit=None): options = ['controls', 'autoplay'] # If we're set to repeat, make it loop - if hasattr(self, 'repeat') and self.repeat: + if getattr(self, '_repeat', False): options.append('loop') return VIDEO_TAG.format(video=self._base64_video, @@ -1308,17 +1320,18 @@ def to_jshtml(self, fps=None, embed_frames=True, default_mode=None): embed_frames : bool, optional default_mode : str, optional What to do when the animation ends. Must be one of ``{'loop', - 'once', 'reflect'}``. Defaults to ``'loop'`` if ``self.repeat`` - is True, otherwise ``'once'``. + 'once', 'reflect'}``. Defaults to ``'loop'`` if the *repeat* + parameter is True, otherwise ``'once'``. """ if fps is None and hasattr(self, '_interval'): # Convert interval in ms to frames per second fps = 1000 / self._interval # If we're not given a default mode, choose one base on the value of - # the repeat attribute + # the _repeat attribute if default_mode is None: - default_mode = 'loop' if self.repeat else 'once' + default_mode = 'loop' if getattr(self, '_repeat', + False) else 'once' if not hasattr(self, "_html_representation"): # Can't open a NamedTemporaryFile twice on Windows, so use a @@ -1382,13 +1395,12 @@ class TimedAnimation(Animation): blit : bool, default: False Whether blitting is used to optimize drawing. """ - def __init__(self, fig, interval=200, repeat_delay=0, repeat=True, event_source=None, *args, **kwargs): self._interval = interval # Undocumented support for repeat_delay = None as backcompat. self._repeat_delay = repeat_delay if repeat_delay is not None else 0 - self.repeat = repeat + self._repeat = repeat # If we're not given an event source, create a new timer. This permits # sharing timers between animation objects for syncing animations. if event_source is None: @@ -1404,19 +1416,34 @@ def _step(self, *args): # delay and set the callback to one which will then set the interval # back. still_going = super()._step(*args) - if not still_going and self.repeat: - self._init_draw() - self.frame_seq = self.new_frame_seq() - self.event_source.interval = self._repeat_delay - return True - else: - self.event_source.interval = self._interval - return still_going + if not still_going: + if self._repeat: + # Restart the draw loop + self._init_draw() + self.frame_seq = self.new_frame_seq() + self.event_source.interval = self._repeat_delay + return True + else: + # We are done with the animation. Call pause to remove + # animated flags from artists that were using blitting + self.pause() + if self._blit: + # Remove the resize callback if we were blitting + self._fig.canvas.mpl_disconnect(self._resize_id) + self._fig.canvas.mpl_disconnect(self._close_id) + self.event_source = None + return False + + self.event_source.interval = self._interval + return True + + repeat = _api.deprecate_privatize_attribute("3.7") class ArtistAnimation(TimedAnimation): """ - Animation using a fixed set of `.Artist` objects. + `TimedAnimation` subclass that creates an animation by using a fixed + set of `.Artist` objects. Before creating an instance, all plotting should have taken place and the relevant artists saved. @@ -1492,7 +1519,8 @@ def _draw_frame(self, artists): class FuncAnimation(TimedAnimation): """ - Makes an animation by repeatedly calling a function *func*. + `TimedAnimation` subclass that makes an animation by repeatedly calling + a function *func*. .. note:: @@ -1508,12 +1536,24 @@ class FuncAnimation(TimedAnimation): func : callable The function to call at each frame. The first argument will be the next value in *frames*. Any additional positional - arguments can be supplied via the *fargs* parameter. + arguments can be supplied using `functools.partial` or via the *fargs* + parameter. The required signature is:: def func(frame, *fargs) -> iterable_of_artists + It is often more convenient to provide the arguments using + `functools.partial`. In this way it is also possible to pass keyword + arguments. To pass a function with both positional and keyword + arguments, set all arguments as keyword arguments, just leaving the + *frame* argument unset:: + + def func(frame, art, *, y=None): + ... + + ani = FuncAnimation(fig, partial(func, art=ln, y='foo')) + If ``blit == True``, *func* must return an iterable of all artists that were modified or created. This information is used by the blitting algorithm to determine which parts of the figure have to be updated. @@ -1552,9 +1592,10 @@ def init_func() -> iterable_of_artists value is unused if ``blit == False`` and may be omitted in that case. fargs : tuple or None, optional - Additional arguments to pass to each call to *func*. + Additional arguments to pass to each call to *func*. Note: the use of + `functools.partial` is preferred over *fargs*. See *func* for details. - save_count : int, default: 100 + save_count : int, optional Fallback for the number of values from *frames* to cache. This is only used if the number of frames cannot be inferred from *frames*, i.e. when it's an iterator without length or a generator. @@ -1579,7 +1620,6 @@ def init_func() -> iterable_of_artists Whether frame data is cached. Disabling cache might be helpful when frames contain large objects. """ - def __init__(self, fig, func, frames=None, init_func=None, fargs=None, save_count=None, *, cache_frame_data=True, **kwargs): if fargs: @@ -1592,7 +1632,7 @@ def __init__(self, fig, func, frames=None, init_func=None, fargs=None, # Amount of framedata to keep around for saving movies. This is only # used if we don't know how many frames there will be: in the case # of no generator or in the case of a callable. - self.save_count = save_count + self._save_count = save_count # Set up a function that creates a new iterable when needed. If nothing # is passed in for frames, just use itertools.count, which will just # keep counting from 0. A callable passed in for frames is assumed to @@ -1612,19 +1652,31 @@ def iter_frames(frames=frames): else: self._iter_gen = lambda: iter(frames) if hasattr(frames, '__len__'): - self.save_count = len(frames) + self._save_count = len(frames) + if save_count is not None: + _api.warn_external( + f"You passed in an explicit {save_count=} " + "which is being ignored in favor of " + f"{len(frames)=}." + ) else: self._iter_gen = lambda: iter(range(frames)) - self.save_count = frames - - if self.save_count is None: - # If we're passed in and using the default, set save_count to 100. - self.save_count = 100 - else: - # itertools.islice returns an error when passed a numpy int instead - # of a native python int (https://bugs.python.org/issue30537). - # As a workaround, convert save_count to a native python int. - self.save_count = int(self.save_count) + self._save_count = frames + if save_count is not None: + _api.warn_external( + f"You passed in an explicit {save_count=} which is being " + f"ignored in favor of {frames=}." + ) + if self._save_count is None and cache_frame_data: + _api.warn_external( + f"{frames=!r} which we can infer the length of, " + "did not pass an explicit *save_count* " + f"and passed {cache_frame_data=}. To avoid a possibly " + "unbounded cache, frame data caching has been disabled. " + "To suppress this warning either pass " + "`cache_frame_data=False` or `save_count=MAX_FRAMES`." + ) + cache_frame_data = False self._cache_frame_data = cache_frame_data @@ -1651,26 +1703,18 @@ def new_saved_frame_seq(self): self._old_saved_seq = list(self._save_seq) return iter(self._old_saved_seq) else: - if self.save_count is not None: - return itertools.islice(self.new_frame_seq(), self.save_count) - - else: + if self._save_count is None: frame_seq = self.new_frame_seq() def gen(): try: - for _ in range(100): + while True: yield next(frame_seq) except StopIteration: pass - else: - _api.warn_deprecated( - "2.2", message="FuncAnimation.save has truncated " - "your animation to 100 frames. In the future, no " - "such truncation will occur; please pass " - "'save_count' accordingly.") - return gen() + else: + return itertools.islice(self.new_frame_seq(), self._save_count) def _init_draw(self): super()._init_draw() @@ -1708,10 +1752,7 @@ def _draw_frame(self, framedata): if self._cache_frame_data: # Save the data for potential saving of movies. self._save_seq.append(framedata) - - # Make sure to respect save_count (keep only the last save_count - # around) - self._save_seq = self._save_seq[-self.save_count:] + self._save_seq = self._save_seq[-self._save_count:] # Call the func with framedata and args. If blitting is desired, # func needs to return a sequence of any artists that were modified. @@ -1737,3 +1778,5 @@ def _draw_frame(self, framedata): for a in self._drawn_artists: a.set_animated(self._blit) + + save_count = _api.deprecate_privatize_attribute("3.7") diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 72ecb0548462..44c128232235 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -1,6 +1,6 @@ from collections import namedtuple import contextlib -from functools import wraps +from functools import lru_cache, wraps import inspect from inspect import Signature, Parameter import logging @@ -12,6 +12,7 @@ import matplotlib as mpl from . import _api, cbook +from .colors import BoundaryNorm from .cm import ScalarMappable from .path import Path from .transforms import (Bbox, IdentityTransform, Transform, TransformedBbox, @@ -20,6 +21,27 @@ _log = logging.getLogger(__name__) +def _prevent_rasterization(draw): + # We assume that by default artists are not allowed to rasterize (unless + # its draw method is explicitly decorated). If it is being drawn after a + # rasterized artist and it has reached a raster_depth of 0, we stop + # rasterization so that it does not affect the behavior of normal artist + # (e.g., change in dpi). + + @wraps(draw) + def draw_wrapper(artist, renderer, *args, **kwargs): + if renderer._raster_depth == 0 and renderer._rasterizing: + # Only stop when we are not in a rasterized parent + # and something has been rasterized since last stop. + renderer.stop_rasterizing() + renderer._rasterizing = False + + return draw(artist, renderer, *args, **kwargs) + + draw_wrapper._supports_rasterization = False + return draw_wrapper + + def allow_rasterization(draw): """ Decorator for Artist.draw method. Provides routines @@ -102,6 +124,15 @@ class Artist: zorder = 0 def __init_subclass__(cls): + + # Decorate draw() method so that all artists are able to stop + # rastrization when necessary. If the artist's draw method is already + # decorated (has a `_supports_rasterization` attribute), it won't be + # decorated. + + if not hasattr(cls.draw, "_supports_rasterization"): + cls.draw = _prevent_rasterization(cls.draw) + # Inject custom set() methods into the subclass with signature and # docstring based on the subclasses' properties. @@ -167,7 +198,7 @@ def __init__(self): # Normally, artist classes need to be queried for mouseover info if and # only if they override get_cursor_data. self._mouseover = type(self).get_cursor_data != Artist.get_cursor_data - self._callbacks = cbook.CallbackRegistry() + self._callbacks = cbook.CallbackRegistry(signals=["pchanged"]) try: self.axes = None except AttributeError: @@ -185,7 +216,7 @@ def __init__(self): def __getstate__(self): d = self.__dict__.copy() # remove the unpicklable remove method, this will get re-added on load - # (by the axes) if the artist lives on an axes. + # (by the Axes) if the artist lives on an Axes. d['stale_callback'] = None return d @@ -203,7 +234,7 @@ def remove(self): Note: there is no support for removing the artist's legend entry. """ - # There is no method to set the callback. Instead the parent should + # There is no method to set the callback. Instead, the parent should # set the _remove_method attribute directly. This would be a # protected attribute if Python supported that sort of thing. The # callback has one parameter, which is the child to be removed. @@ -215,10 +246,8 @@ def remove(self): if hasattr(self, 'axes') and self.axes: # remove from the mouse hit list self.axes._mouseover_set.discard(self) - # mark the axes as stale self.axes.stale = True - # decouple the artist from the axes - self.axes = None + self.axes = None # decouple the artist from the Axes _ax_flag = True if self.figure: @@ -237,13 +266,13 @@ def remove(self): def have_units(self): """Return whether units are set on any axis.""" ax = self.axes - return ax and any(axis.have_units() for axis in ax._get_axis_list()) + return ax and any(axis.have_units() for axis in ax._axis_map.values()) def convert_xunits(self, x): """ Convert *x* using the unit type of the xaxis. - If the artist is not in contained in an Axes or if the xaxis does not + If the artist is not contained in an Axes or if the xaxis does not have units, *x* itself is returned. """ ax = getattr(self, 'axes', None) @@ -255,7 +284,7 @@ def convert_yunits(self, y): """ Convert *y* using the unit type of the yaxis. - If the artist is not in contained in an Axes or if the yaxis does not + If the artist is not contained in an Axes or if the yaxis does not have units, *y* itself is returned. """ ax = getattr(self, 'axes', None) @@ -300,7 +329,7 @@ def stale(self, val): if val and self.stale_callback is not None: self.stale_callback(self, val) - def get_window_extent(self, renderer): + def get_window_extent(self, renderer=None): """ Get the artist's bounding box in display space. @@ -320,24 +349,7 @@ def get_window_extent(self, renderer): """ return Bbox([[0, 0], [0, 0]]) - def _get_clipping_extent_bbox(self): - """ - Return a bbox with the extents of the intersection of the clip_path - and clip_box for this artist, or None if both of these are - None, or ``get_clip_on`` is False. - """ - bbox = None - if self.get_clip_on(): - clip_box = self.get_clip_box() - if clip_box is not None: - bbox = clip_box - clip_path = self.get_clip_path() - if clip_path is not None and bbox is not None: - clip_path = clip_path.get_fully_transformed_path() - bbox = Bbox.intersection(bbox, clip_path.get_extents()) - return bbox - - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer=None): """ Like `.Artist.get_window_extent`, but includes any clipping. @@ -358,7 +370,7 @@ def get_tightbbox(self, renderer): if clip_box is not None: bbox = Bbox.intersection(bbox, clip_box) clip_path = self.get_clip_path() - if clip_path is not None and bbox is not None: + if clip_path is not None: clip_path = clip_path.get_fully_transformed_path() bbox = Bbox.intersection(bbox, clip_path.get_extents()) return bbox @@ -516,6 +528,7 @@ def pick(self, mouseevent): -------- set_picker, get_picker, pickable """ + from .backend_bases import PickEvent # Circular import. # Pick self if self.pickable(): picker = self.get_picker() @@ -524,20 +537,21 @@ def pick(self, mouseevent): else: inside, prop = self.contains(mouseevent) if inside: - self.figure.canvas.pick_event(mouseevent, self, **prop) + PickEvent("pick_event", self.figure.canvas, + mouseevent, self, **prop)._process() # Pick children for a in self.get_children(): - # make sure the event happened in the same axes + # make sure the event happened in the same Axes ax = getattr(a, 'axes', None) if (mouseevent.inaxes is None or ax is None or mouseevent.inaxes == ax): # we need to check if mouseevent.inaxes is None - # because some objects associated with an axes (e.g., a + # because some objects associated with an Axes (e.g., a # tick label) can be outside the bounding box of the - # axes and inaxes will be None + # Axes and inaxes will be None # also check that ax is None so that it traverse objects - # which do no have an axes property but children might + # which do not have an axes property but children might a.pick(mouseevent) def set_picker(self, picker): @@ -751,6 +765,11 @@ def set_clip_box(self, clipbox): Parameters ---------- clipbox : `.Bbox` + + Typically would be created from a `.TransformedBbox`. For + instance ``TransformedBbox(Bbox([[0, 0], [1, 1]]), ax.transAxes)`` + is the default clipping for an artist added to an Axes. + """ self.clipbox = clipbox self.pchanged() @@ -844,6 +863,30 @@ def get_in_layout(self): """ return self._in_layout + def _fully_clipped_to_axes(self): + """ + Return a boolean flag, ``True`` if the artist is clipped to the Axes + and can thus be skipped in layout calculations. Requires `get_clip_on` + is True, one of `clip_box` or `clip_path` is set, ``clip_box.extents`` + is equivalent to ``ax.bbox.extents`` (if set), and ``clip_path._patch`` + is equivalent to ``ax.patch`` (if set). + """ + # Note that ``clip_path.get_fully_transformed_path().get_extents()`` + # cannot be directly compared to ``axes.bbox.extents`` because the + # extents may be undefined (i.e. equivalent to ``Bbox.null()``) + # before the associated artist is drawn, and this method is meant + # to determine whether ``axes.get_tightbbox()`` may bypass drawing + clip_box = self.get_clip_box() + clip_path = self.get_clip_path() + return (self.axes is not None + and self.get_clip_on() + and (clip_box is not None or clip_path is not None) + and (clip_box is None + or np.all(clip_box.extents == self.axes.bbox.extents)) + and (clip_path is None + or isinstance(clip_path, TransformedPatchPath) + and clip_path._patch is self.axes.patch)) + def get_clip_on(self): """Return whether the artist uses clipping.""" return self._clipon @@ -870,7 +913,7 @@ def set_clip_on(self, b): """ Set whether the artist uses clipping. - When False artists will be visible outside of the axes which + When False, artists will be visible outside the Axes which can lead to unexpected results. Parameters @@ -913,7 +956,9 @@ def set_rasterized(self, rasterized): ---------- rasterized : bool """ - if rasterized and not hasattr(self.draw, "_supports_rasterization"): + supports_rasterization = getattr(self.draw, + "_supports_rasterization", False) + if rasterized and not supports_rasterization: _api.warn_external(f"Rasterization of '{self}' will be ignored") self._rasterized = rasterized @@ -929,11 +974,13 @@ def set_agg_filter(self, filter_func): Parameters ---------- filter_func : callable - A filter function, which takes a (m, n, 3) float array and a dpi - value, and returns a (m, n, 3) array. + A filter function, which takes a (m, n, depth) float array + and a dpi value, and returns a (m, n, depth) array and two + offsets from the bottom left corner of the image .. ACCEPTS: a filter function, which takes a (m, n, 3) float array - and a dpi value, and returns a (m, n, 3) array + and a dpi value, and returns a (m, n, 3) array and two offsets + from the bottom left corner of the image """ self._agg_filter = filter_func self.stale = True @@ -1016,7 +1063,7 @@ def set_animated(self, b): If True, the artist is excluded from regular drawing of the figure. You have to call `.Figure.draw_artist` / `.Axes.draw_artist` - explicitly on the artist. This appoach is used to speed up animations + explicitly on the artist. This approach is used to speed up animations using blitting. See also `matplotlib.animation` and @@ -1043,32 +1090,6 @@ def set_in_layout(self, in_layout): """ self._in_layout = in_layout - def update(self, props): - """ - Update this artist's properties from the dict *props*. - - Parameters - ---------- - props : dict - """ - ret = [] - with cbook._setattr_cm(self, eventson=False): - for k, v in props.items(): - # Allow attributes we want to be able to update through - # art.update, art.set, setp. - if k == "axes": - ret.append(setattr(self, k, v)) - else: - func = getattr(self, f"set_{k}", None) - if not callable(func): - raise AttributeError(f"{type(self).__name__!r} object " - f"has no property {k!r}") - ret.append(func(v)) - if ret: - self.pchanged() - self.stale = True - return ret - def get_label(self): """Return the label used for this artist in the legend.""" return self._label @@ -1156,12 +1177,58 @@ def properties(self): """Return a dictionary of all the properties of the artist.""" return ArtistInspector(self).properties() + def _update_props(self, props, errfmt): + """ + Helper for `.Artist.set` and `.Artist.update`. + + *errfmt* is used to generate error messages for invalid property + names; it gets formatted with ``type(self)`` and the property name. + """ + ret = [] + with cbook._setattr_cm(self, eventson=False): + for k, v in props.items(): + # Allow attributes we want to be able to update through + # art.update, art.set, setp. + if k == "axes": + ret.append(setattr(self, k, v)) + else: + func = getattr(self, f"set_{k}", None) + if not callable(func): + raise AttributeError( + errfmt.format(cls=type(self), prop_name=k)) + ret.append(func(v)) + if ret: + self.pchanged() + self.stale = True + return ret + + def update(self, props): + """ + Update this artist's properties from the dict *props*. + + Parameters + ---------- + props : dict + """ + return self._update_props( + props, "{cls.__name__!r} object has no property {prop_name!r}") + + def _internal_update(self, kwargs): + """ + Update artist properties without prenormalizing them, but generating + errors as if calling `set`. + + The lack of prenormalization is to maintain backcompatibility. + """ + return self._update_props( + kwargs, "{cls.__name__}.set() got an unexpected keyword argument " + "{prop_name!r}") + def set(self, **kwargs): # docstring and signature are auto-generated via # Artist._update_set_signature_and_docstring() at the end of the # module. - kwargs = cbook.normalize_kwargs(kwargs, self) - return self.update(kwargs) + return self._internal_update(cbook.normalize_kwargs(kwargs, self)) @contextlib.contextmanager def _cm_set(self, **kwargs): @@ -1278,10 +1345,20 @@ def format_cursor_data(self, data): return "[]" normed = self.norm(data) if np.isfinite(normed): - # Midpoints of neighboring color intervals. - neighbors = self.norm.inverse( - (int(self.norm(data) * n) + np.array([0, 1])) / n) - delta = abs(neighbors - data).max() + if isinstance(self.norm, BoundaryNorm): + # not an invertible normalization mapping + cur_idx = np.argmin(np.abs(self.norm.boundaries - data)) + neigh_idx = max(0, cur_idx - 1) + # use max diff to prevent delta == 0 + delta = np.diff( + self.norm.boundaries[neigh_idx:cur_idx + 2] + ).max() + + else: + # Midpoints of neighboring color intervals. + neighbors = self.norm.inverse( + (int(normed * n) + np.array([0, 1])) / n) + delta = abs(neighbors - data).max() g_sig_digits = cbook._g_sig_digits(data, delta) else: g_sig_digits = 3 # Consistent with default below. @@ -1295,28 +1372,50 @@ def format_cursor_data(self, data): if isinstance(item, Number)) return "[" + data_str + "]" - @property - def mouseover(self): + def get_mouseover(self): """ - If this property is set to *True*, the artist will be queried for - custom context information when the mouse cursor moves over it. - - See also :meth:`get_cursor_data`, :class:`.ToolCursorPosition` and - :class:`.NavigationToolbar2`. + Return whether this artist is queried for custom context information + when the mouse cursor moves over it. """ return self._mouseover - @mouseover.setter - def mouseover(self, val): - val = bool(val) - self._mouseover = val + def set_mouseover(self, mouseover): + """ + Set whether this artist is queried for custom context information when + the mouse cursor moves over it. + + Parameters + ---------- + mouseover : bool + + See Also + -------- + get_cursor_data + .ToolCursorPosition + .NavigationToolbar2 + """ + self._mouseover = bool(mouseover) ax = self.axes if ax: - if val: + if self._mouseover: ax._mouseover_set.add(self) else: ax._mouseover_set.discard(self) + mouseover = property(get_mouseover, set_mouseover) # backcompat. + + +def _get_tightbbox_for_layout_only(obj, *args, **kwargs): + """ + Matplotlib's `.Axes.get_tightbbox` and `.Axis.get_tightbbox` support a + *for_layout_only* kwarg; this helper tries to use the kwarg but skips it + when encountering third-party subclasses that do not support it. + """ + try: + return obj.get_tightbbox(*args, **{**kwargs, "for_layout_only": True}) + except TypeError: + return obj.get_tightbbox(*args, **kwargs) + class ArtistInspector: """ @@ -1401,7 +1500,7 @@ def get_valid_values(self, attr): # although barely relevant wrt. matplotlib's total import time. param_name = func.__code__.co_varnames[1] # We could set the presence * based on whether the parameter is a - # varargs (it can't be a varkwargs) but it's not really worth the it. + # varargs (it can't be a varkwargs) but it's not really worth it. match = re.search(r"(?m)^ *\*?{} : (.+)".format(param_name), docstring) if match: return match.group(1) @@ -1432,24 +1531,36 @@ def get_setters(self): continue func = getattr(self.o, name) if (not callable(func) - or len(inspect.signature(func).parameters) < 2 + or self.number_of_parameters(func) < 2 or self.is_alias(func)): continue setters.append(name[4:]) return setters - def is_alias(self, o): - """Return whether method object *o* is an alias for another method.""" - ds = inspect.getdoc(o) + @staticmethod + @lru_cache(maxsize=None) + def number_of_parameters(func): + """Return number of parameters of the callable *func*.""" + return len(inspect.signature(func).parameters) + + @staticmethod + @lru_cache(maxsize=None) + def is_alias(method): + """ + Return whether the object *method* is an alias for another method. + """ + + ds = inspect.getdoc(method) if ds is None: return False + return ds.startswith('Alias for ') def aliased_name(self, s): """ Return 'PROPNAME or alias' if *s* has an alias, else return 'PROPNAME'. - e.g., for the line markerfacecolor property, which has an + For example, for the line markerfacecolor property, which has an alias, return 'markerfacecolor or mfc' and for the transform property, which does not, return 'transform'. """ @@ -1477,7 +1588,7 @@ def aliased_name_rest(self, s, target): Return 'PROPNAME or alias' if *s* has an alias, else return 'PROPNAME', formatted for reST. - e.g., for the line markerfacecolor property, which has an + For example, for the line markerfacecolor property, which has an alias, return 'markerfacecolor or mfc' and for the transform property, which does not, return 'transform'. """ diff --git a/lib/matplotlib/axes/__init__.py b/lib/matplotlib/axes/__init__.py index 4dd998c0d43d..f8c40889bce7 100644 --- a/lib/matplotlib/axes/__init__.py +++ b/lib/matplotlib/axes/__init__.py @@ -1,2 +1,18 @@ -from ._subplots import * +from . import _base from ._axes import * + +# Backcompat. +from ._axes import Axes as Subplot + + +class _SubplotBaseMeta(type): + def __instancecheck__(self, obj): + return (isinstance(obj, _base._AxesBase) + and obj.get_subplotspec() is not None) + + +class SubplotBase(metaclass=_SubplotBaseMeta): + pass + + +def subplot_class_factory(cls): return cls diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 4f0679048329..9129e83ac008 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -7,13 +7,13 @@ import numpy as np from numpy import ma -import matplotlib.category # Register category unit converter as side-effect. +import matplotlib as mpl +import matplotlib.category # Register category unit converter as side effect. import matplotlib.cbook as cbook import matplotlib.collections as mcoll import matplotlib.colors as mcolors import matplotlib.contour as mcontour -import matplotlib.dates # Register date unit converter as side-effect. -import matplotlib.docstring as docstring +import matplotlib.dates # noqa # Register date unit converter as side effect. import matplotlib.image as mimage import matplotlib.legend as mlegend import matplotlib.lines as mlines @@ -30,7 +30,7 @@ import matplotlib.transforms as mtransforms import matplotlib.tri as mtri import matplotlib.units as munits -from matplotlib import _api, _preprocess_data, rcParams +from matplotlib import _api, _docstring, _preprocess_data from matplotlib.axes._base import ( _AxesBase, _TransformedBoundsLocator, _process_plot_format) from matplotlib.axes._secondary_axes import SecondaryAxis @@ -43,18 +43,29 @@ # All the other methods should go in the _AxesBase class. -@docstring.interpd +@_docstring.interpd class Axes(_AxesBase): """ - The `Axes` contains most of the figure elements: `~.axis.Axis`, + An Axes object encapsulates all the elements of an individual (sub-)plot in + a figure. + + It contains most of the (sub-)plot elements: `~.axis.Axis`, `~.axis.Tick`, `~.lines.Line2D`, `~.text.Text`, `~.patches.Polygon`, etc., and sets the coordinate system. + Like all visible elements in a figure, Axes is an `.Artist` subclass. + The `Axes` instance supports callbacks through a callbacks attribute which is a `~.cbook.CallbackRegistry` instance. The events you can connect to are 'xlim_changed' and 'ylim_changed' and the callback will be called with func(*ax*) where *ax* is the `Axes` instance. + .. note:: + + As a user, you do not instantiate Axes directly, but use Axes creation + methods instead; e.g. from `.pyplot` or `.Figure`: + `~.pyplot.subplots`, `~.pyplot.subplot_mosaic` or `.Figure.add_axes`. + Attributes ---------- dataLim : `.Bbox` @@ -137,10 +148,10 @@ def set_title(self, label, fontdict=None, loc=None, pad=None, *, y=None, of valid text properties. """ if loc is None: - loc = rcParams['axes.titlelocation'] + loc = mpl.rcParams['axes.titlelocation'] if y is None: - y = rcParams['axes.titley'] + y = mpl.rcParams['axes.titley'] if y is None: y = 1.0 else: @@ -152,21 +163,21 @@ def set_title(self, label, fontdict=None, loc=None, pad=None, *, y=None, 'right': self._right_title} title = _api.check_getitem(titles, loc=loc.lower()) default = { - 'fontsize': rcParams['axes.titlesize'], - 'fontweight': rcParams['axes.titleweight'], + 'fontsize': mpl.rcParams['axes.titlesize'], + 'fontweight': mpl.rcParams['axes.titleweight'], 'verticalalignment': 'baseline', 'horizontalalignment': loc.lower()} - titlecolor = rcParams['axes.titlecolor'] + titlecolor = mpl.rcParams['axes.titlecolor'] if not cbook._str_lower_equal(titlecolor, 'auto'): default["color"] = titlecolor if pad is None: - pad = rcParams['axes.titlepad'] + pad = mpl.rcParams['axes.titlepad'] self._set_title_offset_trans(float(pad)) title.set_text(label) title.update(default) if fontdict is not None: title.update(fontdict) - title.update(kwargs) + title._internal_update(kwargs) return title def get_legend_handles_labels(self, legend_handler_map=None): @@ -183,7 +194,7 @@ def get_legend_handles_labels(self, legend_handler_map=None): [self], legend_handler_map) return handles, labels - @docstring.dedent_interpd + @_docstring.dedent_interpd def legend(self, *args, **kwargs): """ Place a legend on the Axes. @@ -216,11 +227,13 @@ def legend(self, *args, **kwargs): line.set_label('Label via method') ax.legend() - Specific lines can be excluded from the automatic legend element - selection by defining a label starting with an underscore. - This is default for all artists, so calling `.Axes.legend` without - any arguments and without setting the labels manually will result in - no legend being drawn. + .. note:: + Specific artists can be excluded from the automatic legend element + selection by using a label starting with an underscore, "_". + A string starting with an underscore is the default label for all + artists, so calling `.Axes.legend` without any arguments and + without setting the labels manually will result in no legend being + drawn. **2. Explicitly listing the artists and labels in the legend** @@ -281,7 +294,7 @@ def legend(self, *args, **kwargs): Other Parameters ---------------- - %(_legend_kw_doc)s + %(_legend_kw_axes)s See Also -------- @@ -326,13 +339,27 @@ def inset_axes(self, bounds, *, transform=None, zorder=5, **kwargs): Defaults to `ax.transAxes`, i.e. the units of *rect* are in Axes-relative coordinates. + projection : {None, 'aitoff', 'hammer', 'lambert', 'mollweide', \ +'polar', 'rectilinear', str}, optional + The projection type of the inset `~.axes.Axes`. *str* is the name + of a custom projection, see `~matplotlib.projections`. The default + None results in a 'rectilinear' projection. + + polar : bool, default: False + If True, equivalent to projection='polar'. + + axes_class : subclass type of `~.axes.Axes`, optional + The `.axes.Axes` subclass that is instantiated. This parameter + is incompatible with *projection* and *polar*. See + :ref:`axisartist_users-guide-index` for examples. + zorder : number Defaults to 5 (same as `.Axes.legend`). Adjust higher or lower to change whether it is above or below data plotted on the parent Axes. **kwargs - Other keyword arguments are passed on to the child `.Axes`. + Other keyword arguments are passed on to the inset Axes class. Returns ------- @@ -358,7 +385,10 @@ def inset_axes(self, bounds, *, transform=None, zorder=5, **kwargs): # This puts the rectangle into figure-relative coordinates. inset_locator = _TransformedBoundsLocator(bounds, transform) bounds = inset_locator(self, None).bounds - inset_ax = Axes(self.figure, bounds, zorder=zorder, **kwargs) + projection_class, pkw = self.figure._process_projection_requirements( + bounds, **kwargs) + inset_ax = projection_class(self.figure, bounds, zorder=zorder, **pkw) + # this locator lets the axes move if in data coordinates. # it gets called in `ax.apply_aspect() (of all places) inset_ax.set_axes_locator(inset_locator) @@ -367,7 +397,7 @@ def inset_axes(self, bounds, *, transform=None, zorder=5, **kwargs): return inset_ax - @docstring.dedent_interpd + @_docstring.dedent_interpd def indicate_inset(self, bounds, inset_ax=None, *, transform=None, facecolor='none', edgecolor='0.5', alpha=0.5, zorder=4.99, **kwargs): @@ -519,10 +549,10 @@ def indicate_inset_zoom(self, inset_ax, **kwargs): rect = (xlim[0], ylim[0], xlim[1] - xlim[0], ylim[1] - ylim[0]) return self.indicate_inset(rect, inset_ax, **kwargs) - @docstring.dedent_interpd + @_docstring.dedent_interpd def secondary_xaxis(self, location, *, functions=None, **kwargs): """ - Add a second x-axis to this Axes. + Add a second x-axis to this `~.axes.Axes`. For example if we want to have a second scale for the data plotted on the xaxis. @@ -561,10 +591,10 @@ def invert(x): raise ValueError('secondary_xaxis location must be either ' 'a float or "top"/"bottom"') - @docstring.dedent_interpd + @_docstring.dedent_interpd def secondary_yaxis(self, location, *, functions=None, **kwargs): """ - Add a second y-axis to this Axes. + Add a second y-axis to this `~.axes.Axes`. For example if we want to have a second scale for the data plotted on the yaxis. @@ -593,7 +623,7 @@ def secondary_yaxis(self, location, *, functions=None, **kwargs): raise ValueError('secondary_yaxis location must be either ' 'a float or "left"/"right"') - @docstring.dedent_interpd + @_docstring.dedent_interpd def text(self, x, y, s, fontdict=None, **kwargs): """ Add text to the Axes. @@ -661,9 +691,14 @@ def text(self, x, y, s, fontdict=None, **kwargs): self._add_text(t) return t - @docstring.dedent_interpd - def annotate(self, text, xy, *args, **kwargs): - a = mtext.Annotation(text, xy, *args, **kwargs) + @_docstring.dedent_interpd + def annotate(self, text, xy, xytext=None, xycoords='data', textcoords=None, + arrowprops=None, annotation_clip=None, **kwargs): + # Signature must match Annotation. This is verified in + # test_annotate_signature(). + a = mtext.Annotation(text, xy, xytext=xytext, xycoords=xycoords, + textcoords=textcoords, arrowprops=arrowprops, + annotation_clip=annotation_clip, **kwargs) a.set_transform(mtransforms.IdentityTransform()) if 'clip_on' in kwargs: a.set_clip_path(self.patch) @@ -672,7 +707,7 @@ def annotate(self, text, xy, *args, **kwargs): annotate.__doc__ = mtext.Annotation.__init__.__doc__ #### Lines and spans - @docstring.dedent_interpd + @_docstring.dedent_interpd def axhline(self, y=0, xmin=0, xmax=1, **kwargs): """ Add a horizontal line across the Axes. @@ -697,8 +732,8 @@ def axhline(self, y=0, xmin=0, xmax=1, **kwargs): Other Parameters ---------------- **kwargs - Valid keyword arguments are `.Line2D` properties, with the - exception of 'transform': + Valid keyword arguments are `.Line2D` properties, except for + 'transform': %(Line2D:kwdoc)s @@ -736,10 +771,11 @@ def axhline(self, y=0, xmin=0, xmax=1, **kwargs): trans = self.get_yaxis_transform(which='grid') l = mlines.Line2D([xmin, xmax], [y, y], transform=trans, **kwargs) self.add_line(l) - self._request_autoscale_view(scalex=False, scaley=scaley) + if scaley: + self._request_autoscale_view("y") return l - @docstring.dedent_interpd + @_docstring.dedent_interpd def axvline(self, x=0, ymin=0, ymax=1, **kwargs): """ Add a vertical line across the Axes. @@ -764,8 +800,8 @@ def axvline(self, x=0, ymin=0, ymax=1, **kwargs): Other Parameters ---------------- **kwargs - Valid keyword arguments are `.Line2D` properties, with the - exception of 'transform': + Valid keyword arguments are `.Line2D` properties, except for + 'transform': %(Line2D:kwdoc)s @@ -803,7 +839,8 @@ def axvline(self, x=0, ymin=0, ymax=1, **kwargs): trans = self.get_xaxis_transform(which='grid') l = mlines.Line2D([x, x], [ymin, ymax], transform=trans, **kwargs) self.add_line(l) - self._request_autoscale_view(scalex=scalex, scaley=False) + if scalex: + self._request_autoscale_view("x") return l @staticmethod @@ -814,7 +851,7 @@ def _check_no_units(vals, names): raise ValueError(f"{name} must be a single scalar value, " f"but got {val}") - @docstring.dedent_interpd + @_docstring.dedent_interpd def axline(self, xy1, xy2=None, *, slope=None, **kwargs): """ Add an infinitely long straight line. @@ -888,7 +925,7 @@ def axline(self, xy1, xy2=None, *, slope=None, **kwargs): self._request_autoscale_view() return line - @docstring.dedent_interpd + @_docstring.dedent_interpd def axhspan(self, ymin, ymax, xmin=0, xmax=1, **kwargs): """ Add a horizontal span (rectangle) across the Axes. @@ -933,10 +970,10 @@ def axhspan(self, ymin, ymax, xmin=0, xmax=1, **kwargs): p = mpatches.Polygon(verts, **kwargs) p.set_transform(self.get_yaxis_transform(which="grid")) self.add_patch(p) - self._request_autoscale_view(scalex=False) + self._request_autoscale_view("y") return p - @docstring.dedent_interpd + @_docstring.dedent_interpd def axvspan(self, xmin, xmax, ymin=0, ymax=1, **kwargs): """ Add a vertical span (rectangle) across the Axes. @@ -990,7 +1027,7 @@ def axvspan(self, xmin, xmax, ymin=0, ymax=1, **kwargs): p.set_transform(self.get_xaxis_transform(which="grid")) p.get_path()._interpolation_steps = 100 self.add_patch(p) - self._request_autoscale_view(scaley=False) + self._request_autoscale_view("x") return p @_preprocess_data(replace_names=["y", "xmin", "xmax", "colors"], @@ -1007,7 +1044,7 @@ def hlines(self, y, xmin, xmax, colors=None, linestyles='solid', xmin, xmax : float or array-like Respective beginning and end of each line. If scalars are - provided, all lines will have same length. + provided, all lines will have the same length. colors : list of colors, default: :rc:`lines.color` @@ -1057,16 +1094,17 @@ def hlines(self, y, xmin, xmax, colors=None, linestyles='solid', lines = mcoll.LineCollection(masked_verts, colors=colors, linestyles=linestyles, label=label) self.add_collection(lines, autolim=False) - lines.update(kwargs) + lines._internal_update(kwargs) if len(y) > 0: - minx = min(xmin.min(), xmax.min()) - maxx = max(xmin.max(), xmax.max()) - miny = y.min() - maxy = y.max() - + # Extreme values of xmin/xmax/y. Using masked_verts here handles + # the case of y being a masked *object* array (as can be generated + # e.g. by errorbar()), which would make nanmin/nanmax stumble. + minx = np.nanmin(masked_verts[..., 0]) + maxx = np.nanmax(masked_verts[..., 0]) + miny = np.nanmin(masked_verts[..., 1]) + maxy = np.nanmax(masked_verts[..., 1]) corners = (minx, miny), (maxx, maxy) - self.update_datalim(corners) self._request_autoscale_view() @@ -1086,7 +1124,7 @@ def vlines(self, x, ymin, ymax, colors=None, linestyles='solid', ymin, ymax : float or array-like Respective beginning and end of each line. If scalars are - provided, all lines will have same length. + provided, all lines will have the same length. colors : list of colors, default: :rc:`lines.color` @@ -1136,14 +1174,16 @@ def vlines(self, x, ymin, ymax, colors=None, linestyles='solid', lines = mcoll.LineCollection(masked_verts, colors=colors, linestyles=linestyles, label=label) self.add_collection(lines, autolim=False) - lines.update(kwargs) + lines._internal_update(kwargs) if len(x) > 0: - minx = x.min() - maxx = x.max() - miny = min(ymin.min(), ymax.min()) - maxy = max(ymin.max(), ymax.max()) - + # Extreme values of x/ymin/ymax. Using masked_verts here handles + # the case of x being a masked *object* array (as can be generated + # e.g. by errorbar()), which would make nanmin/nanmax stumble. + minx = np.nanmin(masked_verts[..., 0]) + maxx = np.nanmax(masked_verts[..., 0]) + miny = np.nanmin(masked_verts[..., 1]) + maxy = np.nanmax(masked_verts[..., 1]) corners = (minx, miny), (maxx, maxy) self.update_datalim(corners) self._request_autoscale_view() @@ -1153,9 +1193,9 @@ def vlines(self, x, ymin, ymax, colors=None, linestyles='solid', @_preprocess_data(replace_names=["positions", "lineoffsets", "linelengths", "linewidths", "colors", "linestyles"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def eventplot(self, positions, orientation='horizontal', lineoffsets=1, - linelengths=1, linewidths=None, colors=None, + linelengths=1, linewidths=None, colors=None, alpha=None, linestyles='solid', **kwargs): """ Plot identical parallel lines at the given positions. @@ -1217,6 +1257,13 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, If *positions* is 2D, this can be a sequence with length matching the length of *positions*. + alpha : float or array-like, default: 1 + The alpha blending value(s), between 0 (transparent) and 1 + (opaque). + + If *positions* is 2D, this can be a sequence with length matching + the length of *positions*. + linestyles : str or tuple or list of such values, default: 'solid' Default is 'solid'. Valid strings are ['solid', 'dashed', 'dashdot', 'dotted', '-', '--', '-.', ':']. Dash tuples @@ -1244,8 +1291,8 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, Notes ----- - For *linelengths*, *linewidths*, *colors*, and *linestyles*, if only - a single value is given, that value is applied to all lines. If an + For *linelengths*, *linewidths*, *colors*, *alpha* and *linestyles*, if + only a single value is given, that value is applied to all lines. If an array-like is given, it must have the same length as *positions*, and each value will be applied to the corresponding row of the array. @@ -1287,6 +1334,8 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, linewidths = [linewidths] if not np.iterable(colors): colors = [colors] + if not np.iterable(alpha): + alpha = [alpha] if hasattr(linestyles, 'lower') or not np.iterable(linestyles): linestyles = [linestyles] @@ -1323,8 +1372,9 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, if len(linewidths) == 1: linewidths = np.tile(linewidths, len(positions)) if len(colors) == 1: - colors = list(colors) - colors = colors * len(positions) + colors = list(colors) * len(positions) + if len(alpha) == 1: + alpha = list(alpha) * len(positions) if len(linestyles) == 1: linestyles = [linestyles] * len(positions) @@ -1340,23 +1390,28 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, if len(colors) != len(positions): raise ValueError('colors and positions are unequal sized ' 'sequences') + if len(alpha) != len(positions): + raise ValueError('alpha and positions are unequal sized ' + 'sequences') if len(linestyles) != len(positions): raise ValueError('linestyles and positions are unequal sized ' 'sequences') colls = [] - for position, lineoffset, linelength, linewidth, color, linestyle in \ + for position, lineoffset, linelength, linewidth, color, alpha_, \ + linestyle in \ zip(positions, lineoffsets, linelengths, linewidths, - colors, linestyles): + colors, alpha, linestyles): coll = mcoll.EventCollection(position, orientation=orientation, lineoffset=lineoffset, linelength=linelength, linewidth=linewidth, color=color, + alpha=alpha_, linestyle=linestyle) self.add_collection(coll, autolim=False) - coll.update(kwargs) + coll._internal_update(kwargs) colls.append(coll) if len(positions) > 0: @@ -1385,7 +1440,7 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, # Uses a custom implementation of data-kwarg handling in # _process_plot_var_args. - @docstring.dedent_interpd + @_docstring.dedent_interpd def plot(self, *args, scalex=True, scaley=True, data=None, **kwargs): """ Plot y versus x as lines and/or markers. @@ -1462,7 +1517,7 @@ def plot(self, *args, scalex=True, scaley=True, data=None, **kwargs): >>> plot(x1, y1, 'g^', x2, y2, 'g-') In this case, any additional keyword argument applies to all - datasets. Also this syntax cannot be combined with the *data* + datasets. Also, this syntax cannot be combined with the *data* parameter. By default, each line is assigned a different style specified by a @@ -1516,7 +1571,8 @@ def plot(self, *args, scalex=True, scaley=True, data=None, **kwargs): ---------------- scalex, scaley : bool, default: True These parameters determine if the view limits are adapted to the - data limits. The values are passed on to `autoscale_view`. + data limits. The values are passed on to + `~.axes.Axes.autoscale_view`. **kwargs : `.Line2D` properties, optional *kwargs* are used to specify properties like a line label (for @@ -1632,15 +1688,18 @@ def plot(self, *args, scalex=True, scaley=True, data=None, **kwargs): lines = [*self._get_lines(*args, data=data, **kwargs)] for line in lines: self.add_line(line) - self._request_autoscale_view(scalex=scalex, scaley=scaley) + if scalex: + self._request_autoscale_view("x") + if scaley: + self._request_autoscale_view("y") return lines @_preprocess_data(replace_names=["x", "y"], label_namer="y") - @docstring.dedent_interpd + @_docstring.dedent_interpd def plot_date(self, x, y, fmt='o', tz=None, xdate=True, ydate=False, **kwargs): """ - Plot coercing the axis to treat floats as dates. + [*Discouraged*] Plot coercing the axis to treat floats as dates. .. admonition:: Discouraged @@ -1717,10 +1776,10 @@ def plot_date(self, x, y, fmt='o', tz=None, xdate=True, ydate=False, return self.plot(x, y, fmt, **kwargs) # @_preprocess_data() # let 'plot' do the unpacking.. - @docstring.dedent_interpd + @_docstring.dedent_interpd def loglog(self, *args, **kwargs): """ - Make a plot with log scaling on both the x and y axis. + Make a plot with log scaling on both the x- and y-axis. Call signatures:: @@ -1728,7 +1787,7 @@ def loglog(self, *args, **kwargs): loglog([x], y, [fmt], [x2], y2, [fmt2], ..., **kwargs) This is just a thin wrapper around `.plot` which additionally changes - both the x-axis and the y-axis to log scaling. All of the concepts and + both the x-axis and the y-axis to log scaling. All the concepts and parameters of plot can be used here as well. The additional parameters *base*, *subs* and *nonpositive* control the @@ -1771,10 +1830,10 @@ def loglog(self, *args, **kwargs): *args, **{k: v for k, v in kwargs.items() if k not in {*dx, *dy}}) # @_preprocess_data() # let 'plot' do the unpacking.. - @docstring.dedent_interpd + @_docstring.dedent_interpd def semilogx(self, *args, **kwargs): """ - Make a plot with log scaling on the x axis. + Make a plot with log scaling on the x-axis. Call signatures:: @@ -1782,8 +1841,8 @@ def semilogx(self, *args, **kwargs): semilogx([x], y, [fmt], [x2], y2, [fmt2], ..., **kwargs) This is just a thin wrapper around `.plot` which additionally changes - the x-axis to log scaling. All of the concepts and parameters of plot - can be used here as well. + the x-axis to log scaling. All the concepts and parameters of plot can + be used here as well. The additional parameters *base*, *subs*, and *nonpositive* control the x-axis properties. They are just forwarded to `.Axes.set_xscale`. @@ -1818,10 +1877,10 @@ def semilogx(self, *args, **kwargs): *args, **{k: v for k, v in kwargs.items() if k not in d}) # @_preprocess_data() # let 'plot' do the unpacking.. - @docstring.dedent_interpd + @_docstring.dedent_interpd def semilogy(self, *args, **kwargs): """ - Make a plot with log scaling on the y axis. + Make a plot with log scaling on the y-axis. Call signatures:: @@ -1829,8 +1888,8 @@ def semilogy(self, *args, **kwargs): semilogy([x], y, [fmt], [x2], y2, [fmt2], ..., **kwargs) This is just a thin wrapper around `.plot` which additionally changes - the y-axis to log scaling. All of the concepts and parameters of plot - can be used here as well. + the y-axis to log scaling. All the concepts and parameters of plot can + be used here as well. The additional parameters *base*, *subs*, and *nonpositive* control the y-axis properties. They are just forwarded to `.Axes.set_yscale`. @@ -2146,14 +2205,22 @@ def _convert_dx(dx, x0, xconv, convert): # removes the units from unit packages like `pint` that # wrap numpy arrays. try: - x0 = cbook.safe_first_element(x0) + x0 = cbook._safe_first_finite(x0) except (TypeError, IndexError, KeyError): - x0 = x0 + pass + except StopIteration: + # this means we found no finite element, fall back to first + # element unconditionally + x0 = cbook.safe_first_element(x0) try: - x = cbook.safe_first_element(xconv) + x = cbook._safe_first_finite(xconv) except (TypeError, IndexError, KeyError): x = xconv + except StopIteration: + # this means we found no finite element, fall back to first + # element unconditionally + x = cbook.safe_first_element(xconv) delist = False if not np.iterable(dx): @@ -2169,7 +2236,7 @@ def _convert_dx(dx, x0, xconv, convert): return dx @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def bar(self, x, height, width=0.8, bottom=None, *, align="center", **kwargs): r""" @@ -2195,7 +2262,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", The width(s) of the bars. bottom : float or array-like, default: 0 - The y coordinate(s) of the bars bases. + The y coordinate(s) of the bottom side(s) of the bars. align : {'center', 'edge'}, default: 'center' Alignment of the bars to the *x* coordinates: @@ -2226,6 +2293,14 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", The tick labels of the bars. Default: None (Use default numeric labels.) + label : str or list of str, optional + A single label is attached to the resulting `.BarContainer` as a + label for the whole dataset. + If a list is provided, it must be the same length as *x* and + labels the individual bars. Repeated labels are not de-duplicated + and will cause repeated label entries, so this is best used when + bars also differ in style (e.g., by passing a list to *color*.) + xerr, yerr : float or array-like of shape(N,) or shape(2, N), optional If not *None*, add horizontal / vertical errorbars to the bar tips. The values are +/- sizes relative to the data: @@ -2237,8 +2312,8 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", errors. - *None*: No errorbar. (Default) - See :doc:`/gallery/statistics/errorbar_features` - for an example on the usage of ``xerr`` and ``yerr``. + See :doc:`/gallery/statistics/errorbar_features` for an example on + the usage of *xerr* and *yerr*. ecolor : color or list of color, default: 'black' The line color of the errorbars. @@ -2247,9 +2322,9 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", The length of the error bar caps in points. error_kw : dict, optional - Dictionary of kwargs to be passed to the `~.Axes.errorbar` - method. Values of *ecolor* or *capsize* defined here take - precedence over the independent kwargs. + Dictionary of keyword arguments to be passed to the + `~.Axes.errorbar` method. Values of *ecolor* or *capsize* defined + here take precedence over the independent keyword arguments. log : bool, default: False If *True*, set the y-axis to be log scale. @@ -2292,7 +2367,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", ezorder += 0.01 error_kw.setdefault('zorder', ezorder) ecolor = kwargs.pop('ecolor', 'k') - capsize = kwargs.pop('capsize', rcParams["errorbar.capsize"]) + capsize = kwargs.pop('capsize', mpl.rcParams["errorbar.capsize"]) error_kw.setdefault('ecolor', ecolor) error_kw.setdefault('capsize', capsize) @@ -2309,7 +2384,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", if orientation == 'vertical': if y is None: y = 0 - elif orientation == 'horizontal': + else: # horizontal if x is None: x = 0 @@ -2318,7 +2393,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", [("x", x), ("y", height)], kwargs, convert=False) if log: self.set_yscale('log', nonpositive='clip') - elif orientation == 'horizontal': + else: # horizontal self._process_unit_info( [("x", width), ("y", y)], kwargs, convert=False) if log: @@ -2347,10 +2422,20 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", if orientation == 'vertical': tick_label_axis = self.xaxis tick_label_position = x - elif orientation == 'horizontal': + else: # horizontal tick_label_axis = self.yaxis tick_label_position = y + if not isinstance(label, str) and np.iterable(label): + bar_container_label = '_nolegend_' + patch_labels = label + else: + bar_container_label = label + patch_labels = ['_nolegend_'] * len(x) + if len(patch_labels) != len(x): + raise ValueError(f'number of labels ({len(patch_labels)}) ' + f'does not match number of bars ({len(x)}).') + linewidth = itertools.cycle(np.atleast_1d(linewidth)) hatch = itertools.cycle(np.atleast_1d(hatch)) color = itertools.chain(itertools.cycle(mcolors.to_rgba_array(color)), @@ -2376,7 +2461,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", f'and width ({width.dtype}) ' f'are incompatible') from e bottom = y - elif orientation == 'horizontal': + else: # horizontal try: bottom = y - height / 2 except TypeError as e: @@ -2384,27 +2469,27 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", f'and height ({height.dtype}) ' f'are incompatible') from e left = x - elif align == 'edge': + else: # edge left = x bottom = y patches = [] args = zip(left, bottom, width, height, color, edgecolor, linewidth, - hatch) - for l, b, w, h, c, e, lw, htch in args: + hatch, patch_labels) + for l, b, w, h, c, e, lw, htch, lbl in args: r = mpatches.Rectangle( xy=(l, b), width=w, height=h, facecolor=c, edgecolor=e, linewidth=lw, - label='_nolegend_', + label=lbl, hatch=htch, ) - r.update(kwargs) + r._internal_update(kwargs) r.get_path()._interpolation_steps = 100 if orientation == 'vertical': r.sticky_edges.y.append(b) - elif orientation == 'horizontal': + else: # horizontal r.sticky_edges.x.append(l) self.add_patch(r) patches.append(r) @@ -2415,7 +2500,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", ex = [l + 0.5 * w for l, w in zip(left, width)] ey = [b + h for b, h in zip(bottom, height)] - elif orientation == 'horizontal': + else: # horizontal # using list comps rather than arrays to preserve unit info ex = [l + w for l, w in zip(left, width)] ey = [b + 0.5 * h for b, h in zip(bottom, height)] @@ -2432,11 +2517,12 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", if orientation == 'vertical': datavalues = height - elif orientation == 'horizontal': + else: # horizontal datavalues = width bar_container = BarContainer(patches, errorbar, datavalues=datavalues, - orientation=orientation, label=label) + orientation=orientation, + label=bar_container_label) self.add_container(bar_container) if tick_labels is not None: @@ -2446,9 +2532,10 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", return bar_container - @docstring.dedent_interpd + # @_preprocess_data() # let 'bar' do the unpacking.. + @_docstring.dedent_interpd def barh(self, y, width, height=0.8, left=None, *, align="center", - **kwargs): + data=None, **kwargs): r""" Make a horizontal bar plot. @@ -2472,7 +2559,7 @@ def barh(self, y, width, height=0.8, left=None, *, align="center", The heights of the bars. left : float or array-like, default: 0 - The x coordinates of the left sides of the bars. + The x coordinates of the left side(s) of the bars. align : {'center', 'edge'}, default: 'center' Alignment of the base to the *y* coordinates*: @@ -2504,9 +2591,17 @@ def barh(self, y, width, height=0.8, left=None, *, align="center", The tick labels of the bars. Default: None (Use default numeric labels.) + label : str or list of str, optional + A single label is attached to the resulting `.BarContainer` as a + label for the whole dataset. + If a list is provided, it must be the same length as *y* and + labels the individual bars. Repeated labels are not de-duplicated + and will cause repeated label entries, so this is best used when + bars also differ in style (e.g., by passing a list to *color*.) + xerr, yerr : float or array-like of shape(N,) or shape(2, N), optional - If not ``None``, add horizontal / vertical errorbars to the - bar tips. The values are +/- sizes relative to the data: + If not *None*, add horizontal / vertical errorbars to the bar tips. + The values are +/- sizes relative to the data: - scalar: symmetric +/- values for all bars - shape(N,): symmetric +/- values for each bar @@ -2515,8 +2610,8 @@ def barh(self, y, width, height=0.8, left=None, *, align="center", errors. - *None*: No errorbar. (default) - See :doc:`/gallery/statistics/errorbar_features` - for an example on the usage of ``xerr`` and ``yerr``. + See :doc:`/gallery/statistics/errorbar_features` for an example on + the usage of *xerr* and *yerr*. ecolor : color or list of color, default: 'black' The line color of the errorbars. @@ -2525,13 +2620,17 @@ def barh(self, y, width, height=0.8, left=None, *, align="center", The length of the error bar caps in points. error_kw : dict, optional - Dictionary of kwargs to be passed to the `~.Axes.errorbar` - method. Values of *ecolor* or *capsize* defined here take - precedence over the independent kwargs. + Dictionary of keyword arguments to be passed to the + `~.Axes.errorbar` method. Values of *ecolor* or *capsize* defined + here take precedence over the independent keyword arguments. log : bool, default: False If ``True``, set the x-axis to be log scale. + data : indexable object, optional + If given, all parameters also accept a string ``s``, which is + interpreted as ``data[s]`` (unless this raises an exception). + **kwargs : `.Rectangle` properties %(Rectangle:kwdoc)s @@ -2544,12 +2643,11 @@ def barh(self, y, width, height=0.8, left=None, *, align="center", ----- Stacked bars can be achieved by passing individual *left* values per bar. See - :doc:`/gallery/lines_bars_and_markers/horizontal_barchart_distribution` - . + :doc:`/gallery/lines_bars_and_markers/horizontal_barchart_distribution`. """ kwargs.setdefault('orientation', 'horizontal') patches = self.bar(x=left, height=height, width=width, bottom=y, - align=align, **kwargs) + align=align, data=data, **kwargs) return patches def bar_label(self, container, labels=None, *, fmt="%g", label_type="edge", @@ -2570,8 +2668,14 @@ def bar_label(self, container, labels=None, *, fmt="%g", label_type="edge", A list of label texts, that should be displayed. If not given, the label texts will be the data values formatted with *fmt*. - fmt : str, default: '%g' - A format string for the label. + fmt : str or callable, default: '%g' + An unnamed %-style or {}-style format string for the label or a + function to call with the value as the first argument. + When *fmt* is a string and can be interpreted in both formats, + %-style takes precedence over {}-style. + + .. versionadded:: 3.7 + Support for {}-style format string and callables. label_type : {'edge', 'center'}, default: 'edge' The label type. Possible values: @@ -2588,13 +2692,25 @@ def bar_label(self, container, labels=None, *, fmt="%g", label_type="edge", **kwargs Any remaining keyword arguments are passed through to - `.Axes.annotate`. + `.Axes.annotate`. The alignment parameters ( + *horizontalalignment* / *ha*, *verticalalignment* / *va*) are + not supported because the labels are automatically aligned to + the bars. Returns ------- list of `.Text` A list of `.Text` instances for the labels. """ + for key in ['horizontalalignment', 'ha', 'verticalalignment', 'va']: + if key in kwargs: + raise ValueError( + f"Passing {key!r} to bar_label() is not supported.") + + a, b = self.yaxis.get_view_interval() + y_inverted = a > b + c, d = self.xaxis.get_view_interval() + x_inverted = c > d # want to know whether to put label on positive or negative direction # cannot use np.sign here because it will return 0 if x == 0 @@ -2623,7 +2739,7 @@ def sign(x): annotations = [] for bar, err, dat, lbl in itertools.zip_longest( - bars, errs, datavalues, labels + bars, errs, datavalues, labels ): (x0, y0), (x1, y1) = bar.get_bbox().get_points() xc, yc = (x0 + x1) / 2, (y0 + y1) / 2 @@ -2631,48 +2747,70 @@ def sign(x): if orientation == "vertical": extrema = max(y0, y1) if dat >= 0 else min(y0, y1) length = abs(y0 - y1) - elif orientation == "horizontal": + else: # horizontal extrema = max(x0, x1) if dat >= 0 else min(x0, x1) length = abs(x0 - x1) - if err is None: + if err is None or np.size(err) == 0: endpt = extrema elif orientation == "vertical": endpt = err[:, 1].max() if dat >= 0 else err[:, 1].min() - elif orientation == "horizontal": + else: # horizontal endpt = err[:, 0].max() if dat >= 0 else err[:, 0].min() if label_type == "center": value = sign(dat) * length - elif label_type == "edge": + else: # edge value = extrema if label_type == "center": - xy = xc, yc - elif label_type == "edge" and orientation == "vertical": - xy = xc, endpt - elif label_type == "edge" and orientation == "horizontal": - xy = endpt, yc + xy = (0.5, 0.5) + kwargs["xycoords"] = ( + lambda r, b=bar: + mtransforms.Bbox.intersection( + b.get_window_extent(r), b.get_clip_box() + ) + ) + else: # edge + if orientation == "vertical": + xy = xc, endpt + else: # horizontal + xy = endpt, yc if orientation == "vertical": - xytext = 0, sign(dat) * padding - else: - xytext = sign(dat) * padding, 0 + y_direction = -1 if y_inverted else 1 + xytext = 0, y_direction * sign(dat) * padding + else: # horizontal + x_direction = -1 if x_inverted else 1 + xytext = x_direction * sign(dat) * padding, 0 if label_type == "center": ha, va = "center", "center" - elif label_type == "edge": + else: # edge if orientation == "vertical": ha = 'center' - va = 'top' if dat < 0 else 'bottom' # also handles NaN - elif orientation == "horizontal": - ha = 'right' if dat < 0 else 'left' # also handles NaN + if y_inverted: + va = 'top' if dat > 0 else 'bottom' # also handles NaN + else: + va = 'top' if dat < 0 else 'bottom' # also handles NaN + else: # horizontal + if x_inverted: + ha = 'right' if dat > 0 else 'left' # also handles NaN + else: + ha = 'right' if dat < 0 else 'left' # also handles NaN va = 'center' if np.isnan(dat): lbl = '' - annotation = self.annotate(fmt % value if lbl is None else lbl, + if lbl is None: + if isinstance(fmt, str): + lbl = cbook._auto_format_str(fmt, value) + elif callable(fmt): + lbl = fmt(value) + else: + raise TypeError("fmt must be a str or callable") + annotation = self.annotate(lbl, xy, xytext, textcoords="offset points", ha=ha, va=va, **kwargs) annotations.append(annotation) @@ -2680,7 +2818,7 @@ def sign(x): return annotations @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def broken_barh(self, xranges, yrange, **kwargs): """ Plot a horizontal sequence of rectangles. @@ -2688,10 +2826,6 @@ def broken_barh(self, xranges, yrange, **kwargs): A rectangle is drawn for each element of *xranges*. All rectangles have the same vertical position and size defined by *yrange*. - This is a convenience function for instantiating a - `.BrokenBarHCollection`, adding it to the Axes and autoscaling the - view. - Parameters ---------- xranges : sequence of tuples (*xmin*, *xwidth*) @@ -2703,13 +2837,13 @@ def broken_barh(self, xranges, yrange, **kwargs): Returns ------- - `~.collections.BrokenBarHCollection` + `~.collections.PolyCollection` Other Parameters ---------------- data : indexable object, optional DATA_PARAMETER_PLACEHOLDER - **kwargs : `.BrokenBarHCollection` properties + **kwargs : `.PolyCollection` properties Each *kwarg* can be either a single argument applying to all rectangles, e.g.:: @@ -2724,38 +2858,35 @@ def broken_barh(self, xranges, yrange, **kwargs): Supported keywords: - %(BrokenBarHCollection:kwdoc)s + %(PolyCollection:kwdoc)s """ # process the unit information - if len(xranges): - xdata = cbook.safe_first_element(xranges) - else: - xdata = None - if len(yrange): - ydata = cbook.safe_first_element(yrange) - else: - ydata = None + xdata = cbook._safe_first_finite(xranges) if len(xranges) else None + ydata = cbook._safe_first_finite(yrange) if len(yrange) else None self._process_unit_info( [("x", xdata), ("y", ydata)], kwargs, convert=False) - xranges_conv = [] - for xr in xranges: - if len(xr) != 2: - raise ValueError('each range in xrange must be a sequence ' - 'with two elements (i.e. an Nx2 array)') - # convert the absolute values, not the x and dx... - x_conv = np.asarray(self.convert_xunits(xr[0])) - x1 = self._convert_dx(xr[1], xr[0], x_conv, self.convert_xunits) - xranges_conv.append((x_conv, x1)) - - yrange_conv = self.convert_yunits(yrange) - - col = mcoll.BrokenBarHCollection(xranges_conv, yrange_conv, **kwargs) + + vertices = [] + y0, dy = yrange + y0, y1 = self.convert_yunits((y0, y0 + dy)) + for xr in xranges: # convert the absolute values, not the x and dx + try: + x0, dx = xr + except Exception: + raise ValueError( + "each range in xrange must be a sequence with two " + "elements (i.e. xrange must be an (N, 2) array)") from None + x0, x1 = self.convert_xunits((x0, x0 + dx)) + vertices.append([(x0, y0), (x0, y1), (x1, y1), (x1, y0)]) + + col = mcoll.PolyCollection(np.array(vertices), **kwargs) self.add_collection(col, autolim=True) self._request_autoscale_view() return col @_preprocess_data() + @_api.delete_parameter("3.6", "use_line_collection") def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, label=None, use_line_collection=True, orientation='vertical'): """ @@ -2771,8 +2902,9 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, stem([locs,] heads, linefmt=None, markerfmt=None, basefmt=None) - The *locs*-positions are optional. The formats may be provided either - as positional or as keyword-arguments. + The *locs*-positions are optional. *linefmt* may be provided as + positional, but all other formats must be provided as keyword + arguments. Parameters ---------- @@ -2805,15 +2937,15 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, markerfmt : str, optional A string defining the color and/or shape of the markers at the stem - heads. Default: 'C0o', i.e. filled circles with the first color of - the color cycle. + heads. If the marker is not given, use the marker 'o', i.e. filled + circles. If the color is not given, use the color from *linefmt*. basefmt : str, default: 'C3-' ('C2-' in classic mode) A format string defining the properties of the baseline. - orientation : str, default: 'vertical' + orientation : {'vertical', 'horizontal'}, default: 'vertical' If 'vertical', will produce a plot with stems oriented vertically, - otherwise the stems will be oriented horizontally. + If 'horizontal', the stems will be oriented horizontally. bottom : float, default: 0 The y/x-position of the baseline (depending on orientation). @@ -2822,11 +2954,12 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, The label to use for the stems in legends. use_line_collection : bool, default: True + *Deprecated since 3.6* + If ``True``, store and plot the stem lines as a `~.collections.LineCollection` instead of individual lines, which significantly increases performance. If ``False``, defaults to the - old behavior of using a list of `.Line2D` objects. This parameter - may be deprecated in the future. + old behavior of using a list of `.Line2D` objects. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -2844,8 +2977,8 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, `stem `_ which inspired this method. """ - if not 1 <= len(args) <= 5: - raise TypeError('stem expected between 1 and 5 positional ' + if not 1 <= len(args) <= 3: + raise TypeError('stem expected between 1 or 3 positional ' 'arguments, got {}'.format(args)) _api.check_in_list(['horizontal', 'vertical'], orientation=orientation) @@ -2853,39 +2986,47 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, heads, = args locs = np.arange(len(heads)) args = () + elif isinstance(args[1], str): + heads, *args = args + locs = np.arange(len(heads)) else: locs, heads, *args = args - if args: - _api.warn_deprecated( - "3.5", - message="Passing the linefmt parameter positionally is " - "deprecated since Matplotlib %(since)s; the " - "parameter will become keyword-only %(removal)s.") if orientation == 'vertical': locs, heads = self._process_unit_info([("x", locs), ("y", heads)]) - else: + else: # horizontal heads, locs = self._process_unit_info([("x", heads), ("y", locs)]) - # defaults for formats + # resolve line format if linefmt is None: linefmt = args[0] if len(args) > 0 else "C0-" linestyle, linemarker, linecolor = _process_plot_format(linefmt) + # resolve marker format if markerfmt is None: - markerfmt = args[1] if len(args) > 1 else "C0o" + # if not given as kwarg, fall back to 'o' + markerfmt = "o" + if markerfmt == '': + markerfmt = ' ' # = empty line style; '' would resolve rcParams markerstyle, markermarker, markercolor = \ _process_plot_format(markerfmt) - + if markermarker is None: + markermarker = 'o' + if markerstyle is None: + markerstyle = 'None' + if markercolor is None: + markercolor = linecolor + + # resolve baseline format if basefmt is None: - basefmt = (args[2] if len(args) > 2 else - "C2-" if rcParams["_internal.classic_mode"] else "C3-") + basefmt = ("C2-" if mpl.rcParams["_internal.classic_mode"] else + "C3-") basestyle, basemarker, basecolor = _process_plot_format(basefmt) # New behaviour in 3.1 is to use a LineCollection for the stemlines if use_line_collection: if linestyle is None: - linestyle = rcParams['lines.linestyle'] + linestyle = mpl.rcParams['lines.linestyle'] xlines = self.vlines if orientation == "vertical" else self.hlines stemlines = xlines( locs, bottom, heads, @@ -2934,7 +3075,7 @@ def pie(self, x, explode=None, labels=None, colors=None, autopct=None, pctdistance=0.6, shadow=False, labeldistance=1.1, startangle=0, radius=1, counterclock=True, wedgeprops=None, textprops=None, center=(0, 0), - frame=False, rotatelabels=False, *, normalize=True): + frame=False, rotatelabels=False, *, normalize=True, hatch=None): """ Plot a pie chart. @@ -2960,29 +3101,34 @@ def pie(self, x, explode=None, labels=None, colors=None, A sequence of colors through which the pie chart will cycle. If *None*, will use the colors in the currently active cycle. + hatch : str or list, default: None + Hatching pattern applied to all pie wedges or sequence of patterns + through which the chart will cycle. For a list of valid patterns, + see :doc:`/gallery/shapes_and_collections/hatch_style_reference`. + + .. versionadded:: 3.7 + autopct : None or str or callable, default: None - If not *None*, is a string or function used to label the wedges - with their numeric value. The label will be placed inside the - wedge. If it is a format string, the label will be ``fmt % pct``. - If it is a function, it will be called. + If not *None*, *autopct* is a string or function used to label the + wedges with their numeric value. The label will be placed inside + the wedge. If *autopct* is a format string, the label will be + ``fmt % pct``. If *autopct* is a function, then it will be called. pctdistance : float, default: 0.6 - The ratio between the center of each pie slice and the start of - the text generated by *autopct*. Ignored if *autopct* is *None*. + The relative distance along the radius at which the the text + generated by *autopct* is drawn. To draw the text outside the pie, + set *pctdistance* > 1. This parameter is ignored if *autopct* is + ``None``. + + labeldistance : float or None, default: 1.1 + The relative distance along the radius at which the labels are + drawn. To draw the labels inside the pie, set *labeldistance* < 1. + If set to ``None``, labels are not drawn but are still stored for + use in `.legend`. shadow : bool, default: False Draw a shadow beneath the pie. - normalize : bool, default: True - When *True*, always make a full pie by normalizing x so that - ``sum(x) == 1``. *False* makes a partial pie if ``sum(x) <= 1`` - and raises a `ValueError` for ``sum(x) > 1``. - - labeldistance : float or None, default: 1.1 - The radial distance at which the pie labels are drawn. - If set to ``None``, label are not drawn, but are stored for use in - ``legend()`` - startangle : float, default: 0 degrees The angle by which the start of the pie is rotated, counterclockwise from the x-axis. @@ -2994,11 +3140,11 @@ def pie(self, x, explode=None, labels=None, colors=None, Specify fractions direction, clockwise or counterclockwise. wedgeprops : dict, default: None - Dict of arguments passed to the wedge objects making the pie. - For example, you can pass in ``wedgeprops = {'linewidth': 3}`` - to set the width of the wedge border lines equal to 3. - For more details, look at the doc/arguments of the wedge object. - By default ``clip_on=False``. + Dict of arguments passed to each `.patches.Wedge` of the pie. + For example, ``wedgeprops = {'linewidth': 3}`` sets the width of + the wedge border lines equal to 3. By default, ``clip_on=False``. + When there is a conflict between these properties and other + keywords, properties passed to *wedgeprops* take precedence. textprops : dict, default: None Dict of arguments to pass to the text objects. @@ -3012,6 +3158,11 @@ def pie(self, x, explode=None, labels=None, colors=None, rotatelabels : bool, default: False Rotate each label to the angle of the corresponding slice if true. + normalize : bool, default: True + When *True*, always make a full pie by normalizing x so that + ``sum(x) == 1``. *False* makes a partial pie if ``sum(x) <= 1`` + and raises a `ValueError` for ``sum(x) > 1``. + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -3066,6 +3217,8 @@ def pie(self, x, explode=None, labels=None, colors=None, def get_next_color(): return next(color_cycle) + hatch_cycle = itertools.cycle(np.atleast_1d(hatch)) + _api.check_isinstance(Number, radius=radius, startangle=startangle) if radius <= 0: raise ValueError(f'radius must be a positive number, not {radius}') @@ -3092,6 +3245,7 @@ def get_next_color(): w = mpatches.Wedge((x, y), radius, 360. * min(theta1, theta2), 360. * max(theta1, theta2), facecolor=get_next_color(), + hatch=next(hatch_cycle), clip_on=False, label=label) w.set(**wedgeprops) @@ -3119,7 +3273,7 @@ def get_next_color(): horizontalalignment=label_alignment_h, verticalalignment=label_alignment_v, rotation=label_rotation, - size=rcParams['xtick.labelsize']) + size=mpl.rcParams['xtick.labelsize']) t.set(**textprops) texts.append(t) @@ -3154,9 +3308,41 @@ def get_next_color(): else: return slices, texts, autotexts + @staticmethod + def _errorevery_to_mask(x, errorevery): + """ + Normalize `errorbar`'s *errorevery* to be a boolean mask for data *x*. + + This function is split out to be usable both by 2D and 3D errorbars. + """ + if isinstance(errorevery, Integral): + errorevery = (0, errorevery) + if isinstance(errorevery, tuple): + if (len(errorevery) == 2 and + isinstance(errorevery[0], Integral) and + isinstance(errorevery[1], Integral)): + errorevery = slice(errorevery[0], None, errorevery[1]) + else: + raise ValueError( + f'{errorevery=!r} is a not a tuple of two integers') + elif isinstance(errorevery, slice): + pass + elif not isinstance(errorevery, str) and np.iterable(errorevery): + try: + x[errorevery] # fancy indexing + except (ValueError, IndexError) as err: + raise ValueError( + f"{errorevery=!r} is iterable but not a valid NumPy fancy " + "index to match 'xerr'/'yerr'") from err + else: + raise ValueError(f"{errorevery=!r} is not a recognized value") + everymask = np.zeros(len(x), bool) + everymask[errorevery] = True + return everymask + @_preprocess_data(replace_names=["x", "y", "xerr", "yerr"], label_namer="y") - @docstring.dedent_interpd + @_docstring.dedent_interpd def errorbar(self, x, y, yerr=None, xerr=None, fmt='', ecolor=None, elinewidth=None, capsize=None, barsabove=False, lolims=False, uplims=False, @@ -3169,6 +3355,10 @@ def errorbar(self, x, y, yerr=None, xerr=None, sizes. By default, this draws the data markers/lines as well the errorbars. Use fmt='none' to draw errorbars without any data markers. + .. versionadded:: 3.7 + Caps and error lines are drawn in polar coordinates on polar plots. + + Parameters ---------- x, y : float or array-like @@ -3184,7 +3374,7 @@ def errorbar(self, x, y, yerr=None, xerr=None, errors. - *None*: No errorbar. - Note that all error arrays should have *positive* values. + All values must be >= 0. See :doc:`/gallery/statistics/errorbar_features` for an example on the usage of ``xerr`` and ``yerr``. @@ -3193,7 +3383,7 @@ def errorbar(self, x, y, yerr=None, xerr=None, The format for the data points / data lines. See `.plot` for details. - Use 'none' (case insensitive) to plot errorbars without any data + Use 'none' (case-insensitive) to plot errorbars without any data markers. ecolor : color, default: None @@ -3266,7 +3456,25 @@ def errorbar(self, x, y, yerr=None, xerr=None, property names, *markerfacecolor*, *markeredgecolor*, *markersize* and *markeredgewidth*. - Valid kwargs for the marker properties are `.Line2D` properties: + Valid kwargs for the marker properties are: + + - *dashes* + - *dash_capstyle* + - *dash_joinstyle* + - *drawstyle* + - *fillstyle* + - *linestyle* + - *marker* + - *markeredgecolor* + - *markeredgewidth* + - *markerfacecolor* + - *markerfacecoloralt* + - *markersize* + - *markevery* + - *solid_capstyle* + - *solid_joinstyle* + + Refer to the corresponding `.Line2D` property for more details: %(Line2D:kwdoc)s """ @@ -3305,10 +3513,10 @@ def _upcast_err(err): # safe_first_element because getitem is index-first not # location first on pandas objects so err[0] almost always # fails. - isinstance(cbook.safe_first_element(err), np.ndarray) + isinstance(cbook._safe_first_finite(err), np.ndarray) ): # Get the type of the first element - atype = type(cbook.safe_first_element(err)) + atype = type(cbook._safe_first_finite(err)) # Promote the outer container to match the inner container if atype is np.ndarray: # Converts using np.asarray, because data cannot @@ -3328,32 +3536,7 @@ def _upcast_err(err): if len(x) != len(y): raise ValueError("'x' and 'y' must have the same size") - if isinstance(errorevery, Integral): - errorevery = (0, errorevery) - if isinstance(errorevery, tuple): - if (len(errorevery) == 2 and - isinstance(errorevery[0], Integral) and - isinstance(errorevery[1], Integral)): - errorevery = slice(errorevery[0], None, errorevery[1]) - else: - raise ValueError( - f'errorevery={errorevery!r} is a not a tuple of two ' - f'integers') - elif isinstance(errorevery, slice): - pass - elif not isinstance(errorevery, str) and np.iterable(errorevery): - # fancy indexing - try: - x[errorevery] - except (ValueError, IndexError) as err: - raise ValueError( - f"errorevery={errorevery!r} is iterable but not a valid " - f"NumPy fancy index to match 'xerr'/'yerr'") from err - else: - raise ValueError( - f"errorevery={errorevery!r} is not a recognized value") - everymask = np.zeros(len(x), bool) - everymask[errorevery] = True + everymask = self._errorevery_to_mask(x, errorevery) label = kwargs.pop("label", None) kwargs['label'] = '_nolegend_' @@ -3391,6 +3574,7 @@ def _upcast_err(err): # Eject any line-specific information from format string, as it's not # needed for bars or caps. for key in ['marker', 'markersize', 'markerfacecolor', + 'markerfacecoloralt', 'markeredgewidth', 'markeredgecolor', 'markevery', 'linestyle', 'fillstyle', 'drawstyle', 'dash_capstyle', 'dash_joinstyle', 'solid_capstyle', 'solid_joinstyle', @@ -3412,7 +3596,7 @@ def _upcast_err(err): # Make the style dict for caps (the "hats"). eb_cap_style = {**base_style, 'linestyle': 'none'} if capsize is None: - capsize = rcParams["errorbar.capsize"] + capsize = mpl.rcParams["errorbar.capsize"] if capsize > 0: eb_cap_style['markersize'] = 2. * capsize if capthick is not None: @@ -3427,10 +3611,11 @@ def _upcast_err(err): eb_cap_style['color'] = ecolor barcols = [] - caplines = [] + caplines = {'x': [], 'y': []} # Vectorized fancy-indexer. - def apply_mask(arrays, mask): return [array[mask] for array in arrays] + def apply_mask(arrays, mask): + return [array[mask] for array in arrays] # dep: dependent dataset, indep: independent dataset for (dep_axis, dep, err, lolims, uplims, indep, lines_func, @@ -3451,14 +3636,22 @@ def apply_mask(arrays, mask): return [array[mask] for array in arrays] f"'{dep_axis}err' (shape: {np.shape(err)}) must be a " f"scalar or a 1D or (2, n) array-like whose shape matches " f"'{dep_axis}' (shape: {np.shape(dep)})") from None + res = np.zeros(err.shape, dtype=bool) # Default in case of nan + if np.any(np.less(err, -err, out=res, where=(err == err))): + # like err<0, but also works for timedelta and nan. + raise ValueError( + f"'{dep_axis}err' must not contain negative values") # This is like # elow, ehigh = np.broadcast_to(...) # return dep - elow * ~lolims, dep + ehigh * ~uplims # except that broadcast_to would strip units. low, high = dep + np.row_stack([-(1 - lolims), 1 - uplims]) * err - barcols.append(lines_func( *apply_mask([indep, low, high], everymask), **eb_lines_style)) + if self.name == "polar" and dep_axis == "x": + for b in barcols: + for p in b.get_paths(): + p._interpolation_steps = 2 # Normal errorbars for points without upper/lower limits. nolims = ~(lolims | uplims) if nolims.any() and capsize > 0: @@ -3471,7 +3664,7 @@ def apply_mask(arrays, mask): return [array[mask] for array in arrays] line = mlines.Line2D(indep_masked, indep_masked, marker=marker, **eb_cap_style) line.set(**{f"{dep_axis}data": lh_masked}) - caplines.append(line) + caplines[dep_axis].append(line) for idx, (lims, hl) in enumerate([(lolims, high), (uplims, low)]): if not lims.any(): continue @@ -3485,15 +3678,29 @@ def apply_mask(arrays, mask): return [array[mask] for array in arrays] line = mlines.Line2D(x_masked, y_masked, marker=hlmarker, **eb_cap_style) line.set(**{f"{dep_axis}data": hl_masked}) - caplines.append(line) + caplines[dep_axis].append(line) if capsize > 0: - caplines.append(mlines.Line2D( + caplines[dep_axis].append(mlines.Line2D( x_masked, y_masked, marker=marker, **eb_cap_style)) - - for l in caplines: - self.add_line(l) + if self.name == 'polar': + for axis in caplines: + for l in caplines[axis]: + # Rotate caps to be perpendicular to the error bars + for theta, r in zip(l.get_xdata(), l.get_ydata()): + rotation = mtransforms.Affine2D().rotate(theta) + if axis == 'y': + rotation.rotate(-np.pi / 2) + ms = mmarkers.MarkerStyle(marker=marker, + transform=rotation) + self.add_line(mlines.Line2D([theta], [r], marker=ms, + **eb_cap_style)) + else: + for axis in caplines: + for l in caplines[axis]: + self.add_line(l) self._request_autoscale_view() + caplines = caplines['x'] + caplines['y'] errorbar_container = ErrorbarContainer( (data_line, tuple(caplines), tuple(barcols)), has_xerr=(xerr is not None), has_yerr=(yerr is not None), @@ -3510,7 +3717,8 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, showbox=None, showfliers=None, boxprops=None, labels=None, flierprops=None, medianprops=None, meanprops=None, capprops=None, whiskerprops=None, - manage_ticks=True, autorange=False, zorder=None): + manage_ticks=True, autorange=False, zorder=None, + capwidths=None): """ Draw a box and whisker plot. @@ -3677,6 +3885,8 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, Show the arithmetic means. capprops : dict, default: None The style of the caps. + capwidths : float or array, default: None + The widths of the caps. boxprops : dict, default: None The style of the box. whiskerprops : dict, default: None @@ -3697,28 +3907,28 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, # Missing arguments default to rcParams. if whis is None: - whis = rcParams['boxplot.whiskers'] + whis = mpl.rcParams['boxplot.whiskers'] if bootstrap is None: - bootstrap = rcParams['boxplot.bootstrap'] + bootstrap = mpl.rcParams['boxplot.bootstrap'] bxpstats = cbook.boxplot_stats(x, whis=whis, bootstrap=bootstrap, labels=labels, autorange=autorange) if notch is None: - notch = rcParams['boxplot.notch'] + notch = mpl.rcParams['boxplot.notch'] if vert is None: - vert = rcParams['boxplot.vertical'] + vert = mpl.rcParams['boxplot.vertical'] if patch_artist is None: - patch_artist = rcParams['boxplot.patchartist'] + patch_artist = mpl.rcParams['boxplot.patchartist'] if meanline is None: - meanline = rcParams['boxplot.meanline'] + meanline = mpl.rcParams['boxplot.meanline'] if showmeans is None: - showmeans = rcParams['boxplot.showmeans'] + showmeans = mpl.rcParams['boxplot.showmeans'] if showcaps is None: - showcaps = rcParams['boxplot.showcaps'] + showcaps = mpl.rcParams['boxplot.showcaps'] if showbox is None: - showbox = rcParams['boxplot.showbox'] + showbox = mpl.rcParams['boxplot.showbox'] if showfliers is None: - showfliers = rcParams['boxplot.showfliers'] + showfliers = mpl.rcParams['boxplot.showfliers'] if boxprops is None: boxprops = {} @@ -3805,7 +4015,8 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, medianprops=medianprops, meanprops=meanprops, meanline=meanline, showfliers=showfliers, capprops=capprops, whiskerprops=whiskerprops, - manage_ticks=manage_ticks, zorder=zorder) + manage_ticks=manage_ticks, zorder=zorder, + capwidths=capwidths) return artists def bxp(self, bxpstats, positions=None, widths=None, vert=True, @@ -3813,7 +4024,8 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, showcaps=True, showbox=True, showfliers=True, boxprops=None, whiskerprops=None, flierprops=None, medianprops=None, capprops=None, meanprops=None, - meanline=False, manage_ticks=True, zorder=None): + meanline=False, manage_ticks=True, zorder=None, + capwidths=None): """ Drawing function for box and whisker plots. @@ -3851,6 +4063,10 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, The widths of the boxes. The default is ``clip(0.15*(distance between extreme positions), 0.15, 0.5)``. + capwidths : float or array-like, default: None + Either a scalar or a vector and sets the width of each cap. + The default is ``0.5*(with of the box)``, see *widths*. + vert : bool, default: True If `True` (default), makes the boxes vertical. If `False`, makes horizontal boxes. @@ -3920,7 +4136,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, zdelta = 0.1 def merge_kw_rc(subkey, explicit, zdelta=0, usemarker=True): - d = {k.split('.')[-1]: v for k, v in rcParams.items() + d = {k.split('.')[-1]: v for k, v in mpl.rcParams.items() if k.startswith(f'boxplot.{subkey}props')} d['zorder'] = zorder + zdelta if not usemarker: @@ -3929,11 +4145,11 @@ def merge_kw_rc(subkey, explicit, zdelta=0, usemarker=True): return d box_kw = { - 'linestyle': rcParams['boxplot.boxprops.linestyle'], - 'linewidth': rcParams['boxplot.boxprops.linewidth'], - 'edgecolor': rcParams['boxplot.boxprops.color'], - 'facecolor': ('white' if rcParams['_internal.classic_mode'] - else rcParams['patch.facecolor']), + 'linestyle': mpl.rcParams['boxplot.boxprops.linestyle'], + 'linewidth': mpl.rcParams['boxplot.boxprops.linewidth'], + 'edgecolor': mpl.rcParams['boxplot.boxprops.color'], + 'facecolor': ('white' if mpl.rcParams['_internal.classic_mode'] + else mpl.rcParams['patch.facecolor']), 'zorder': zorder, **cbook.normalize_kwargs(boxprops, mpatches.PathPatch) } if patch_artist else merge_kw_rc('box', boxprops, usemarker=False) @@ -3954,9 +4170,8 @@ def do_plot(xs, ys, **kwargs): return self.plot(*[xs, ys][maybe_swap], **kwargs)[0] def do_patch(xs, ys, **kwargs): - path = mpath.Path( - # Last (0, 0) vertex has a CLOSEPOLY code and is thus ignored. - np.column_stack([[*xs, 0], [*ys, 0]][maybe_swap]), closed=True) + path = mpath.Path._create_closed( + np.column_stack([xs, ys][maybe_swap])) patch = mpatches.PathPatch(path, **kwargs) self.add_artist(patch) return patch @@ -3983,7 +4198,16 @@ def do_patch(xs, ys, **kwargs): elif len(widths) != N: raise ValueError(datashape_message.format("widths")) - for pos, width, stats in zip(positions, widths, bxpstats): + # capwidth + if capwidths is None: + capwidths = 0.5 * np.array(widths) + elif np.isscalar(capwidths): + capwidths = [capwidths] * N + elif len(capwidths) != N: + raise ValueError(datashape_message.format("capwidths")) + + for pos, width, stats, capwidth in zip(positions, widths, bxpstats, + capwidths): # try to find a new label datalabels.append(stats.get('label', pos)) @@ -3992,8 +4216,8 @@ def do_patch(xs, ys, **kwargs): whislo_y = [stats['q1'], stats['whislo']] whishi_y = [stats['q3'], stats['whishi']] # cap coords - cap_left = pos - width * 0.25 - cap_right = pos + width * 0.25 + cap_left = pos - capwidth * 0.5 + cap_right = pos + capwidth * 0.5 cap_x = [cap_left, cap_right] cap_lo = np.full(2, stats['whislo']) cap_hi = np.full(2, stats['whishi']) @@ -4003,14 +4227,16 @@ def do_patch(xs, ys, **kwargs): med_y = [stats['med'], stats['med']] # notched boxes if shownotches: - box_x = [box_left, box_right, box_right, cap_right, box_right, - box_right, box_left, box_left, cap_left, box_left, - box_left] + notch_left = pos - width * 0.25 + notch_right = pos + width * 0.25 + box_x = [box_left, box_right, box_right, notch_right, + box_right, box_right, box_left, box_left, notch_left, + box_left, box_left] box_y = [stats['q1'], stats['q1'], stats['cilo'], stats['med'], stats['cihi'], stats['q3'], stats['q3'], stats['cihi'], stats['med'], stats['cilo'], stats['q1']] - med_x = cap_x + med_x = [notch_left, notch_right] # plain boxes else: box_x = [box_left, box_right, box_right, box_left, box_left] @@ -4076,8 +4302,7 @@ def do_patch(xs, ys, **kwargs): axis.set_major_formatter(formatter) formatter.seq = [*formatter.seq, *datalabels] - self._request_autoscale_view( - scalex=self._autoscaleXon, scaley=self._autoscaleYon) + self._request_autoscale_view() return dict(whiskers=whiskers, caps=caps, boxes=boxes, medians=medians, fliers=fliers, means=means) @@ -4161,18 +4386,18 @@ def _parse_scatter_color_args(c, edgecolors, kwargs, xsize, if facecolors is None: facecolors = kwcolor - if edgecolors is None and not rcParams['_internal.classic_mode']: - edgecolors = rcParams['scatter.edgecolors'] + if edgecolors is None and not mpl.rcParams['_internal.classic_mode']: + edgecolors = mpl.rcParams['scatter.edgecolors'] c_was_none = c is None if c is None: c = (facecolors if facecolors is not None - else "b" if rcParams['_internal.classic_mode'] + else "b" if mpl.rcParams['_internal.classic_mode'] else get_next_color_func()) c_is_string_or_strings = ( isinstance(c, str) or (np.iterable(c) and len(c) > 0 - and isinstance(cbook.safe_first_element(c), str))) + and isinstance(cbook._safe_first_finite(c), str))) def invalid_shape_exception(csize, xsize): return ValueError( @@ -4193,14 +4418,14 @@ def invalid_shape_exception(csize, xsize): c_is_mapped = False if c.size != xsize: valid_shape = False - # If c can be either mapped values or a RGB(A) color, prefer + # If c can be either mapped values or an RGB(A) color, prefer # the former if shapes match, the latter otherwise. elif c.size == xsize: c = c.ravel() c_is_mapped = True else: # Wrong size; it must not be intended for mapping. if c.shape in ((3,), (4,)): - _log.warning( + _api.warn_external( "*c* argument looks like a single numeric RGB or " "RGBA sequence, which should be avoided as value-" "mapping will have precedence in case its length " @@ -4222,7 +4447,7 @@ def invalid_shape_exception(csize, xsize): # severe failure => one may appreciate a verbose feedback. raise ValueError( f"'c' argument must be a color, a sequence of colors, " - f"or a sequence of numbers, not {c}") from err + f"or a sequence of numbers, not {c!r}") from err else: if len(colors) not in (0, 1, xsize): # NB: remember that a single color is also acceptable. @@ -4236,6 +4461,7 @@ def invalid_shape_exception(csize, xsize): "edgecolors", "c", "facecolor", "facecolors", "color"], label_namer="y") + @_docstring.interpd def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, vmin=None, vmax=None, alpha=None, linewidths=None, *, edgecolors=None, plotnonfinite=False, **kwargs): @@ -4248,7 +4474,7 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, The data positions. s : float or array-like, shape (n, ), optional - The marker size in points**2. + The marker size in points**2 (typographic points are 1/72 in.). Default is ``rcParams['lines.markersize'] ** 2``. c : array-like or list of colors or color, optional @@ -4263,9 +4489,9 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, Note that *c* should not be a single numeric RGB or RGBA sequence because that is indistinguishable from an array of values to be colormapped. If you want to specify the same RGB or RGBA value for - all points, use a 2D array with a single row. Otherwise, value- - matching will have precedence in case of a size matching with *x* - and *y*. + all points, use a 2D array with a single row. Otherwise, + value-matching will have precedence in case of a size matching with + *x* and *y*. If you wish to specify a single color for all points prefer the *color* keyword argument. @@ -4282,21 +4508,17 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, See :mod:`matplotlib.markers` for more information about marker styles. - cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - A `.Colormap` instance or registered colormap name. *cmap* is only - used if *c* is an array of floats. + %(cmap_doc)s + + This parameter is ignored if *c* is RGB(A). + + %(norm_doc)s + + This parameter is ignored if *c* is RGB(A). - norm : `~matplotlib.colors.Normalize`, default: None - If *c* is an array of floats, *norm* is used to scale the color - data, *c*, in the range 0 to 1, in order to map into the colormap - *cmap*. - If *None*, use the default `.colors.Normalize`. + %(vmin_vmax_doc)s - vmin, vmax : float, default: None - *vmin* and *vmax* are used in conjunction with the default norm to - map the color array *c* to the colormap *cmap*. If None, the - respective min and max of the color array is used. - It is an error to use *vmin*/*vmax* when *norm* is given. + This parameter is ignored if *c* is RGB(A). alpha : float, default: None The alpha blending value, between 0 (transparent) and 1 (opaque). @@ -4362,8 +4584,8 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, raise ValueError("x and y must be the same size") if s is None: - s = (20 if rcParams['_internal.classic_mode'] else - rcParams['lines.markersize'] ** 2.0) + s = (20 if mpl.rcParams['_internal.classic_mode'] else + mpl.rcParams['lines.markersize'] ** 2.0) s = np.ma.ravel(s) if (len(s) not in (1, x.size) or (not np.issubdtype(s.dtype, np.floating) and @@ -4399,7 +4621,7 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, # load default marker from rcParams if marker is None: - marker = rcParams['scatter.marker'] + marker = mpl.rcParams['scatter.marker'] if isinstance(marker, mmarkers.MarkerStyle): marker_obj = marker @@ -4440,38 +4662,47 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, edgecolors = 'face' if linewidths is None: - linewidths = rcParams['lines.linewidth'] + linewidths = mpl.rcParams['lines.linewidth'] elif np.iterable(linewidths): linewidths = [ - lw if lw is not None else rcParams['lines.linewidth'] + lw if lw is not None else mpl.rcParams['lines.linewidth'] for lw in linewidths] offsets = np.ma.column_stack([x, y]) collection = mcoll.PathCollection( - (path,), scales, - facecolors=colors, - edgecolors=edgecolors, - linewidths=linewidths, - offsets=offsets, - transOffset=kwargs.pop('transform', self.transData), - alpha=alpha - ) + (path,), scales, + facecolors=colors, + edgecolors=edgecolors, + linewidths=linewidths, + offsets=offsets, + offset_transform=kwargs.pop('transform', self.transData), + alpha=alpha, + ) collection.set_transform(mtransforms.IdentityTransform()) - collection.update(kwargs) - if colors is None: collection.set_array(c) collection.set_cmap(cmap) collection.set_norm(norm) collection._scale_norm(norm, vmin, vmax) + else: + extra_kwargs = { + 'cmap': cmap, 'norm': norm, 'vmin': vmin, 'vmax': vmax + } + extra_keys = [k for k, v in extra_kwargs.items() if v is not None] + if any(extra_keys): + keys_str = ", ".join(f"'{k}'" for k in extra_keys) + _api.warn_external( + "No data for colormapping provided via 'c'. " + f"Parameters {keys_str} will be ignored") + collection._internal_update(kwargs) # Classic mode only: # ensure there are margins to allow for the # finite size of the symbols. In v2.x, margins # are present by default, so we disable this # scatter-specific override. - if rcParams['_internal.classic_mode']: + if mpl.rcParams['_internal.classic_mode']: if self._xmargin < 0.05 and x.size > 0: self.set_xmargin(0.05) if self._ymargin < 0.05 and x.size > 0: @@ -4483,7 +4714,7 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, return collection @_preprocess_data(replace_names=["x", "y", "C"], label_namer="y") - @docstring.dedent_interpd + @_docstring.dedent_interpd def hexbin(self, x, y, C=None, gridsize=100, bins=None, xscale='linear', yscale='linear', extent=None, cmap=None, norm=None, vmin=None, vmax=None, @@ -4514,7 +4745,31 @@ def hexbin(self, x, y, C=None, gridsize=100, bins=None, the hexagons are approximately regular. Alternatively, if a tuple (*nx*, *ny*), the number of hexagons - in the *x*-direction and the *y*-direction. + in the *x*-direction and the *y*-direction. In the + *y*-direction, counting is done along vertically aligned + hexagons, not along the zig-zag chains of hexagons; see the + following illustration. + + .. plot:: + + import numpy + import matplotlib.pyplot as plt + + np.random.seed(19680801) + n= 300 + x = np.random.standard_normal(n) + y = np.random.standard_normal(n) + + fig, ax = plt.subplots(figsize=(4, 4)) + h = ax.hexbin(x, y, gridsize=(5, 3)) + hx, hy = h.get_offsets().T + ax.plot(hx[24::3], hy[24::3], 'ro-') + ax.plot(hx[-3:], hy[-3:], 'ro-') + ax.set_title('gridsize=(5, 3)') + ax.axis('off') + + To get approximately regular hexagons, choose + :math:`n_x = \\sqrt{3}\\,n_y`. bins : 'log' or int or sequence, default: None Discretization of the hexagon values. @@ -4570,21 +4825,11 @@ def hexbin(self, x, y, C=None, gridsize=100, bins=None, Other Parameters ---------------- - cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - The Colormap instance or registered colormap name used to map - the bin values to colors. - - norm : `~matplotlib.colors.Normalize`, optional - The Normalize instance scales the bin values to the canonical - colormap range [0, 1] for mapping to colors. By default, the data - range is mapped to the colorbar range using linear scaling. - - vmin, vmax : float, default: None - The colorbar range. If *None*, suitable min/max values are - automatically chosen by the `.Normalize` instance (defaults to - the respective min/max values of the bins in case of the default - linear scaling). - It is an error to use *vmin*/*vmax* when *norm* is given. + %(cmap_doc)s + + %(norm_doc)s + + %(vmin_vmax_doc)s alpha : float between 0 and 1, optional The alpha blending value, between 0 (transparent) and 1 (opaque). @@ -4636,33 +4881,39 @@ def reduce_C_function(C: array) -> float nx = gridsize ny = int(nx / math.sqrt(3)) # Count the number of data in each hexagon - x = np.array(x, float) - y = np.array(y, float) + x = np.asarray(x, float) + y = np.asarray(y, float) - if marginals: - xorig = x.copy() - yorig = y.copy() + # Will be log()'d if necessary, and then rescaled. + tx = x + ty = y if xscale == 'log': if np.any(x <= 0.0): - raise ValueError("x contains non-positive values, so can not" - " be log-scaled") - x = np.log10(x) + raise ValueError("x contains non-positive values, so can not " + "be log-scaled") + tx = np.log10(tx) if yscale == 'log': if np.any(y <= 0.0): - raise ValueError("y contains non-positive values, so can not" - " be log-scaled") - y = np.log10(y) + raise ValueError("y contains non-positive values, so can not " + "be log-scaled") + ty = np.log10(ty) if extent is not None: xmin, xmax, ymin, ymax = extent else: - xmin, xmax = (np.min(x), np.max(x)) if len(x) else (0, 1) - ymin, ymax = (np.min(y), np.max(y)) if len(y) else (0, 1) + xmin, xmax = (tx.min(), tx.max()) if len(x) else (0, 1) + ymin, ymax = (ty.min(), ty.max()) if len(y) else (0, 1) # to avoid issues with singular data, expand the min/max pairs xmin, xmax = mtransforms.nonsingular(xmin, xmax, expander=0.1) ymin, ymax = mtransforms.nonsingular(ymin, ymax, expander=0.1) + nx1 = nx + 1 + ny1 = ny + 1 + nx2 = nx + ny2 = ny + n = nx1 * ny1 + nx2 * ny2 + # In the x-direction, the hexagons exactly cover the region from # xmin to xmax. Need some padding to avoid roundoff errors. padding = 1.e-9 * (xmax - xmin) @@ -4670,76 +4921,48 @@ def reduce_C_function(C: array) -> float xmax += padding sx = (xmax - xmin) / nx sy = (ymax - ymin) / ny - - x = (x - xmin) / sx - y = (y - ymin) / sy - ix1 = np.round(x).astype(int) - iy1 = np.round(y).astype(int) - ix2 = np.floor(x).astype(int) - iy2 = np.floor(y).astype(int) - - nx1 = nx + 1 - ny1 = ny + 1 - nx2 = nx - ny2 = ny - n = nx1 * ny1 + nx2 * ny2 - - d1 = (x - ix1) ** 2 + 3.0 * (y - iy1) ** 2 - d2 = (x - ix2 - 0.5) ** 2 + 3.0 * (y - iy2 - 0.5) ** 2 + # Positions in hexagon index coordinates. + ix = (tx - xmin) / sx + iy = (ty - ymin) / sy + ix1 = np.round(ix).astype(int) + iy1 = np.round(iy).astype(int) + ix2 = np.floor(ix).astype(int) + iy2 = np.floor(iy).astype(int) + # flat indices, plus one so that out-of-range points go to position 0. + i1 = np.where((0 <= ix1) & (ix1 < nx1) & (0 <= iy1) & (iy1 < ny1), + ix1 * ny1 + iy1 + 1, 0) + i2 = np.where((0 <= ix2) & (ix2 < nx2) & (0 <= iy2) & (iy2 < ny2), + ix2 * ny2 + iy2 + 1, 0) + + d1 = (ix - ix1) ** 2 + 3.0 * (iy - iy1) ** 2 + d2 = (ix - ix2 - 0.5) ** 2 + 3.0 * (iy - iy2 - 0.5) ** 2 bdist = (d1 < d2) - if C is None: - lattice1 = np.zeros((nx1, ny1)) - lattice2 = np.zeros((nx2, ny2)) - c1 = (0 <= ix1) & (ix1 < nx1) & (0 <= iy1) & (iy1 < ny1) & bdist - c2 = (0 <= ix2) & (ix2 < nx2) & (0 <= iy2) & (iy2 < ny2) & ~bdist - np.add.at(lattice1, (ix1[c1], iy1[c1]), 1) - np.add.at(lattice2, (ix2[c2], iy2[c2]), 1) - if mincnt is not None: - lattice1[lattice1 < mincnt] = np.nan - lattice2[lattice2 < mincnt] = np.nan - accum = np.concatenate([lattice1.ravel(), lattice2.ravel()]) - good_idxs = ~np.isnan(accum) + if C is None: # [1:] drops out-of-range points. + counts1 = np.bincount(i1[bdist], minlength=1 + nx1 * ny1)[1:] + counts2 = np.bincount(i2[~bdist], minlength=1 + nx2 * ny2)[1:] + accum = np.concatenate([counts1, counts2]).astype(float) + if mincnt is not None: + accum[accum < mincnt] = np.nan + C = np.ones(len(x)) else: - if mincnt is None: - mincnt = 0 - - # create accumulation arrays - lattice1 = np.empty((nx1, ny1), dtype=object) - for i in range(nx1): - for j in range(ny1): - lattice1[i, j] = [] - lattice2 = np.empty((nx2, ny2), dtype=object) - for i in range(nx2): - for j in range(ny2): - lattice2[i, j] = [] - + # store the C values in a list per hexagon index + Cs_at_i1 = [[] for _ in range(1 + nx1 * ny1)] + Cs_at_i2 = [[] for _ in range(1 + nx2 * ny2)] for i in range(len(x)): if bdist[i]: - if 0 <= ix1[i] < nx1 and 0 <= iy1[i] < ny1: - lattice1[ix1[i], iy1[i]].append(C[i]) + Cs_at_i1[i1[i]].append(C[i]) else: - if 0 <= ix2[i] < nx2 and 0 <= iy2[i] < ny2: - lattice2[ix2[i], iy2[i]].append(C[i]) - - for i in range(nx1): - for j in range(ny1): - vals = lattice1[i, j] - if len(vals) > mincnt: - lattice1[i, j] = reduce_C_function(vals) - else: - lattice1[i, j] = np.nan - for i in range(nx2): - for j in range(ny2): - vals = lattice2[i, j] - if len(vals) > mincnt: - lattice2[i, j] = reduce_C_function(vals) - else: - lattice2[i, j] = np.nan + Cs_at_i2[i2[i]].append(C[i]) + if mincnt is None: + mincnt = 0 + accum = np.array( + [reduce_C_function(acc) if len(acc) > mincnt else np.nan + for Cs_at_i in [Cs_at_i1, Cs_at_i2] + for acc in Cs_at_i[1:]], # [1:] drops out-of-range points. + float) - accum = np.concatenate([lattice1.astype(float).ravel(), - lattice2.astype(float).ravel()]) - good_idxs = ~np.isnan(accum) + good_idxs = ~np.isnan(accum) offsets = np.zeros((n, 2), float) offsets[:nx1 * ny1, 0] = np.repeat(np.arange(nx1), ny1) @@ -4783,8 +5006,9 @@ def reduce_C_function(C: array) -> float edgecolors=edgecolors, linewidths=linewidths, offsets=offsets, - transOffset=mtransforms.AffineDeltaTransform(self.transData), - ) + offset_transform=mtransforms.AffineDeltaTransform( + self.transData), + ) # Set normalizer if bins is 'log' if bins == 'log': @@ -4796,8 +5020,7 @@ def reduce_C_function(C: array) -> float vmin = vmax = None bins = None - # autoscale the norm with current accum values if it hasn't - # been set + # autoscale the norm with current accum values if it hasn't been set if norm is not None: if norm.vmin is None and norm.vmax is None: norm.autoscale(accum) @@ -4814,7 +5037,7 @@ def reduce_C_function(C: array) -> float collection.set_cmap(cmap) collection.set_norm(norm) collection.set_alpha(alpha) - collection.update(kwargs) + collection._internal_update(kwargs) collection._scale_norm(norm, vmin, vmax) corners = ((xmin, ymin), (xmax, ymax)) @@ -4827,98 +5050,61 @@ def reduce_C_function(C: array) -> float return collection # Process marginals - if C is None: - C = np.ones(len(x)) + bars = [] + for zname, z, zmin, zmax, zscale, nbins in [ + ("x", x, xmin, xmax, xscale, nx), + ("y", y, ymin, ymax, yscale, 2 * ny), + ]: - def coarse_bin(x, y, bin_edges): - """ - Sort x-values into bins defined by *bin_edges*, then for all the - corresponding y-values in each bin use *reduce_c_function* to - compute the bin value. - """ - nbins = len(bin_edges) - 1 - # Sort x-values into bins - bin_idxs = np.searchsorted(bin_edges, x) - 1 - mus = np.zeros(nbins) * np.nan + if zscale == "log": + bin_edges = np.geomspace(zmin, zmax, nbins + 1) + else: + bin_edges = np.linspace(zmin, zmax, nbins + 1) + + verts = np.empty((nbins, 4, 2)) + verts[:, 0, 0] = verts[:, 1, 0] = bin_edges[:-1] + verts[:, 2, 0] = verts[:, 3, 0] = bin_edges[1:] + verts[:, 0, 1] = verts[:, 3, 1] = .00 + verts[:, 1, 1] = verts[:, 2, 1] = .05 + if zname == "y": + verts = verts[:, :, ::-1] # Swap x and y. + + # Sort z-values into bins defined by bin_edges. + bin_idxs = np.searchsorted(bin_edges, z) - 1 + values = np.empty(nbins) for i in range(nbins): - # Get y-values for each bin - yi = y[bin_idxs == i] - if len(yi) > 0: - mus[i] = reduce_C_function(yi) - return mus - - if xscale == 'log': - bin_edges = np.geomspace(xmin, xmax, nx + 1) - else: - bin_edges = np.linspace(xmin, xmax, nx + 1) - xcoarse = coarse_bin(xorig, C, bin_edges) - - verts, values = [], [] - for bin_left, bin_right, val in zip( - bin_edges[:-1], bin_edges[1:], xcoarse): - if np.isnan(val): - continue - verts.append([(bin_left, 0), - (bin_left, 0.05), - (bin_right, 0.05), - (bin_right, 0)]) - values.append(val) - - values = np.array(values) - trans = self.get_xaxis_transform(which='grid') - - hbar = mcoll.PolyCollection(verts, transform=trans, edgecolors='face') - - hbar.set_array(values) - hbar.set_cmap(cmap) - hbar.set_norm(norm) - hbar.set_alpha(alpha) - hbar.update(kwargs) - self.add_collection(hbar, autolim=False) - - if yscale == 'log': - bin_edges = np.geomspace(ymin, ymax, 2 * ny + 1) - else: - bin_edges = np.linspace(ymin, ymax, 2 * ny + 1) - ycoarse = coarse_bin(yorig, C, bin_edges) - - verts, values = [], [] - for bin_bottom, bin_top, val in zip( - bin_edges[:-1], bin_edges[1:], ycoarse): - if np.isnan(val): - continue - verts.append([(0, bin_bottom), - (0, bin_top), - (0.05, bin_top), - (0.05, bin_bottom)]) - values.append(val) - - values = np.array(values) - - trans = self.get_yaxis_transform(which='grid') - - vbar = mcoll.PolyCollection(verts, transform=trans, edgecolors='face') - vbar.set_array(values) - vbar.set_cmap(cmap) - vbar.set_norm(norm) - vbar.set_alpha(alpha) - vbar.update(kwargs) - self.add_collection(vbar, autolim=False) - - collection.hbar = hbar - collection.vbar = vbar + # Get C-values for each bin, and compute bin value with + # reduce_C_function. + ci = C[bin_idxs == i] + values[i] = reduce_C_function(ci) if len(ci) > 0 else np.nan + + mask = ~np.isnan(values) + verts = verts[mask] + values = values[mask] + + trans = getattr(self, f"get_{zname}axis_transform")(which="grid") + bar = mcoll.PolyCollection( + verts, transform=trans, edgecolors="face") + bar.set_array(values) + bar.set_cmap(cmap) + bar.set_norm(norm) + bar.set_alpha(alpha) + bar._internal_update(kwargs) + bars.append(self.add_collection(bar, autolim=False)) + + collection.hbar, collection.vbar = bars def on_changed(collection): - hbar.set_cmap(collection.get_cmap()) - hbar.set_clim(collection.get_clim()) - vbar.set_cmap(collection.get_cmap()) - vbar.set_clim(collection.get_clim()) + collection.hbar.set_cmap(collection.get_cmap()) + collection.hbar.set_cmap(collection.get_cmap()) + collection.vbar.set_clim(collection.get_clim()) + collection.vbar.set_clim(collection.get_clim()) collection.callbacks.connect('changed', on_changed) return collection - @docstring.dedent_interpd + @_docstring.dedent_interpd def arrow(self, x, y, dx, dy, **kwargs): """ Add an arrow to the Axes. @@ -4957,7 +5143,7 @@ def arrow(self, x, y, dx, dy, **kwargs): self._request_autoscale_view() return a - @docstring.copy(mquiver.QuiverKey.__init__) + @_docstring.copy(mquiver.QuiverKey.__init__) def quiverkey(self, Q, X, Y, U, label, **kwargs): qk = mquiver.QuiverKey(Q, X, Y, U, label, **kwargs) self.add_artist(qk) @@ -4971,9 +5157,9 @@ def _quiver_units(self, args, kwargs): return (x, y) + args[2:] return args - # args can by a combination if X, Y, U, V, C and all should be replaced + # args can be a combination of X, Y, U, V, C and all should be replaced @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def quiver(self, *args, **kwargs): """%(quiver_doc)s""" # Make sure units are handled for x and y values @@ -4985,7 +5171,7 @@ def quiver(self, *args, **kwargs): # args can be some combination of X, Y, U, V, C and all should be replaced @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def barbs(self, *args, **kwargs): """%(barbs_doc)s""" # Make sure units are handled for x and y values @@ -5140,7 +5326,7 @@ def _fill_between_x_or_y( dep_dir = {"x": "y", "y": "x"}[ind_dir] - if not rcParams["_internal.classic_mode"]: + if not mpl.rcParams["_internal.classic_mode"]: kwargs = cbook.normalize_kwargs(kwargs, mcoll.Collection) if not any(c in kwargs for c in ("color", "facecolor")): kwargs["facecolor"] = \ @@ -5164,7 +5350,7 @@ def _fill_between_x_or_y( raise ValueError(f"where size ({where.size}) does not match " f"{ind_dir} size ({ind.size})") where = where & ~functools.reduce( - np.logical_or, map(np.ma.getmask, [ind, dep1, dep2])) + np.logical_or, map(np.ma.getmaskarray, [ind, dep1, dep2])) ind, dep1, dep2 = np.broadcast_arrays( np.atleast_1d(ind), dep1, dep2, subok=True) @@ -5251,7 +5437,7 @@ def fill_between(self, x, y1, y2=0, where=None, interpolate=False, dir="horizontal", ind="x", dep="y" ) fill_between = _preprocess_data( - docstring.dedent_interpd(fill_between), + _docstring.dedent_interpd(fill_between), replace_names=["x", "y1", "y2", "where"]) def fill_betweenx(self, y, x1, x2=0, where=None, @@ -5265,15 +5451,16 @@ def fill_betweenx(self, y, x1, x2=0, where=None, dir="vertical", ind="y", dep="x" ) fill_betweenx = _preprocess_data( - docstring.dedent_interpd(fill_betweenx), + _docstring.dedent_interpd(fill_betweenx), replace_names=["y", "x1", "x2", "where"]) #### plotting z(x, y): imshow, pcolor and relatives, contour - @_api.make_keyword_only("3.5", "aspect") + @_preprocess_data() - def imshow(self, X, cmap=None, norm=None, aspect=None, + @_docstring.interpd + def imshow(self, X, cmap=None, norm=None, *, aspect=None, interpolation=None, alpha=None, - vmin=None, vmax=None, origin=None, extent=None, *, + vmin=None, vmax=None, origin=None, extent=None, interpolation_stage=None, filternorm=True, filterrad=4.0, resample=None, url=None, **kwargs): """ @@ -5309,15 +5496,17 @@ def imshow(self, X, cmap=None, norm=None, aspect=None, Out-of-range RGB(A) values are clipped. - cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - The Colormap instance or registered colormap name used to map - scalar data to colors. This parameter is ignored for RGB(A) data. + %(cmap_doc)s + + This parameter is ignored if *X* is RGB(A). - norm : `~matplotlib.colors.Normalize`, optional - The `.Normalize` instance used to scale scalar data to the [0, 1] - range before mapping to colors using *cmap*. By default, a linear - scaling mapping the lowest value to 0 and the highest to 1 is used. - This parameter is ignored for RGB(A) data. + %(norm_doc)s + + This parameter is ignored if *X* is RGB(A). + + %(vmin_vmax_doc)s + + This parameter is ignored if *X* is RGB(A). aspect : {'equal', 'auto'} or float, default: :rc:`image.aspect` The aspect ratio of the Axes. This parameter is particularly @@ -5377,13 +5566,6 @@ def imshow(self, X, cmap=None, norm=None, aspect=None, If *alpha* is an array, the alpha blending values are applied pixel by pixel, and *alpha* must have the same shape as *X*. - vmin, vmax : float, optional - When using scalar data and no explicit *norm*, *vmin* and *vmax* - define the data range that the colormap covers. By default, - the colormap covers the complete value range of the supplied - data. It is an error to use *vmin*/*vmax* when *norm* is given. - When using RGB(A) data, parameters *vmin*/*vmax* are ignored. - origin : {'upper', 'lower'}, default: :rc:`image.origin` Place the [0, 0] index of the array in the upper left or lower left corner of the Axes. The convention (the default) 'upper' is @@ -5397,6 +5579,7 @@ def imshow(self, X, cmap=None, norm=None, aspect=None, extent : floats (left, right, bottom, top), optional The bounding box in data coordinates that the image will fill. + These values may be unitful and match the units of the Axes. The image is stretched individually along x and y to fill the box. The default extent is determined by the following conditions. @@ -5470,10 +5653,11 @@ def imshow(self, X, cmap=None, norm=None, aspect=None, (unassociated) alpha representation. """ if aspect is None: - aspect = rcParams['image.aspect'] + aspect = mpl.rcParams['image.aspect'] self.set_aspect(aspect) - im = mimage.AxesImage(self, cmap, norm, interpolation, - origin, extent, filternorm=filternorm, + im = mimage.AxesImage(self, cmap=cmap, norm=norm, + interpolation=interpolation, origin=origin, + extent=extent, filternorm=filternorm, filterrad=filterrad, resample=resample, interpolation_stage=interpolation_stage, **kwargs) @@ -5503,7 +5687,7 @@ def _pcolorargs(self, funcname, *args, shading='auto', **kwargs): _valid_shading = ['gouraud', 'nearest', 'flat', 'auto'] try: _api.check_in_list(_valid_shading, shading=shading) - except ValueError as err: + except ValueError: _api.warn_external(f"shading value '{shading}' not in list of " f"valid values {_valid_shading}. Setting " "shading='auto'.") @@ -5511,7 +5695,7 @@ def _pcolorargs(self, funcname, *args, shading='auto', **kwargs): if len(args) == 1: C = np.asanyarray(args[0]) - nrows, ncols = C.shape + nrows, ncols = C.shape[:2] if shading in ['gouraud', 'nearest']: X, Y = np.meshgrid(np.arange(ncols), np.arange(nrows)) else: @@ -5540,10 +5724,9 @@ def _pcolorargs(self, funcname, *args, shading='auto', **kwargs): X = X.data # strip mask as downstream doesn't like it... if isinstance(Y, np.ma.core.MaskedArray): Y = Y.data - nrows, ncols = C.shape + nrows, ncols = C.shape[:2] else: - raise TypeError(f'{funcname}() takes 1 or 3 positional arguments ' - f'but {len(args)} were given') + raise _api.nargs_error(funcname, takes="1 or 3", given=len(args)) Nx = X.shape[-1] Ny = Y.shape[0] @@ -5565,9 +5748,10 @@ def _pcolorargs(self, funcname, *args, shading='auto', **kwargs): if shading == 'flat': if (Nx, Ny) != (ncols + 1, nrows + 1): - raise TypeError('Dimensions of C %s are incompatible with' - ' X (%d) and/or Y (%d); see help(%s)' % ( - C.shape, Nx, Ny, funcname)) + raise TypeError(f"Dimensions of C {C.shape} should" + f" be one smaller than X({Nx}) and Y({Ny})" + f" while using shading='flat'" + f" see help({funcname})") else: # ['nearest', 'gouraud']: if (Nx, Ny) != (ncols, nrows): raise TypeError('Dimensions of C %s are incompatible with' @@ -5609,20 +5793,8 @@ def _interp_grid(X): C = cbook.safe_masked_invalid(C) return X, Y, C, shading - def _pcolor_grid_deprecation_helper(self): - grid_active = any(axis._major_tick_kw["gridOn"] - for axis in self._get_axis_list()) - # explicit is-True check because get_axisbelow() can also be 'line' - grid_hidden_by_pcolor = self.get_axisbelow() is True - if grid_active and not grid_hidden_by_pcolor: - _api.warn_deprecated( - "3.5", message="Auto-removal of grids by pcolor() and " - "pcolormesh() is deprecated since %(since)s and will be " - "removed %(removal)s; please call grid(False) first.") - self.grid(False) - @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, vmin=None, vmax=None, **kwargs): r""" @@ -5646,15 +5818,16 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, Parameters ---------- C : 2D array-like - The color-mapped values. + The color-mapped values. Color-mapping is controlled by *cmap*, + *norm*, *vmin*, and *vmax*. X, Y : array-like, optional The coordinates of the corners of quadrilaterals of a pcolormesh:: (X[i+1, j], Y[i+1, j]) (X[i+1, j+1], Y[i+1, j+1]) - +-----+ - | | - +-----+ + ●╶───╴● + │ │ + ●╶───╴● (X[i, j], Y[i, j]) (X[i, j+1], Y[i, j+1]) Note that the column index corresponds to the x-coordinate, and @@ -5693,21 +5866,11 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, See :doc:`/gallery/images_contours_and_fields/pcolormesh_grids` for more description. - cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - A Colormap instance or registered colormap name. The colormap - maps the *C* values to colors. + %(cmap_doc)s - norm : `~matplotlib.colors.Normalize`, optional - The Normalize instance scales the data values to the canonical - colormap range [0, 1] for mapping to colors. By default, the data - range is mapped to the colorbar range using linear scaling. + %(norm_doc)s - vmin, vmax : float, default: None - The colorbar range. If *None*, suitable min/max values are - automatically chosen by the `.Normalize` instance (defaults to - the respective min/max values of *C* in case of the default linear - scaling). - It is an error to use *vmin*/*vmax* when *norm* is given. + %(vmin_vmax_doc)s edgecolors : {'none', None, 'face', color, color sequence}, optional The color of the edges. Defaults to 'none'. Possible values: @@ -5778,7 +5941,7 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, """ if shading is None: - shading = rcParams['pcolor.shading'] + shading = mpl.rcParams['pcolor.shading'] shading = shading.lower() X, Y, C, shading = self._pcolorargs('pcolor', *args, shading=shading, kwargs=kwargs) @@ -5834,7 +5997,6 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, collection = mcoll.PolyCollection( verts, array=C, cmap=cmap, norm=norm, alpha=alpha, **kwargs) collection._scale_norm(norm, vmin, vmax) - self._pcolor_grid_deprecation_helper() x = X.compressed() y = Y.compressed() @@ -5866,7 +6028,7 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, return collection @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, vmax=None, shading=None, antialiased=False, **kwargs): """ @@ -5887,16 +6049,26 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, Parameters ---------- - C : 2D array-like - The color-mapped values. + C : array-like + The mesh data. Supported array shapes are: + + - (M, N) or M*N: a mesh with scalar data. The values are mapped to + colors using normalization and a colormap. See parameters *norm*, + *cmap*, *vmin*, *vmax*. + - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). + - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), + i.e. including transparency. + + The first two dimensions (M, N) define the rows and columns of + the mesh data. X, Y : array-like, optional The coordinates of the corners of quadrilaterals of a pcolormesh:: (X[i+1, j], Y[i+1, j]) (X[i+1, j+1], Y[i+1, j+1]) - +-----+ - | | - +-----+ + ●╶───╴● + │ │ + ●╶───╴● (X[i, j], Y[i, j]) (X[i, j+1], Y[i, j+1]) Note that the column index corresponds to the x-coordinate, and @@ -5919,21 +6091,11 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, expanded as needed into the appropriate 2D arrays, making a rectangular grid. - cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - A Colormap instance or registered colormap name. The colormap - maps the *C* values to colors. + %(cmap_doc)s - norm : `~matplotlib.colors.Normalize`, optional - The Normalize instance scales the data values to the canonical - colormap range [0, 1] for mapping to colors. By default, the data - range is mapped to the colorbar range using linear scaling. + %(norm_doc)s - vmin, vmax : float, default: None - The colorbar range. If *None*, suitable min/max values are - automatically chosen by the `.Normalize` instance (defaults to - the respective min/max values of *C* in case of the default linear - scaling). - It is an error to use *vmin*/*vmax* when *norm* is given. + %(vmin_vmax_doc)s edgecolors : {'none', None, 'face', color, color sequence}, optional The color of the edges. Defaults to 'none'. Possible values: @@ -6051,23 +6213,23 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, """ if shading is None: - shading = rcParams['pcolor.shading'] + shading = mpl.rcParams['pcolor.shading'] shading = shading.lower() kwargs.setdefault('edgecolors', 'none') X, Y, C, shading = self._pcolorargs('pcolormesh', *args, shading=shading, kwargs=kwargs) coords = np.stack([X, Y], axis=-1) - # convert to one dimensional array - C = C.ravel() + # convert to one dimensional array, except for 3D RGB(A) arrays + if C.ndim != 3: + C = C.ravel() - kwargs.setdefault('snap', rcParams['pcolormesh.snap']) + kwargs.setdefault('snap', mpl.rcParams['pcolormesh.snap']) collection = mcoll.QuadMesh( coords, antialiased=antialiased, shading=shading, array=C, cmap=cmap, norm=norm, alpha=alpha, **kwargs) collection._scale_norm(norm, vmin, vmax) - self._pcolor_grid_deprecation_helper() coords = coords.reshape(-1, 2) # flatten the grid structure; keep x, y @@ -6093,7 +6255,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, return collection @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, vmax=None, **kwargs): """ @@ -6116,15 +6278,15 @@ def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, - It supports only flat shading (no outlines) - It lacks support for log scaling of the axes. - - It does not have a have a pyplot wrapper. + - It does not have a pyplot wrapper. Parameters ---------- C : array-like The image data. Supported array shapes are: - - (M, N): an image with scalar data. The data is visualized - using a colormap. + - (M, N): an image with scalar data. Color-mapping is controlled + by *cmap*, *norm*, *vmin*, and *vmax*. - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), i.e. including transparency. @@ -6167,21 +6329,17 @@ def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, These arguments can only be passed positionally. - cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - A Colormap instance or registered colormap name. The colormap - maps the *C* values to colors. + %(cmap_doc)s + + This parameter is ignored if *C* is RGB(A). + + %(norm_doc)s + + This parameter is ignored if *C* is RGB(A). - norm : `~matplotlib.colors.Normalize`, optional - The Normalize instance scales the data values to the canonical - colormap range [0, 1] for mapping to colors. By default, the data - range is mapped to the colorbar range using linear scaling. + %(vmin_vmax_doc)s - vmin, vmax : float, default: None - The colorbar range. If *None*, suitable min/max values are - automatically chosen by the `.Normalize` instance (defaults to - the respective min/max values of *C* in case of the default linear - scaling). - It is an error to use *vmin*/*vmax* when *norm* is given. + This parameter is ignored if *C* is RGB(A). alpha : float, default: None The alpha blending value, between 0 (transparent) and 1 (opaque). @@ -6239,14 +6397,10 @@ def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, if style == "quadmesh": # data point in each cell is value at lower left corner coords = np.stack([x, y], axis=-1) - if np.ndim(C) == 2: - qm_kwargs = {"array": np.ma.ravel(C)} - elif np.ndim(C) == 3: - qm_kwargs = {"color": np.ma.reshape(C, (-1, C.shape[-1]))} - else: + if np.ndim(C) not in {2, 3}: raise ValueError("C must be 2D or 3D") collection = mcoll.QuadMesh( - coords, **qm_kwargs, + coords, array=C, alpha=alpha, cmap=cmap, norm=norm, antialiased=False, edgecolors="none") self.add_collection(collection, autolim=False) @@ -6257,7 +6411,7 @@ def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, extent = xl, xr, yb, yt = x[0], x[-1], y[0], y[-1] if style == "image": im = mimage.AxesImage( - self, cmap, norm, + self, cmap=cmap, norm=norm, data=C, alpha=alpha, extent=extent, interpolation='nearest', origin='lower', **kwargs) @@ -6283,7 +6437,7 @@ def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, return ret @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def contour(self, *args, **kwargs): """ Plot contour lines. @@ -6299,7 +6453,7 @@ def contour(self, *args, **kwargs): return contours @_preprocess_data() - @docstring.dedent_interpd + @_docstring.dedent_interpd def contourf(self, *args, **kwargs): """ Plot filled contours. @@ -6342,23 +6496,33 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, orientation='vertical', rwidth=None, log=False, color=None, label=None, stacked=False, **kwargs): """ - Plot a histogram. + Compute and plot a histogram. - Compute and draw the histogram of *x*. The return value is a tuple - (*n*, *bins*, *patches*) or ([*n0*, *n1*, ...], *bins*, [*patches0*, - *patches1*, ...]) if the input contains multiple data. See the - documentation of the *weights* parameter to draw a histogram of - already-binned data. + This method uses `numpy.histogram` to bin the data in *x* and count the + number of values in each bin, then draws the distribution either as a + `.BarContainer` or `.Polygon`. The *bins*, *range*, *density*, and + *weights* parameters are forwarded to `numpy.histogram`. - Multiple data can be provided via *x* as a list of datasets - of potentially different length ([*x0*, *x1*, ...]), or as - a 2D ndarray in which each column is a dataset. Note that - the ndarray form is transposed relative to the list form. + If the data has already been binned and counted, use `~.bar` or + `~.stairs` to plot the distribution:: - Masked arrays are not supported. + counts, bins = np.histogram(x) + plt.stairs(counts, bins) + + Alternatively, plot pre-computed bins and counts using ``hist()`` by + treating each bin as a single point with a weight equal to its count:: + + plt.hist(bins[:-1], bins, weights=counts) - The *bins*, *range*, *weights*, and *density* parameters behave as in - `numpy.histogram`. + The data input *x* can be a singular array, a list of datasets of + potentially different lengths ([*x0*, *x1*, ...]), or a 2D ndarray in + which each column is a dataset. Note that the ndarray form is + transposed relative to the list form. If the input is an array, then + the return value is a tuple (*n*, *bins*, *patches*); if the input is a + sequence of arrays, then the return value is a tuple + ([*n0*, *n1*, ...], *bins*, [*patches0*, *patches1*, ...]). + + Masked arrays are not supported. Parameters ---------- @@ -6412,15 +6576,6 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, normalized, so that the integral of the density over the range remains 1. - This parameter can be used to draw a histogram of data that has - already been binned, e.g. using `numpy.histogram` (by treating each - bin as a single point with a weight equal to its count) :: - - counts, bins = np.histogram(data) - plt.hist(bins[:-1], bins, weights=counts) - - (or you may alternatively use `~.bar()`). - cumulative : bool or -1, default: False If ``True``, then a histogram is computed where each bin gives the counts in that bin plus all bins for smaller values. The last bin @@ -6435,7 +6590,7 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, equals 1. bottom : array-like, scalar, or None, default: None - Location of the bottom of each bin, ie. bins are drawn from + Location of the bottom of each bin, i.e. bins are drawn from ``bottom`` to ``bottom + hist(x, bins)`` If a scalar, the bottom of each bin is shifted by the same amount. If an array, each bin is shifted independently and the length of bottom must match the @@ -6521,9 +6676,9 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, Notes ----- - For large numbers of bins (>1000), 'step' and 'stepfilled' can be - significantly faster than 'bar' and 'barstacked'. - + For large numbers of bins (>1000), plotting can be significantly faster + if *histtype* is set to 'step' or 'stepfilled' rather than 'bar' or + 'barstacked'. """ # Avoid shadowing the builtin. bin_range = range @@ -6533,7 +6688,7 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, x = [x] if bins is None: - bins = rcParams['hist.bins'] + bins = mpl.rcParams['hist.bins'] # Validate string inputs here to avoid cluttering subsequent code. _api.check_in_list(['bar', 'barstacked', 'step', 'stepfilled'], @@ -6584,13 +6739,13 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, input_empty = False if color is None: - color = [self._get_lines.get_next_color() for i in range(nx)] + colors = [self._get_lines.get_next_color() for i in range(nx)] else: - color = mcolors.to_rgba_array(color) - if len(color) != nx: + colors = mcolors.to_rgba_array(color) + if len(colors) != nx: raise ValueError(f"The 'color' keyword argument must have one " f"color per dataset, but {nx} datasets and " - f"{len(color)} colors were provided") + f"{len(colors)} colors were provided") hist_kwargs = dict() @@ -6635,6 +6790,7 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, m, bins = np.histogram(x[i], bins, weights=w[i], **hist_kwargs) tops.append(m) tops = np.array(tops, float) # causes problems later if it's an int + bins = np.array(bins, float) # causes problems if float16 if stacked: tops = tops.cumsum(axis=0) # If a stacked density plot, normalize so the area of all the @@ -6659,7 +6815,7 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, if rwidth is not None: dr = np.clip(rwidth, 0, 1) elif (len(tops) > 1 and - ((not stacked) or rcParams['_internal.classic_mode'])): + ((not stacked) or mpl.rcParams['_internal.classic_mode'])): dr = 0.8 else: dr = 1.0 @@ -6684,19 +6840,19 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, _barfunc = self.bar bottom_kwarg = 'bottom' - for m, c in zip(tops, color): + for top, color in zip(tops, colors): if bottom is None: - bottom = np.zeros(len(m)) + bottom = np.zeros(len(top)) if stacked: - height = m - bottom + height = top - bottom else: - height = m + height = top bars = _barfunc(bins[:-1]+boffset, height, width, align='center', log=log, - color=c, **{bottom_kwarg: bottom}) + color=color, **{bottom_kwarg: bottom}) patches.append(bars) if stacked: - bottom = m + bottom = top boffset += dw # Remove stickies from all bars but the lowest ones, as otherwise # margin expansion would be unable to cross the stickies in the @@ -6735,12 +6891,12 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, fill = (histtype == 'stepfilled') xvals, yvals = [], [] - for m in tops: + for top in tops: if stacked: # top of the previous polygon becomes the bottom y[2*len(bins)-1:] = y[1:2*len(bins)-1][::-1] # set the top of this polygon - y[1:2*len(bins)-1:2] = y[2:2*len(bins):2] = m + bottom + y[1:2*len(bins)-1:2] = y[2:2*len(bins):2] = top + bottom # The starting point of the polygon has not yet been # updated. So far only the endpoint was adjusted. This @@ -6760,12 +6916,12 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, # add patches in reverse order so that when stacking, # items lower in the stack are plotted on top of # items higher in the stack - for x, y, c in reversed(list(zip(xvals, yvals, color))): + for x, y, color in reversed(list(zip(xvals, yvals, colors))): patches.append(self.fill( x[:split], y[:split], closed=True if fill else None, - facecolor=c, - edgecolor=None if fill else c, + facecolor=color, + edgecolor=None if fill else color, fill=fill if fill else None, zorder=None if fill else mlines.Line2D.zorder)) for patch_list in patches: @@ -6784,11 +6940,11 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, for patch, lbl in itertools.zip_longest(patches, labels): if patch: p = patch[0] - p.update(kwargs) + p._internal_update(kwargs) if lbl is not None: p.set_label(lbl) for p in patch[1:]: - p.update(kwargs) + p._internal_update(kwargs) p.set_label('_nolegend_') if nx == 1: @@ -6876,7 +7032,7 @@ def stairs(self, values, edges=None, *, return patch @_preprocess_data(replace_names=["x", "y", "weights"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, cmin=None, cmax=None, **kwargs): """ @@ -6928,23 +7084,18 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, histogrammed along the first dimension and values in y are histogrammed along the second dimension. xedges : 1D array - The bin edges along the x axis. + The bin edges along the x-axis. yedges : 1D array - The bin edges along the y axis. + The bin edges along the y-axis. image : `~.matplotlib.collections.QuadMesh` Other Parameters ---------------- - cmap : Colormap or str, optional - A `.colors.Colormap` instance. If not set, use rc settings. + %(cmap_doc)s - norm : Normalize, optional - A `.colors.Normalize` instance is used to - scale luminance data to ``[0, 1]``. If not set, defaults to - `.colors.Normalize()`. + %(norm_doc)s - vmin/vmax : None or scalar, optional - Arguments passed to the `~.colors.Normalize` instance. + %(vmin_vmax_doc)s alpha : ``0 <= scalar <= 1`` or ``None``, optional The alpha blending value. @@ -6988,7 +7139,7 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, return h, xedges, yedges, pc @_preprocess_data(replace_names=["x"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def psd(self, x, NFFT=None, Fs=None, Fc=None, detrend=None, window=None, noverlap=None, pad_to=None, sides=None, scale_by_freq=None, return_line=None, **kwargs): @@ -7099,7 +7250,7 @@ def psd(self, x, NFFT=None, Fs=None, Fc=None, detrend=None, return pxx, freqs, line @_preprocess_data(replace_names=["x", "y"], label_namer="y") - @docstring.dedent_interpd + @_docstring.dedent_interpd def csd(self, x, y, NFFT=None, Fs=None, Fc=None, detrend=None, window=None, noverlap=None, pad_to=None, sides=None, scale_by_freq=None, return_line=None, **kwargs): @@ -7201,7 +7352,7 @@ def csd(self, x, y, NFFT=None, Fs=None, Fc=None, detrend=None, return pxy, freqs, line @_preprocess_data(replace_names=["x"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def magnitude_spectrum(self, x, Fs=None, Fc=None, window=None, pad_to=None, sides=None, scale=None, **kwargs): @@ -7287,7 +7438,7 @@ def magnitude_spectrum(self, x, Fs=None, Fc=None, window=None, return spec, freqs, line @_preprocess_data(replace_names=["x"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def angle_spectrum(self, x, Fs=None, Fc=None, window=None, pad_to=None, sides=None, **kwargs): """ @@ -7356,7 +7507,7 @@ def angle_spectrum(self, x, Fs=None, Fc=None, window=None, return spec, freqs, lines[0] @_preprocess_data(replace_names=["x"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def phase_spectrum(self, x, Fs=None, Fc=None, window=None, pad_to=None, sides=None, **kwargs): """ @@ -7425,15 +7576,14 @@ def phase_spectrum(self, x, Fs=None, Fc=None, window=None, return spec, freqs, lines[0] @_preprocess_data(replace_names=["x", "y"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def cohere(self, x, y, NFFT=256, Fs=2, Fc=0, detrend=mlab.detrend_none, window=mlab.window_hanning, noverlap=0, pad_to=None, sides='default', scale_by_freq=None, **kwargs): r""" Plot the coherence between *x* and *y*. - Plot the coherence between *x* and *y*. Coherence is the - normalized cross spectral density: + Coherence is the normalized cross spectral density: .. math:: @@ -7478,7 +7628,8 @@ def cohere(self, x, y, NFFT=256, Fs=2, Fc=0, detrend=mlab.detrend_none, """ cxy, freqs = mlab.cohere(x=x, y=y, NFFT=NFFT, Fs=Fs, detrend=detrend, window=window, noverlap=noverlap, - scale_by_freq=scale_by_freq) + scale_by_freq=scale_by_freq, sides=sides, + pad_to=pad_to) freqs += Fc self.plot(freqs, cxy, **kwargs) @@ -7489,7 +7640,7 @@ def cohere(self, x, y, NFFT=256, Fs=2, Fc=0, detrend=mlab.detrend_none, return cxy, freqs @_preprocess_data(replace_names=["x"]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def specgram(self, x, NFFT=None, Fs=None, Fc=None, detrend=None, window=None, noverlap=None, cmap=None, xextent=None, pad_to=None, sides=None, @@ -7526,7 +7677,7 @@ def specgram(self, x, NFFT=None, Fs=None, Fc=None, detrend=None, scale : {'default', 'linear', 'dB'} The scaling of the values in the *spec*. 'linear' is no scaling. 'dB' returns the values in dB scale. When *mode* is 'psd', - this is dB power (10 * log10). Otherwise this is dB amplitude + this is dB power (10 * log10). Otherwise, this is dB amplitude (20 * log10). 'default' is 'dB' if *mode* is 'psd' or 'magnitude' and 'linear' otherwise. This must be 'linear' if *mode* is 'angle' or 'phase'. @@ -7636,8 +7787,7 @@ def specgram(self, x, NFFT=None, Fs=None, Fc=None, detrend=None, extent = xmin, xmax, freqs[0], freqs[-1] if 'origin' in kwargs: - raise TypeError("specgram() got an unexpected keyword argument " - "'origin'") + raise _api.kwarg_error("specgram", "origin") im = self.imshow(Z, cmap, extent=extent, vmin=vmin, vmax=vmax, origin='upper', **kwargs) @@ -7645,7 +7795,7 @@ def specgram(self, x, NFFT=None, Fs=None, Fc=None, detrend=None, return spec, freqs, t, im - @docstring.dedent_interpd + @_docstring.dedent_interpd def spy(self, Z, precision=0, marker=None, markersize=None, aspect='equal', origin="upper", **kwargs): """ @@ -7735,8 +7885,7 @@ def spy(self, Z, precision=0, marker=None, markersize=None, kwargs['cmap'] = mcolors.ListedColormap(['w', 'k'], name='binary') if 'interpolation' in kwargs: - raise TypeError( - "spy() got an unexpected keyword argument 'interpolation'") + raise _api.kwarg_error("spy", "interpolation") if 'norm' not in kwargs: kwargs['norm'] = mcolors.NoNorm() ret = self.imshow(mask, interpolation='nearest', @@ -7761,8 +7910,7 @@ def spy(self, Z, precision=0, marker=None, markersize=None, if markersize is None: markersize = 10 if 'linestyle' in kwargs: - raise TypeError( - "spy() got an unexpected keyword argument 'linestyle'") + raise _api.kwarg_error("spy", "linestyle") ret = mlines.Line2D( x, y, linestyle='None', marker=marker, markersize=markersize, **kwargs) @@ -7777,7 +7925,7 @@ def spy(self, Z, precision=0, marker=None, markersize=None, self.title.set_y(1.05) if origin == "upper": self.xaxis.tick_top() - else: + else: # lower self.xaxis.tick_bottom() self.xaxis.set_ticks_position('both') self.xaxis.set_major_locator( @@ -7889,8 +8037,9 @@ def violinplot(self, dataset, positions=None, vert=True, widths=0.5, The method used to calculate the estimator bandwidth. This can be 'scott', 'silverman', a scalar constant or a callable. If a scalar, this will be used directly as `kde.factor`. If a - callable, it should take a `GaussianKDE` instance as its only - parameter and return a scalar. If None (default), 'scott' is used. + callable, it should take a `matplotlib.mlab.GaussianKDE` instance as + its only parameter and return a scalar. If None (default), 'scott' + is used. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -8059,7 +8208,7 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5, line_ends = [[-0.25], [0.25]] * np.array(widths) + positions # Colors. - if rcParams['_internal.classic_mode']: + if mpl.rcParams['_internal.classic_mode']: fillcolor = 'y' linecolor = 'r' else: @@ -8112,7 +8261,7 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5, table = mtable.table - # args can by either Y or y1, y2, ... and all should be replaced + # args can be either Y or y1, y2, ... and all should be replaced stackplot = _preprocess_data()(mstack.stackplot) streamplot = _preprocess_data( @@ -8122,3 +8271,13 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5, tricontourf = mtri.tricontourf tripcolor = mtri.tripcolor triplot = mtri.triplot + + def _get_aspect_ratio(self): + """ + Convenience method to calculate the aspect ratio of the axes in + the display coordinate system. + """ + figure_size = self.get_figure().get_size_inches() + ll, ur = self.get_position() * figure_size + width, height = ur - ll + return height / (width * self.get_data_ratio()) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 2a95e5c60c4e..d10b4e020056 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1,5 +1,4 @@ -from collections import OrderedDict -from collections.abc import MutableSequence +from collections.abc import Iterable, Sequence from contextlib import ExitStack import functools import inspect @@ -12,17 +11,17 @@ import numpy as np import matplotlib as mpl -from matplotlib import _api, cbook, docstring +from matplotlib import _api, cbook, _docstring, offsetbox import matplotlib.artist as martist import matplotlib.axis as maxis from matplotlib.cbook import _OrderedSet, _check_1d, index_of import matplotlib.collections as mcoll import matplotlib.colors as mcolors import matplotlib.font_manager as font_manager +from matplotlib.gridspec import SubplotSpec import matplotlib.image as mimage import matplotlib.lines as mlines import matplotlib.patches as mpatches -import matplotlib.path as mpath from matplotlib.rcsetup import cycler, validate_axisbelow import matplotlib.spines as mspines import matplotlib.table as mtable @@ -47,7 +46,7 @@ class _axis_method_wrapper: The docstring of ``get_foo`` is built by replacing "this Axis" by "the {attr_name}" (i.e., "the xaxis", "the yaxis") in the wrapped method's - dedented docstring; additional replacements can by given in *doc_sub*. + dedented docstring; additional replacements can be given in *doc_sub*. """ def __init__(self, attr_name, method_name, *, doc_sub=None): @@ -99,7 +98,7 @@ class _TransformedBoundsLocator: Axes locator for `.Axes.inset_axes` and similarly positioned Axes. The locator is a callable object used in `.Axes.set_aspect` to compute the - axes location depending on the renderer. + Axes location depending on the renderer. """ def __init__(self, bounds, transform): @@ -119,7 +118,7 @@ def __call__(self, ax, renderer): self._transform - ax.figure.transSubfigure) -def _process_plot_format(fmt): +def _process_plot_format(fmt, *, ambiguous_fmt_datakey=False): """ Convert a MATLAB style color/line style format string to a (*linestyle*, *marker*, *color*) tuple. @@ -164,31 +163,31 @@ def _process_plot_format(fmt): except ValueError: pass # No, not just a color. + errfmt = ("{!r} is neither a data key nor a valid format string ({})" + if ambiguous_fmt_datakey else + "{!r} is not a valid format string ({})") + i = 0 while i < len(fmt): c = fmt[i] if fmt[i:i+2] in mlines.lineStyles: # First, the two-char styles. if linestyle is not None: - raise ValueError( - 'Illegal format string "%s"; two linestyle symbols' % fmt) + raise ValueError(errfmt.format(fmt, "two linestyle symbols")) linestyle = fmt[i:i+2] i += 2 elif c in mlines.lineStyles: if linestyle is not None: - raise ValueError( - 'Illegal format string "%s"; two linestyle symbols' % fmt) + raise ValueError(errfmt.format(fmt, "two linestyle symbols")) linestyle = c i += 1 elif c in mlines.lineMarkers: if marker is not None: - raise ValueError( - 'Illegal format string "%s"; two marker symbols' % fmt) + raise ValueError(errfmt.format(fmt, "two marker symbols")) marker = c i += 1 elif c in mcolors.get_named_colors_mapping(): if color is not None: - raise ValueError( - 'Illegal format string "%s"; two color symbols' % fmt) + raise ValueError(errfmt.format(fmt, "two color symbols")) color = c i += 1 elif c == 'C' and i < len(fmt) - 1: @@ -197,7 +196,7 @@ def _process_plot_format(fmt): i += 2 else: raise ValueError( - 'Unrecognized character %c in format string' % c) + errfmt.format(fmt, f"unrecognized character {c!r}")) if linestyle is None and marker is None: linestyle = mpl.rcParams['lines.linestyle'] @@ -244,8 +243,7 @@ def __call__(self, *args, data=None, **kwargs): for pos_only in "xy": if pos_only in kwargs: - raise TypeError("{} got an unexpected keyword argument {!r}" - .format(self.command, pos_only)) + raise _api.kwarg_error(self.command, pos_only) if not args: return @@ -294,6 +292,7 @@ def __call__(self, *args, data=None, **kwargs): kwargs["label"] = mpl._label_from_arg( replaced[label_namer_idx], args[label_namer_idx]) args = replaced + ambiguous_fmt_datakey = data is not None and len(args) == 2 if len(args) >= 4 and not cbook.is_scalar_or_string( kwargs.get("label")): @@ -309,7 +308,8 @@ def __call__(self, *args, data=None, **kwargs): if args and isinstance(args[0], str): this += args[0], args = args[1:] - yield from self._plot_args(this, kwargs) + yield from self._plot_args( + this, kwargs, ambiguous_fmt_datakey=ambiguous_fmt_datakey) def get_next_color(self): """Return the next color in the cycle.""" @@ -403,7 +403,8 @@ def _makefill(self, x, y, kw, kwargs): seg.set(**kwargs) return seg, kwargs - def _plot_args(self, tup, kwargs, return_kwargs=False): + def _plot_args(self, tup, kwargs, *, + return_kwargs=False, ambiguous_fmt_datakey=False): """ Process the arguments of ``plot([x], y, [fmt], **kwargs)`` calls. @@ -430,9 +431,13 @@ def _plot_args(self, tup, kwargs, return_kwargs=False): The keyword arguments passed to ``plot()``. return_kwargs : bool - If true, return the effective keyword arguments after label + Whether to also return the effective keyword arguments after label unpacking as well. + ambiguous_fmt_datakey : bool + Whether the format string in *tup* could also have been a + misspelled data key. + Returns ------- result @@ -446,7 +451,8 @@ def _plot_args(self, tup, kwargs, return_kwargs=False): if len(tup) > 1 and isinstance(tup[-1], str): # xy is tup with fmt stripped (could still be (y,) only) *xy, fmt = tup - linestyle, marker, color = _process_plot_format(fmt) + linestyle, marker, color = _process_plot_format( + fmt, ambiguous_fmt_datakey=ambiguous_fmt_datakey) elif len(tup) == 3: raise ValueError('third arg must be a format string') else: @@ -538,20 +544,35 @@ def _plot_args(self, tup, kwargs, return_kwargs=False): return [l[0] for l in result] -@cbook._define_aliases({"facecolor": ["fc"]}) +@_api.define_aliases({"facecolor": ["fc"]}) class _AxesBase(martist.Artist): name = "rectilinear" - _axis_names = ("x", "y") # See _get_axis_map. + # axis names are the prefixes for the attributes that contain the + # respective axis; e.g. 'x' <-> self.xaxis, containing an XAxis. + # Note that PolarAxes uses these attributes as well, so that we have + # 'x' <-> self.xaxis, containing a ThetaAxis. In particular we do not + # have 'theta' in _axis_names. + # In practice, this is ('x', 'y') for all 2D Axes and ('x', 'y', 'z') + # for Axes3D. + _axis_names = ("x", "y") _shared_axes = {name: cbook.Grouper() for name in _axis_names} _twinned_axes = cbook.Grouper() + _subclass_uses_cla = False + + @property + def _axis_map(self): + """A mapping of axis names, e.g. 'x', to `Axis` instances.""" + return {name: getattr(self, f"{name}axis") + for name in self._axis_names} + def __str__(self): return "{0}({1[0]:g},{1[1]:g};{1[2]:g}x{1[3]:g})".format( type(self).__name__, self._position.bounds) - @_api.make_keyword_only("3.4", "facecolor") - def __init__(self, fig, rect, + def __init__(self, fig, + *args, facecolor=None, # defaults to rc axes.facecolor frameon=True, sharex=None, # use Axes instance's xaxis info @@ -570,13 +591,22 @@ def __init__(self, fig, rect, fig : `~matplotlib.figure.Figure` The Axes is built in the `.Figure` *fig*. - rect : [left, bottom, width, height] - The Axes is built in the rectangle *rect*. *rect* is in - `.Figure` coordinates. + *args + ``*args`` can be a single ``(left, bottom, width, height)`` + rectangle or a single `.Bbox`. This specifies the rectangle (in + figure coordinates) where the Axes is positioned. + + ``*args`` can also consist of three numbers or a single three-digit + number; in the latter case, the digits are considered as + independent numbers. The numbers are interpreted as ``(nrows, + ncols, index)``: ``(nrows, ncols)`` specifies the size of an array + of subplots, and ``index`` is the 1-based index of the subplot + being created. Finally, ``*args`` can also directly be a + `.SubplotSpec` instance. sharex, sharey : `~.axes.Axes`, optional - The x or y `~.matplotlib.axis` is shared with the x or - y axis in the input `~.axes.Axes`. + The x- or y-`~.matplotlib.axis` is shared with the x- or y-axis in + the input `~.axes.Axes`. frameon : bool, default: True Whether the Axes frame is visible. @@ -597,10 +627,21 @@ def __init__(self, fig, rect, """ super().__init__() - if isinstance(rect, mtransforms.Bbox): - self._position = rect + if "rect" in kwargs: + if args: + raise TypeError( + "'rect' cannot be used together with positional arguments") + rect = kwargs.pop("rect") + _api.check_isinstance((mtransforms.Bbox, Iterable), rect=rect) + args = (rect,) + subplotspec = None + if len(args) == 1 and isinstance(args[0], mtransforms.Bbox): + self._position = args[0] + elif len(args) == 1 and np.iterable(args[0]): + self._position = mtransforms.Bbox.from_bounds(*args[0]) else: - self._position = mtransforms.Bbox.from_bounds(*rect) + self._position = self._originalPosition = mtransforms.Bbox.unit() + subplotspec = SubplotSpec._from_subplot_args(fig, args) if self._position.width < 0 or self._position.height < 0: raise ValueError('Width and height specified must be non-negative') self._originalPosition = self._position.frozen() @@ -613,8 +654,18 @@ def __init__(self, fig, rect, self._sharey = sharey self.set_label(label) self.set_figure(fig) + # The subplotspec needs to be set after the figure (so that + # figure-level subplotpars are taken into account), but the figure + # needs to be set after self._position is initialized. + if subplotspec: + self.set_subplotspec(subplotspec) + else: + self._subplotspec = None self.set_box_aspect(box_aspect) self._axes_locator = None # Optionally set via update(kwargs). + + self._children = [] + # placeholder for any colorbars added that use this Axes. # (see colorbar.py): self._colorbars = [] @@ -629,7 +680,7 @@ def __init__(self, fig, rect, self.set_axisbelow(mpl.rcParams['axes.axisbelow']) self._rasterization_zorder = None - self.cla() + self.clear() # funcs used to format x and y - fall back on major formatters self.fmt_xdata = None @@ -643,12 +694,11 @@ def __init__(self, fig, rect, if yscale: self.set_yscale(yscale) - self.update(kwargs) + self._internal_update(kwargs) - for name, axis in self._get_axis_map().items(): - axis.callbacks._pickled_cids.add( - axis.callbacks.connect( - 'units', self._unit_change_handler(name))) + for name, axis in self._axis_map.items(): + axis.callbacks._connect_picklable( + 'units', self._unit_change_handler(name)) rcParams = mpl.rcParams self.tick_params( @@ -681,9 +731,21 @@ def __init__(self, fig, rect, rcParams['ytick.major.right']), which='major') + def __init_subclass__(cls, **kwargs): + parent_uses_cla = super(cls, cls)._subclass_uses_cla + if 'cla' in cls.__dict__: + _api.warn_deprecated( + '3.6', + pending=True, + message=f'Overriding `Axes.cla` in {cls.__qualname__} is ' + 'pending deprecation in %(since)s and will be fully ' + 'deprecated in favor of `Axes.clear` in the future. ' + 'Please report ' + f'this to the {cls.__module__!r} author.') + cls._subclass_uses_cla = 'cla' in cls.__dict__ or parent_uses_cla + super().__init_subclass__(**kwargs) + def __getstate__(self): - # The renderer should be re-created by the figure, and then cached at - # that point. state = super().__getstate__() # Prune the sharing & twinning info to only contain the current group. state["_shared_axes"] = { @@ -708,26 +770,40 @@ def __repr__(self): fields = [] if self.get_label(): fields += [f"label={self.get_label()!r}"] - titles = [] - for k in ["left", "center", "right"]: - if hasattr(self, 'get_title'): + if hasattr(self, "get_title"): + titles = {} + for k in ["left", "center", "right"]: title = self.get_title(loc=k) if title: - titles.append(f"{k!r}:{title!r}") - if titles: - fields += ["title={" + ",".join(titles) + "}"] - if self.get_xlabel(): - fields += [f"xlabel={self.get_xlabel()!r}"] - if self.get_ylabel(): - fields += [f"ylabel={self.get_ylabel()!r}"] - return f"<{self.__class__.__name__}:" + ", ".join(fields) + ">" - - def get_window_extent(self, *args, **kwargs): + titles[k] = title + if titles: + fields += [f"title={titles}"] + for name, axis in self._axis_map.items(): + if axis.get_label() and axis.get_label().get_text(): + fields += [f"{name}label={axis.get_label().get_text()!r}"] + return f"<{self.__class__.__name__}: " + ", ".join(fields) + ">" + + def get_subplotspec(self): + """Return the `.SubplotSpec` associated with the subplot, or None.""" + return self._subplotspec + + def set_subplotspec(self, subplotspec): + """Set the `.SubplotSpec`. associated with the subplot.""" + self._subplotspec = subplotspec + self._set_position(subplotspec.get_position(self.figure)) + + def get_gridspec(self): + """Return the `.GridSpec` associated with the subplot, or None.""" + return self._subplotspec.get_gridspec() if self._subplotspec else None + + @_api.delete_parameter("3.6", "args") + @_api.delete_parameter("3.6", "kwargs") + def get_window_extent(self, renderer=None, *args, **kwargs): """ Return the Axes bounding box in display space; *args* and *kwargs* are empty. - This bounding box does not include the spines, ticks, ticklables, + This bounding box does not include the spines, ticks, ticklabels, or other labels. For a bounding box including these elements use `~matplotlib.axes.Axes.get_tightbbox`. @@ -747,7 +823,6 @@ def _init_axis(self): self.yaxis = maxis.YAxis(self) self.spines.left.register_axis(self.yaxis) self.spines.right.register_axis(self.yaxis) - self._update_transScale() def set_figure(self, fig): # docstring inherited @@ -782,24 +857,26 @@ def viewLim(self): self._unstale_viewLim() return self._viewLim - # API could be better, right now this is just to match the old calls to - # autoscale_view() after each plotting method. - def _request_autoscale_view(self, tight=None, **kwargs): - # kwargs are "scalex", "scaley" (& "scalez" for 3D) and default to True - want_scale = {name: True for name in self._axis_names} - for k, v in kwargs.items(): # Validate args before changing anything. - if k.startswith("scale"): - name = k[5:] - if name in want_scale: - want_scale[name] = v - continue - raise TypeError( - f"_request_autoscale_view() got an unexpected argument {k!r}") + def _request_autoscale_view(self, axis="all", tight=None): + """ + Mark a single axis, or all of them, as stale wrt. autoscaling. + + No computation is performed until the next autoscaling; thus, separate + calls to control individual axises incur negligible performance cost. + + Parameters + ---------- + axis : str, default: "all" + Either an element of ``self._axis_names``, or "all". + tight : bool or None, default: None + """ + axis_names = _api.check_getitem( + {**{k: [k] for k in self._axis_names}, "all": self._axis_names}, + axis=axis) + for name in axis_names: + self._stale_viewlims[name] = True if tight is not None: self._tight = tight - for k, v in want_scale.items(): - if v: - self._stale_viewlims[k] = True # Else keep old state. def _set_lim_and_transforms(self): """ @@ -849,6 +926,10 @@ def get_xaxis_transform(self, which='grid'): `~matplotlib.axis.Axis` class, and is meant to be overridden by new kinds of projections that may need to place axis elements in different locations. + + Parameters + ---------- + which : {'grid', 'tick1', 'tick2'} """ if which == 'grid': return self._xaxis_transform @@ -859,7 +940,7 @@ def get_xaxis_transform(self, which='grid'): # for cartesian projection, this is top spine return self.spines.top.get_spine_transform() else: - raise ValueError('unknown value for which') + raise ValueError(f'unknown value for which: {which!r}') def get_xaxis_text1_transform(self, pad_points): """ @@ -925,6 +1006,10 @@ def get_yaxis_transform(self, which='grid'): `~matplotlib.axis.Axis` class, and is meant to be overridden by new kinds of projections that may need to place axis elements in different locations. + + Parameters + ---------- + which : {'grid', 'tick1', 'tick2'} """ if which == 'grid': return self._yaxis_transform @@ -935,7 +1020,7 @@ def get_yaxis_transform(self, which='grid'): # for cartesian projection, this is top spine return self.spines.right.get_spine_transform() else: - raise ValueError('unknown value for which') + raise ValueError(f'unknown value for which: {which!r}') def get_yaxis_text1_transform(self, pad_points): """ @@ -993,13 +1078,6 @@ def _update_transScale(self): self.transScale.set( mtransforms.blended_transform_factory( self.xaxis.get_transform(), self.yaxis.get_transform())) - for line in getattr(self, "_children", []): # Not set during init. - if not isinstance(line, mlines.Line2D): - continue - try: - line._transformed_path.invalidate() - except AttributeError: - pass def get_position(self, original=False): """ @@ -1008,7 +1086,7 @@ def get_position(self, original=False): Parameters ---------- original : bool - If ``True``, return the original position. Otherwise return the + If ``True``, return the original position. Otherwise, return the active position. For an explanation of the positions see `.set_position`. @@ -1038,7 +1116,7 @@ def set_position(self, pos, which='both'): Parameters ---------- pos : [left, bottom, width, height] or `~matplotlib.transforms.Bbox` - The new position of the in `.Figure` coordinates. + The new position of the Axes in `.Figure` coordinates. which : {'both', 'active', 'original'}, default: 'both' Determines which position variables to change. @@ -1057,7 +1135,7 @@ def _set_position(self, pos, which='both'): """ Private version of set_position. - Call this internally to get the same functionality of `get_position`, + Call this internally to get the same functionality of `set_position`, but not to take the axis out of the constrained_layout hierarchy. """ if not isinstance(pos, mtransforms.BboxBase): @@ -1073,8 +1151,9 @@ def reset_position(self): """ Reset the active position to the original position. - This resets the a possible position change due to aspect constraints. - For an explanation of the positions see `.set_position`. + This undoes changes to the active position (as defined in + `.set_position`) which may have been performed to satisfy fixed-aspect + constraints. """ for ax in self._twinned_axes.get_siblings(self): pos = ax.get_position(original=True) @@ -1104,7 +1183,7 @@ def _set_artist_props(self, a): a.set_transform(self.transData) a.axes = self - if a.mouseover: + if a.get_mouseover(): self._mouseover_set.add(a) def _gen_axes_patch(self): @@ -1115,7 +1194,7 @@ def _gen_axes_patch(self): The patch used to draw the background of the Axes. It is also used as the clipping path for any data elements on the Axes. - In the standard axes, this is a rectangle, but in other projections + In the standard Axes, this is a rectangle, but in other projections it may not be. Notes @@ -1139,15 +1218,15 @@ def _gen_axes_spines(self, locations=None, offset=0.0, units='inches'): ----- Intended to be overridden by new projection types. """ - return OrderedDict((side, mspines.Spine.linear_spine(self, side)) - for side in ['left', 'right', 'bottom', 'top']) + return {side: mspines.Spine.linear_spine(self, side) + for side in ['left', 'right', 'bottom', 'top']} def sharex(self, other): """ Share the x-axis with *other*. This is equivalent to passing ``sharex=other`` when constructing the - axes, and cannot be used if the x-axis is already being shared with + Axes, and cannot be used if the x-axis is already being shared with another Axes. """ _api.check_isinstance(_AxesBase, other=other) @@ -1166,7 +1245,7 @@ def sharey(self, other): Share the y-axis with *other*. This is equivalent to passing ``sharey=other`` when constructing the - axes, and cannot be used if the y-axis is already being shared with + Axes, and cannot be used if the y-axis is already being shared with another Axes. """ _api.check_isinstance(_AxesBase, other=other) @@ -1180,9 +1259,12 @@ def sharey(self, other): self.set_ylim(y0, y1, emit=False, auto=other.get_autoscaley_on()) self.yaxis._scale = other.yaxis._scale - def cla(self): + def __clear(self): """Clear the Axes.""" - # Note: this is called by Axes.__init__() + # The actual implementation of clear() as long as clear() has to be + # an adapter delegating to the correct implementation. + # The implementation can move back into clear() when the + # deprecation on cla() subclassing expires. # stash the current visibility state if hasattr(self, 'patch'): @@ -1193,31 +1275,14 @@ def cla(self): xaxis_visible = self.xaxis.get_visible() yaxis_visible = self.yaxis.get_visible() - self.xaxis.clear() - self.yaxis.clear() - - for name, spine in self.spines.items(): + for axis in self._axis_map.values(): + axis.clear() # Also resets the scale to linear. + for spine in self.spines.values(): spine.clear() self.ignore_existing_data_limits = True - self.callbacks = cbook.CallbackRegistry() - - if self._sharex is not None: - self.sharex(self._sharex) - else: - self.xaxis._set_scale('linear') - try: - self.set_xlim(0, 1) - except TypeError: - pass - if self._sharey is not None: - self.sharey(self._sharey) - else: - self.yaxis._set_scale('linear') - try: - self.set_ylim(0, 1) - except TypeError: - pass + self.callbacks = cbook.CallbackRegistry( + signals=["xlim_changed", "ylim_changed", "zlim_changed"]) # update the minor locator for x and y axis based on rcParams if mpl.rcParams['xtick.minor.visible']: @@ -1225,21 +1290,18 @@ def cla(self): if mpl.rcParams['ytick.minor.visible']: self.yaxis.set_minor_locator(mticker.AutoMinorLocator()) - if self._sharex is None: - self._autoscaleXon = True - if self._sharey is None: - self._autoscaleYon = True self._xmargin = mpl.rcParams['axes.xmargin'] self._ymargin = mpl.rcParams['axes.ymargin'] self._tight = None self._use_sticky_edges = True - self._update_transScale() # needed? self._get_lines = _process_plot_var_args(self) self._get_patches_for_fill = _process_plot_var_args(self, 'fill') self._gridOn = mpl.rcParams['axes.grid'] - self._children = [] + old_children, self._children = self._children, [] + for chld in old_children: + chld.axes = chld.figure = None self._mouseover_set = _OrderedSet() self.child_axes = [] self._current_image = None # strictly for pyplot via _sci, _gci @@ -1310,33 +1372,56 @@ def cla(self): self.yaxis.set_visible(yaxis_visible) self.patch.set_visible(patch_visible) + # This comes last, as the call to _set_lim may trigger an autoscale (in + # case of shared axes), requiring children to be already set up. + for name, axis in self._axis_map.items(): + share = getattr(self, f"_share{name}") + if share is not None: + getattr(self, f"share{name}")(share) + else: + axis._set_scale("linear") + axis._set_lim(0, 1, auto=True) + self._update_transScale() + self.stale = True - class ArtistList(MutableSequence): + def clear(self): + """Clear the Axes.""" + # Act as an alias, or as the superclass implementation depending on the + # subclass implementation. + if self._subclass_uses_cla: + self.cla() + else: + self.__clear() + + def cla(self): + """Clear the Axes.""" + # Act as an alias, or as the superclass implementation depending on the + # subclass implementation. + if self._subclass_uses_cla: + self.__clear() + else: + self.clear() + + class ArtistList(Sequence): """ A sublist of Axes children based on their type. - The type-specific children sublists will become immutable in - Matplotlib 3.7. Then, these artist lists will likely be replaced by - tuples. Use as if this is a tuple already. - - This class exists only for the transition period to warn on the - deprecated modifcation of artist lists. + The type-specific children sublists were made immutable in Matplotlib + 3.7. In the future these artist lists may be replaced by tuples. Use + as if this is a tuple already. """ - def __init__(self, axes, prop_name, add_name, + def __init__(self, axes, prop_name, valid_types=None, invalid_types=None): """ Parameters ---------- - axes : .axes.Axes + axes : `~matplotlib.axes.Axes` The Axes from which this sublist will pull the children Artists. prop_name : str The property name used to access this sublist from the Axes; used to generate deprecation warnings. - add_name : str - The method name used to add Artists of this sublist's type to - the Axes; used to generate deprecation warnings. valid_types : list of type, optional A list of types that determine which children will be returned by this sublist. If specified, then the Artists in the sublist @@ -1351,7 +1436,6 @@ def __init__(self, axes, prop_name, add_name, """ self._axes = axes self._prop_name = prop_name - self._add_name = add_name self._type_check = lambda artist: ( (not valid_types or isinstance(artist, valid_types)) and (not invalid_types or not isinstance(artist, invalid_types)) @@ -1365,7 +1449,7 @@ def __len__(self): for artist in self._axes._children) def __iter__(self): - for artist in self._axes._children: + for artist in list(self._axes._children): if self._type_check(artist): yield artist @@ -1377,107 +1461,47 @@ def __getitem__(self, key): def __add__(self, other): if isinstance(other, (list, _AxesBase.ArtistList)): return [*self, *other] + if isinstance(other, (tuple, _AxesBase.ArtistList)): + return (*self, *other) return NotImplemented def __radd__(self, other): if isinstance(other, list): return other + list(self) + if isinstance(other, tuple): + return other + tuple(self) return NotImplemented - def insert(self, index, item): - _api.warn_deprecated( - '3.5', - name=f'modification of the Axes.{self._prop_name}', - obj_type='property', - alternative=f'Axes.{self._add_name}') - try: - index = self._axes._children.index(self[index]) - except IndexError: - index = None - getattr(self._axes, self._add_name)(item) - if index is not None: - # Move new item to the specified index, if there's something to - # put it before. - self._axes._children[index:index] = self._axes._children[-1:] - del self._axes._children[-1] - - def __setitem__(self, key, item): - _api.warn_deprecated( - '3.5', - name=f'modification of the Axes.{self._prop_name}', - obj_type='property', - alternative=f'Artist.remove() and Axes.f{self._add_name}') - del self[key] - if isinstance(key, slice): - key = key.start - if not np.iterable(item): - self.insert(key, item) - return - - try: - index = self._axes._children.index(self[key]) - except IndexError: - index = None - for i, artist in enumerate(item): - getattr(self._axes, self._add_name)(artist) - if index is not None: - # Move new items to the specified index, if there's something - # to put it before. - i = -(i + 1) - self._axes._children[index:index] = self._axes._children[i:] - del self._axes._children[i:] - - def __delitem__(self, key): - _api.warn_deprecated( - '3.5', - name=f'modification of the Axes.{self._prop_name}', - obj_type='property', - alternative='Artist.remove()') - if isinstance(key, slice): - for artist in self[key]: - artist.remove() - else: - self[key].remove() - @property def artists(self): - return self.ArtistList(self, 'artists', 'add_artist', invalid_types=( + return self.ArtistList(self, 'artists', invalid_types=( mcoll.Collection, mimage.AxesImage, mlines.Line2D, mpatches.Patch, mtable.Table, mtext.Text)) @property def collections(self): - return self.ArtistList(self, 'collections', 'add_collection', + return self.ArtistList(self, 'collections', valid_types=mcoll.Collection) @property def images(self): - return self.ArtistList(self, 'images', 'add_image', - valid_types=mimage.AxesImage) + return self.ArtistList(self, 'images', valid_types=mimage.AxesImage) @property def lines(self): - return self.ArtistList(self, 'lines', 'add_line', - valid_types=mlines.Line2D) + return self.ArtistList(self, 'lines', valid_types=mlines.Line2D) @property def patches(self): - return self.ArtistList(self, 'patches', 'add_patch', - valid_types=mpatches.Patch) + return self.ArtistList(self, 'patches', valid_types=mpatches.Patch) @property def tables(self): - return self.ArtistList(self, 'tables', 'add_table', - valid_types=mtable.Table) + return self.ArtistList(self, 'tables', valid_types=mtable.Table) @property def texts(self): - return self.ArtistList(self, 'texts', 'add_artist', - valid_types=mtext.Text) - - def clear(self): - """Clear the Axes.""" - self.cla() + return self.ArtistList(self, 'texts', valid_types=mtext.Text) def get_facecolor(self): """Get the facecolor of the Axes.""" @@ -1612,7 +1636,7 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): anchor : None or str or (float, float), optional If not ``None``, this defines where the Axes will be drawn if there - is extra space due to aspect constraints. The most common way to + is extra space due to aspect constraints. The most common way to specify the anchor are abbreviations of cardinal directions: ===== ===================== @@ -1641,6 +1665,8 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): aspect = 1 if not cbook._str_equal(aspect, 'auto'): aspect = float(aspect) # raise ValueError if necessary + if aspect <= 0 or not np.isfinite(aspect): + raise ValueError("aspect must be finite and positive ") if share: axes = {sibling for name in self._axis_names @@ -1802,17 +1828,15 @@ def set_anchor(self, anchor, share=False): Either an (*x*, *y*) pair of relative coordinates (0 is left or bottom, 1 is right or top), 'C' (center), or a cardinal direction ('SW', southwest, is bottom left, etc.). str inputs are shorthands - for (*x*, *y*) coordinates, as shown in the following table:: - - .. code-block:: none + for (*x*, *y*) coordinates, as shown in the following diagram:: - +-----------------+-----------------+-----------------+ - | 'NW' (0.0, 1.0) | 'N' (0.5, 1.0) | 'NE' (1.0, 1.0) | - +-----------------+-----------------+-----------------+ - | 'W' (0.0, 0.5) | 'C' (0.5, 0.5) | 'E' (1.0, 0.5) | - +-----------------+-----------------+-----------------+ - | 'SW' (0.0, 0.0) | 'S' (0.5, 0.0) | 'SE' (1.0, 0.0) | - +-----------------+-----------------+-----------------+ + ┌─────────────────┬─────────────────┬─────────────────┐ + │ 'NW' (0.0, 1.0) │ 'N' (0.5, 1.0) │ 'NE' (1.0, 1.0) │ + ├─────────────────┼─────────────────┼─────────────────┤ + │ 'W' (0.0, 0.5) │ 'C' (0.5, 0.5) │ 'E' (1.0, 0.5) │ + ├─────────────────┼─────────────────┼─────────────────┤ + │ 'SW' (0.0, 0.0) │ 'S' (0.5, 0.0) │ 'SE' (1.0, 0.0) │ + └─────────────────┴─────────────────┴─────────────────┘ share : bool, default: False If ``True``, apply the settings to all shared Axes. @@ -1857,6 +1881,13 @@ def apply_aspect(self, position=None): Axes box (position) or the view limits. In the former case, `~matplotlib.axes.Axes.get_anchor` will affect the position. + Parameters + ---------- + position : None or .Bbox + If not ``None``, this defines the position of the + Axes within the figure as a Bbox. See `~.Axes.get_position` + for further details. + Notes ----- This is called automatically when each Axes is drawn. You may need @@ -1882,7 +1913,7 @@ def apply_aspect(self, position=None): return trans = self.get_figure().transSubfigure - bb = mtransforms.Bbox.from_bounds(0, 0, 1, 1).transformed(trans) + bb = mtransforms.Bbox.unit().transformed(trans) # this is the physical aspect of the panel (or figure): fig_aspect = bb.height / bb.width @@ -1944,10 +1975,12 @@ def apply_aspect(self, position=None): shared_x = self in self._shared_axes["x"] shared_y = self in self._shared_axes["y"] - # Not sure whether we need this check: + if shared_x and shared_y: - raise RuntimeError("adjustable='datalim' is not allowed when both " - "axes are shared") + raise RuntimeError("set_aspect(..., adjustable='datalim') or " + "axis('equal') are not allowed when both axes " + "are shared. Try set_aspect(..., " + "adjustable='box').") # If y is shared, then we are only allowed to change x, etc. if shared_y: @@ -1971,7 +2004,7 @@ def apply_aspect(self, position=None): x1 = xc + Xsize / 2.0 self.set_xbound(x_trf.inverted().transform([x0, x1])) - def axis(self, *args, emit=True, **kwargs): + def axis(self, arg=None, /, *, emit=True, **kwargs): """ Convenience method to get or set some axis properties. @@ -2029,38 +2062,34 @@ def axis(self, *args, emit=True, **kwargs): matplotlib.axes.Axes.set_xlim matplotlib.axes.Axes.set_ylim """ - if len(args) > 1: - raise TypeError("axis() takes 0 or 1 positional arguments but " - f"{len(args)} were given") - elif len(args) == 1 and isinstance(args[0], (str, bool)): - s = args[0] - if s is True: - s = 'on' - if s is False: - s = 'off' - s = s.lower() - if s == 'on': + if isinstance(arg, (str, bool)): + if arg is True: + arg = 'on' + if arg is False: + arg = 'off' + arg = arg.lower() + if arg == 'on': self.set_axis_on() - elif s == 'off': + elif arg == 'off': self.set_axis_off() - elif s in ('equal', 'tight', 'scaled', 'auto', 'image', 'square'): + elif arg in [ + 'equal', 'tight', 'scaled', 'auto', 'image', 'square']: self.set_autoscale_on(True) self.set_aspect('auto') self.autoscale_view(tight=False) - # self.apply_aspect() - if s == 'equal': + if arg == 'equal': self.set_aspect('equal', adjustable='datalim') - elif s == 'scaled': + elif arg == 'scaled': self.set_aspect('equal', adjustable='box', anchor='C') self.set_autoscale_on(False) # Req. by Mark Bakker - elif s == 'tight': + elif arg == 'tight': self.autoscale_view(tight=True) self.set_autoscale_on(False) - elif s == 'image': + elif arg == 'image': self.autoscale_view(tight=True) self.set_autoscale_on(False) self.set_aspect('equal', adjustable='box', anchor='C') - elif s == 'square': + elif arg == 'square': self.set_aspect('equal', adjustable='box', anchor='C') self.set_autoscale_on(False) xlim = self.get_xlim() @@ -2071,13 +2100,12 @@ def axis(self, *args, emit=True, **kwargs): self.set_ylim([ylim[0], ylim[0] + edge_size], emit=emit, auto=False) else: - raise ValueError('Unrecognized string %s to axis; ' - 'try on or off' % s) + raise ValueError(f"Unrecognized string {arg!r} to axis; " + "try 'on' or 'off'") else: - if len(args) == 1: - limits = args[0] + if arg is not None: try: - xmin, xmax, ymin, ymax = limits + xmin, xmax, ymin, ymax = arg except (TypeError, ValueError) as err: raise TypeError('the first argument to axis() must be an ' 'iterable of the form ' @@ -2096,8 +2124,7 @@ def axis(self, *args, emit=True, **kwargs): self.set_xlim(xmin, xmax, emit=emit, auto=xauto) self.set_ylim(ymin, ymax, emit=emit, auto=yauto) if kwargs: - raise TypeError(f"axis() got an unexpected keyword argument " - f"'{next(iter(kwargs))}'") + raise _api.kwarg_error("axis", kwargs) return (*self.get_xlim(), *self.get_ylim()) def get_legend(self): @@ -2114,19 +2141,23 @@ def get_lines(self): def get_xaxis(self): """ - Return the XAxis instance. + [*Discouraged*] Return the XAxis instance. + + .. admonition:: Discouraged - The use of this function is discouraged. You should instead directly - access the attribute ``ax.xaxis``. + The use of this function is discouraged. You should instead + directly access the attribute ``ax.xaxis``. """ return self.xaxis def get_yaxis(self): """ - Return the YAxis instance. + [*Discouraged*] Return the YAxis instance. + + .. admonition:: Discouraged - The use of this function is discouraged. You should instead directly - access the attribute ``ax.yaxis``. + The use of this function is discouraged. You should instead + directly access the attribute ``ax.yaxis``. """ return self.yaxis @@ -2142,7 +2173,7 @@ def _sci(self, im): Set the current image. This image will be the target of colormap functions like - `~.pyplot.viridis`, and other functions such as `~.pyplot.clim`. The + ``pyplot.viridis``, and other functions such as `~.pyplot.clim`. The current image is an attribute of the current Axes. """ _api.check_isinstance( @@ -2172,20 +2203,6 @@ def has_data(self): mlines.Line2D, mpatches.Patch)) for a in self._children) - def _deprecate_noninstance(self, _name, _types, **kwargs): - """ - For each *key, value* pair in *kwargs*, check that *value* is an - instance of one of *_types*; if not, raise an appropriate deprecation. - """ - for key, value in kwargs.items(): - if not isinstance(value, _types): - _api.warn_deprecated( - '3.5', name=_name, - message=f'Passing argument *{key}* of unexpected type ' - f'{type(value).__qualname__} to %(name)s which only ' - f'accepts {_types} is deprecated since %(since)s and will ' - 'become an error %(removal)s.') - def add_artist(self, a): """ Add an `.Artist` to the Axes; return the artist. @@ -2229,8 +2246,7 @@ def add_collection(self, collection, autolim=True): """ Add a `.Collection` to the Axes; return the collection. """ - self._deprecate_noninstance('add_collection', mcoll.Collection, - collection=collection) + _api.check_isinstance(mcoll.Collection, collection=collection) label = collection.get_label() if not label: collection.set_label(f'_child{len(self._children)}') @@ -2263,7 +2279,7 @@ def add_image(self, image): """ Add an `.AxesImage` to the Axes; return the image. """ - self._deprecate_noninstance('add_image', mimage.AxesImage, image=image) + _api.check_isinstance(mimage.AxesImage, image=image) self._set_artist_props(image) if not image.get_label(): image.set_label(f'_child{len(self._children)}') @@ -2280,7 +2296,7 @@ def add_line(self, line): """ Add a `.Line2D` to the Axes; return the line. """ - self._deprecate_noninstance('add_line', mlines.Line2D, line=line) + _api.check_isinstance(mlines.Line2D, line=line) self._set_artist_props(line) if line.get_clip_path() is None: line.set_clip_path(self.patch) @@ -2297,7 +2313,7 @@ def _add_text(self, txt): """ Add a `.Text` to the Axes; return the text. """ - self._deprecate_noninstance('_add_text', mtext.Text, txt=txt) + _api.check_isinstance(mtext.Text, txt=txt) self._set_artist_props(txt) self._children.append(txt) txt._remove_method = self._children.remove @@ -2356,7 +2372,7 @@ def add_patch(self, p): """ Add a `.Patch` to the Axes; return the patch. """ - self._deprecate_noninstance('add_patch', mpatches.Patch, p=p) + _api.check_isinstance(mpatches.Patch, p=p) self._set_artist_props(p) if p.get_clip_path() is None: p.set_clip_path(self.patch) @@ -2379,10 +2395,18 @@ def _update_patch_limits(self, patch): ((not patch.get_width()) and (not patch.get_height()))): return p = patch.get_path() - vertices = p.vertices if p.codes is None else p.vertices[np.isin( - p.codes, (mpath.Path.CLOSEPOLY, mpath.Path.STOP), invert=True)] - if not vertices.size: - return + # Get all vertices on the path + # Loop through each segment to get extrema for Bezier curve sections + vertices = [] + for curve, code in p.iter_bezier(simplify=False): + # Get distance along the curve of any extrema + _, dzeros = curve.axis_aligned_extrema() + # Calculate vertices of start, end and any extrema in between + vertices.append(curve([0, *dzeros, 1])) + + if len(vertices): + vertices = np.row_stack(vertices) + patch_trf = patch.get_transform() updatex, updatey = patch_trf.contains_branch_seperately(self.transData) if not (updatex or updatey): @@ -2401,7 +2425,7 @@ def add_table(self, tab): """ Add a `.Table` to the Axes; return the table. """ - self._deprecate_noninstance('add_table', mtable.Table, tab=tab) + _api.check_isinstance(mtable.Table, tab=tab) self._set_artist_props(tab) self._children.append(tab) tab.set_clip_path(self.patch) @@ -2410,7 +2434,7 @@ def add_table(self, tab): def add_container(self, container): """ - Add a `.Container` to the axes' containers; return the container. + Add a `.Container` to the Axes' containers; return the container. """ label = container.get_label() if not label: @@ -2426,12 +2450,11 @@ def _unit_change_handler(self, axis_name, event=None): if event is None: # Allow connecting `self._unit_change_handler(name)` return functools.partial( self._unit_change_handler, axis_name, event=object()) - _api.check_in_list(self._get_axis_map(), axis_name=axis_name) + _api.check_in_list(self._axis_map, axis_name=axis_name) for line in self.lines: line.recache_always() self.relim() - self._request_autoscale_view(scalex=(axis_name == "x"), - scaley=(axis_name == "y")) + self._request_autoscale_view(axis_name) def relim(self, visible_only=False): """ @@ -2493,12 +2516,12 @@ def _process_unit_info(self, datasets=None, kwargs=None, *, convert=True): ---------- datasets : list List of (axis_name, dataset) pairs (where the axis name is defined - as in `._get_axis_map`). Individual datasets can also be None + as in `._axis_map`). Individual datasets can also be None (which gets passed through). kwargs : dict Other parameters from which unit info (i.e., the *xunits*, - *yunits*, *zunits* (for 3D axes), *runits* and *thetaunits* (for - polar axes) entries) is popped, if present. Note that this dict is + *yunits*, *zunits* (for 3D Axes), *runits* and *thetaunits* (for + polar) entries) is popped, if present. Note that this dict is mutated in-place! convert : bool, default: True Whether to return the original datasets or the converted ones. @@ -2515,14 +2538,16 @@ def _process_unit_info(self, datasets=None, kwargs=None, *, convert=True): # (e.g. if some are scalars, etc.). datasets = datasets or [] kwargs = kwargs or {} - axis_map = self._get_axis_map() + axis_map = self._axis_map for axis_name, data in datasets: try: axis = axis_map[axis_name] except KeyError: raise ValueError(f"Invalid axis name: {axis_name!r}") from None # Update from data if axis is already set but no unit is set yet. - if axis is not None and data is not None and not axis.have_units(): + if (axis is not None and + data is not None and + not axis._have_units_and_converter()): axis.update_units(data) for axis_name, axis in axis_map.items(): # Return if no axis is set. @@ -2551,17 +2576,15 @@ def in_axes(self, mouseevent): """ return self.patch.contains(mouseevent)[0] + get_autoscalex_on = _axis_method_wrapper("xaxis", "_get_autoscale_on") + get_autoscaley_on = _axis_method_wrapper("yaxis", "_get_autoscale_on") + set_autoscalex_on = _axis_method_wrapper("xaxis", "_set_autoscale_on") + set_autoscaley_on = _axis_method_wrapper("yaxis", "_set_autoscale_on") + def get_autoscale_on(self): """Return True if each axis is autoscaled, False otherwise.""" - return self._autoscaleXon and self._autoscaleYon - - def get_autoscalex_on(self): - """Return whether the x-axis is autoscaled.""" - return self._autoscaleXon - - def get_autoscaley_on(self): - """Return whether the y-axis is autoscaled.""" - return self._autoscaleYon + return all(axis._get_autoscale_on() + for axis in self._axis_map.values()) def set_autoscale_on(self, b): """ @@ -2572,30 +2595,8 @@ def set_autoscale_on(self, b): ---------- b : bool """ - self._autoscaleXon = b - self._autoscaleYon = b - - def set_autoscalex_on(self, b): - """ - Set whether the x-axis is autoscaled on the next draw or call to - `.Axes.autoscale_view`. - - Parameters - ---------- - b : bool - """ - self._autoscaleXon = b - - def set_autoscaley_on(self, b): - """ - Set whether the y-axis is autoscaled on the next draw or call to - `.Axes.autoscale_view`. - - Parameters - ---------- - b : bool - """ - self._autoscaleYon = b + for axis in self._axis_map.values(): + axis._set_autoscale_on(b) @property def use_sticky_edges(self): @@ -2623,14 +2624,13 @@ def set_xmargin(self, m): """ Set padding of X data limits prior to autoscaling. - *m* times the data interval will be added to each - end of that interval before it is used in autoscaling. - For example, if your data is in the range [0, 2], a factor of - ``m = 0.1`` will result in a range [-0.2, 2.2]. + *m* times the data interval will be added to each end of that interval + before it is used in autoscaling. If *m* is negative, this will clip + the data range instead of expanding it. - Negative values -0.5 < m < 0 will result in clipping of the data range. - I.e. for a data range [0, 2], a factor of ``m = -0.1`` will result in - a range [0.2, 1.8]. + For example, if your data is in the range [0, 2], a margin of 0.1 will + result in a range [-0.2, 2.2]; a margin of -0.1 will result in a range + of [0.2, 1.8]. Parameters ---------- @@ -2639,21 +2639,20 @@ def set_xmargin(self, m): if m <= -0.5: raise ValueError("margin must be greater than -0.5") self._xmargin = m - self._request_autoscale_view(scalex=True, scaley=False) + self._request_autoscale_view("x") self.stale = True def set_ymargin(self, m): """ Set padding of Y data limits prior to autoscaling. - *m* times the data interval will be added to each - end of that interval before it is used in autoscaling. - For example, if your data is in the range [0, 2], a factor of - ``m = 0.1`` will result in a range [-0.2, 2.2]. + *m* times the data interval will be added to each end of that interval + before it is used in autoscaling. If *m* is negative, this will clip + the data range instead of expanding it. - Negative values -0.5 < m < 0 will result in clipping of the data range. - I.e. for a data range [0, 2], a factor of ``m = -0.1`` will result in - a range [0.2, 1.8]. + For example, if your data is in the range [0, 2], a margin of 0.1 will + result in a range [-0.2, 2.2]; a margin of -0.1 will result in a range + of [0.2, 1.8]. Parameters ---------- @@ -2662,7 +2661,7 @@ def set_ymargin(self, m): if m <= -0.5: raise ValueError("margin must be greater than -0.5") self._ymargin = m - self._request_autoscale_view(scalex=False, scaley=True) + self._request_autoscale_view("y") self.stale = True def margins(self, *margins, x=None, y=None, tight=True): @@ -2697,11 +2696,11 @@ def margins(self, *margins, x=None, y=None, tight=True): only the y-axis. tight : bool or None, default: True - The *tight* parameter is passed to :meth:`autoscale_view`, + The *tight* parameter is passed to `~.axes.Axes.autoscale_view`, which is executed after a margin is changed; the default here is *True*, on the assumption that when margins are specified, no additional padding to match tick marks is - usually desired. Set *tight* to *None* will preserve + usually desired. Setting *tight* to *None* preserves the previous setting. Returns @@ -2717,7 +2716,7 @@ def margins(self, *margins, x=None, y=None, tight=True): before calling :meth:`margins`. """ - if margins and x is not None and y is not None: + if margins and (x is not None or y is not None): raise TypeError('Cannot pass both positional and keyword ' 'arguments for x and/or y.') elif len(margins) == 1: @@ -2779,29 +2778,35 @@ def autoscale(self, enable=True, axis='both', tight=None): True turns autoscaling on, False turns it off. None leaves the autoscaling state unchanged. axis : {'both', 'x', 'y'}, default: 'both' - Which axis to operate on. + The axis on which to operate. (For 3D Axes, *axis* can also be set + to 'z', and 'both' refers to all three axes.) tight : bool or None, default: None If True, first set the margins to zero. Then, this argument is - forwarded to `autoscale_view` (regardless of its value); see the - description of its behavior there. + forwarded to `~.axes.Axes.autoscale_view` (regardless of + its value); see the description of its behavior there. """ if enable is None: scalex = True scaley = True else: - scalex = False - scaley = False if axis in ['x', 'both']: - self._autoscaleXon = bool(enable) - scalex = self._autoscaleXon + self.set_autoscalex_on(bool(enable)) + scalex = self.get_autoscalex_on() + else: + scalex = False if axis in ['y', 'both']: - self._autoscaleYon = bool(enable) - scaley = self._autoscaleYon + self.set_autoscaley_on(bool(enable)) + scaley = self.get_autoscaley_on() + else: + scaley = False if tight and scalex: self._xmargin = 0 if tight and scaley: self._ymargin = 0 - self._request_autoscale_view(tight=tight, scalex=scalex, scaley=scaley) + if scalex: + self._request_autoscale_view("x", tight=tight) + if scaley: + self._request_autoscale_view("y", tight=tight) def autoscale_view(self, tight=None, scalex=True, scaley=True): """ @@ -2824,10 +2829,10 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): behaves like True). scalex : bool, default: True - Whether to autoscale the x axis. + Whether to autoscale the x-axis. scaley : bool, default: True - Whether to autoscale the y axis. + Whether to autoscale the y-axis. Notes ----- @@ -2847,57 +2852,47 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): x_stickies = y_stickies = np.array([]) if self.use_sticky_edges: - # Only iterate over Axes and artists if needed. The check for - # ``hasattr(ax, "_children")`` is necessary because this can be - # called very early in the Axes init process (e.g., for twin axes) - # when these attributes don't even exist yet, in which case - # `get_children` would raise an AttributeError. - if self._xmargin and scalex and self._autoscaleXon: + if self._xmargin and scalex and self.get_autoscalex_on(): x_stickies = np.sort(np.concatenate([ artist.sticky_edges.x for ax in self._shared_axes["x"].get_siblings(self) - if hasattr(ax, "_children") for artist in ax.get_children()])) - if self._ymargin and scaley and self._autoscaleYon: + if self._ymargin and scaley and self.get_autoscaley_on(): y_stickies = np.sort(np.concatenate([ artist.sticky_edges.y for ax in self._shared_axes["y"].get_siblings(self) - if hasattr(ax, "_children") for artist in ax.get_children()])) if self.get_xscale() == 'log': x_stickies = x_stickies[x_stickies > 0] if self.get_yscale() == 'log': y_stickies = y_stickies[y_stickies > 0] - def handle_single_axis(scale, autoscaleon, shared_axes, name, - axis, margin, stickies, set_bound): + def handle_single_axis( + scale, shared_axes, name, axis, margin, stickies, set_bound): - if not (scale and autoscaleon): + if not (scale and axis._get_autoscale_on()): return # nothing to do... shared = shared_axes.get_siblings(self) # Base autoscaling on finite data limits when there is at least one # finite data limit among all the shared_axes and intervals. - # Also, find the minimum minpos for use in the margin calculation. - x_values = [] - minimum_minpos = np.inf - for ax in shared: - x_values.extend(getattr(ax.dataLim, f"interval{name}")) - minimum_minpos = min(minimum_minpos, - getattr(ax.dataLim, f"minpos{name}")) - x_values = np.extract(np.isfinite(x_values), x_values) - if x_values.size >= 1: - x0, x1 = (x_values.min(), x_values.max()) + values = [val for ax in shared + for val in getattr(ax.dataLim, f"interval{name}") + if np.isfinite(val)] + if values: + x0, x1 = (min(values), max(values)) elif getattr(self._viewLim, f"mutated{name}")(): # No data, but explicit viewLims already set: # in mutatedx or mutatedy. return else: x0, x1 = (-np.inf, np.inf) - # If x0 and x1 are non finite, use the locator to figure out - # default limits. + # If x0 and x1 are nonfinite, get default limits from the locator. locator = axis.get_major_locator() x0, x1 = locator.nonsingular(x0, x1) + # Find the minimum minpos for use in the margin calculation. + minimum_minpos = min( + getattr(ax.dataLim, f"minpos{name}") for ax in shared) # Prevent margin addition from crossing a sticky value. A small # tolerance must be added due to floating point issues with @@ -2935,27 +2930,11 @@ def handle_single_axis(scale, autoscaleon, shared_axes, name, # End of definition of internal function 'handle_single_axis'. handle_single_axis( - scalex, self._autoscaleXon, self._shared_axes["x"], 'x', - self.xaxis, self._xmargin, x_stickies, self.set_xbound) + scalex, self._shared_axes["x"], 'x', self.xaxis, self._xmargin, + x_stickies, self.set_xbound) handle_single_axis( - scaley, self._autoscaleYon, self._shared_axes["y"], 'y', - self.yaxis, self._ymargin, y_stickies, self.set_ybound) - - def _get_axis_list(self): - return tuple(getattr(self, f"{name}axis") for name in self._axis_names) - - def _get_axis_map(self): - """ - Return a mapping of `Axis` "names" to `Axis` instances. - - The `Axis` name is derived from the attribute under which the instance - is stored, so e.g. for polar axes, the theta-axis is still named "x" - and the r-axis is still named "y" (for back-compatibility). - - In practice, this means that the entries are typically "x" and "y", and - additionally "z" for 3D Axes. - """ - return dict(zip(self._axis_names, self._get_axis_list())) + scaley, self._shared_axes["y"], 'y', self.yaxis, self._ymargin, + y_stickies, self.set_ybound) def _update_title_position(self, renderer): """ @@ -2968,35 +2947,35 @@ def _update_title_position(self, renderer): titles = (self.title, self._left_title, self._right_title) + # Need to check all our twins too, and all the children as well. + axs = self._twinned_axes.get_siblings(self) + self.child_axes + for ax in self.child_axes: # Child positions must be updated first. + locator = ax.get_axes_locator() + ax.apply_aspect(locator(self, renderer) if locator else None) + for title in titles: x, _ = title.get_position() # need to start again in case of window resizing title.set_position((x, 1.0)) - # need to check all our twins too... - axs = self._twinned_axes.get_siblings(self) - # and all the children - for ax in self.child_axes: - if ax is not None: - locator = ax.get_axes_locator() - if locator: - pos = locator(self, renderer) - ax.apply_aspect(pos) - else: - ax.apply_aspect() - axs = axs + [ax] - top = -np.Inf + top = -np.inf for ax in axs: + bb = None if (ax.xaxis.get_ticks_position() in ['top', 'unknown'] or ax.xaxis.get_label_position() == 'top'): bb = ax.xaxis.get_tightbbox(renderer) - else: + if bb is None: if 'outline' in ax.spines: # Special case for colorbars: bb = ax.spines['outline'].get_window_extent() else: bb = ax.get_window_extent(renderer) - if bb is not None: - top = max(top, bb.ymax) + top = max(top, bb.ymax) + if title.get_text(): + ax.yaxis.get_tightbbox(renderer) # update offsetText + if ax.yaxis.offsetText.get_text(): + bb = ax.yaxis.offsetText.get_tightbbox(renderer) + if bb.intersection(title.get_tightbbox(renderer), bb): + top = bb.ymax if top < 0: # the top of Axes is not even on the figure, so don't try and # automatically place it. @@ -3035,11 +3014,7 @@ def draw(self, renderer): # loop over self and child Axes... locator = self.get_axes_locator() - if locator: - pos = locator(self, renderer) - self.apply_aspect(pos) - else: - self.apply_aspect() + self.apply_aspect(locator(self, renderer) if locator else None) artists = self.get_children() artists.remove(self.patch) @@ -3055,7 +3030,7 @@ def draw(self, renderer): self._update_title_position(renderer) if not self.axison: - for _axis in self._get_axis_list(): + for _axis in self._axis_map.values(): artists.remove(_axis) if not self.figure.canvas.is_saving(): @@ -3070,23 +3045,23 @@ def draw(self, renderer): if (rasterization_zorder is not None and artists and artists[0].zorder < rasterization_zorder): - renderer.start_rasterizing() - artists_rasterized = [a for a in artists - if a.zorder < rasterization_zorder] - artists = [a for a in artists - if a.zorder >= rasterization_zorder] + split_index = np.searchsorted( + [art.zorder for art in artists], + rasterization_zorder, side='right' + ) + artists_rasterized = artists[:split_index] + artists = artists[split_index:] else: artists_rasterized = [] - # the patch draws the background rectangle -- the frame below - # will draw the edges if self.axison and self._frameon: - self.patch.draw(renderer) + if artists_rasterized: + artists_rasterized = [self.patch] + artists_rasterized + else: + artists = [self.patch] + artists if artists_rasterized: - for a in artists_rasterized: - a.draw(renderer) - renderer.stop_rasterizing() + _draw_rasterized(self.figure, artists_rasterized, renderer) mimage._draw_list_compositing_images( renderer, self, artists, self.figure.suppressComposite) @@ -3097,33 +3072,22 @@ def draw(self, renderer): def draw_artist(self, a): """ Efficiently redraw a single artist. - - This method can only be used after an initial draw of the figure, - because that creates and caches the renderer needed here. """ - if self.figure._cachedRenderer is None: - raise AttributeError("draw_artist can only be used after an " - "initial draw which caches the renderer") - a.draw(self.figure._cachedRenderer) + a.draw(self.figure.canvas.get_renderer()) def redraw_in_frame(self): """ Efficiently redraw Axes data, but not axis ticks, labels, etc. - - This method can only be used after an initial draw which caches the - renderer. """ - if self.figure._cachedRenderer is None: - raise AttributeError("redraw_in_frame can only be used after an " - "initial draw which caches the renderer") with ExitStack() as stack: - for artist in [*self._get_axis_list(), + for artist in [*self._axis_map.values(), self.title, self._left_title, self._right_title]: stack.enter_context(artist._cm_set(visible=False)) - self.draw(self.figure._cachedRenderer) + self.draw(self.figure.canvas.get_renderer()) + @_api.deprecated("3.6", alternative="Axes.figure.canvas.get_renderer()") def get_renderer_cache(self): - return self.figure._cachedRenderer + return self.figure.canvas.get_renderer() # Axes rectangle characteristics @@ -3179,21 +3143,18 @@ def set_axisbelow(self, b): -------- get_axisbelow """ + # Check that b is True, False or 'line' self._axisbelow = axisbelow = validate_axisbelow(b) - if axisbelow is True: - zorder = 0.5 - elif axisbelow is False: - zorder = 2.5 - elif axisbelow == "line": - zorder = 1.5 - else: - raise ValueError("Unexpected axisbelow value") - for axis in self._get_axis_list(): + zorder = { + True: 0.5, + 'line': 1.5, + False: 2.5, + }[axisbelow] + for axis in self._axis_map.values(): axis.set_zorder(zorder) self.stale = True - @docstring.dedent_interpd - @_api.rename_parameter("3.5", "b", "visible") + @_docstring.dedent_interpd def grid(self, visible=None, which='major', axis='both', **kwargs): """ Configure the grid lines. @@ -3239,7 +3200,7 @@ def grid(self, visible=None, which='major', axis='both', **kwargs): def ticklabel_format(self, *, axis='both', style='', scilimits=None, useOffset=None, useLocale=None, useMathText=None): r""" - Configure the `.ScalarFormatter` used by default for linear axes. + Configure the `.ScalarFormatter` used by default for linear Axes. If a parameter is not set, the corresponding property of the formatter is left unchanged. @@ -3292,8 +3253,8 @@ def ticklabel_format(self, *, axis='both', style='', scilimits=None, ) from err STYLES = {'sci': True, 'scientific': True, 'plain': False, '': None} is_sci_style = _api.check_getitem(STYLES, style=style) - axis_map = {**{k: [v] for k, v in self._get_axis_map().items()}, - 'both': self._get_axis_list()} + axis_map = {**{k: [v] for k, v in self._axis_map.items()}, + 'both': list(self._axis_map.values())} axises = _api.check_getitem(axis_map, axis=axis) try: for axis in axises: @@ -3321,8 +3282,8 @@ def locator_params(self, axis='both', tight=None, **kwargs): Parameters ---------- axis : {'both', 'x', 'y'}, default: 'both' - The axis on which to operate. - + The axis on which to operate. (For 3D Axes, *axis* can also be + set to 'z', and 'both' refers to all three axes.) tight : bool or None, optional Parameter passed to `~.Axes.autoscale_view`. Default is None, for no change. @@ -3334,7 +3295,7 @@ def locator_params(self, axis='both', tight=None, **kwargs): ``set_params()`` method of the locator. Supported keywords depend on the type of the locator. See for example `~.ticker.MaxNLocator.set_params` for the `.ticker.MaxNLocator` - used by default for linear axes. + used by default for linear. Examples -------- @@ -3344,15 +3305,12 @@ def locator_params(self, axis='both', tight=None, **kwargs): ax.locator_params(tight=True, nbins=4) """ - _api.check_in_list(['x', 'y', 'both'], axis=axis) - update_x = axis in ['x', 'both'] - update_y = axis in ['y', 'both'] - if update_x: - self.xaxis.get_major_locator().set_params(**kwargs) - if update_y: - self.yaxis.get_major_locator().set_params(**kwargs) - self._request_autoscale_view(tight=tight, - scalex=update_x, scaley=update_y) + _api.check_in_list([*self._axis_names, "both"], axis=axis) + for name in self._axis_names: + if axis in [name, "both"]: + loc = self._axis_map[name].get_major_locator() + loc.set_params(**kwargs) + self._request_autoscale_view(name, tight=tight) self.stale = True def tick_params(self, axis='both', **kwargs): @@ -3360,7 +3318,8 @@ def tick_params(self, axis='both', **kwargs): Change the appearance of ticks, tick labels, and gridlines. Tick properties that are not explicitly set using the keyword - arguments remain unchanged unless *reset* is True. + arguments remain unchanged unless *reset* is True. For the current + style settings, see `.Axis.get_tick_params`. Parameters ---------- @@ -3374,7 +3333,7 @@ def tick_params(self, axis='both', **kwargs): Other Parameters ---------------- direction : {'in', 'out', 'inout'} - Puts ticks inside the axes, outside the axes, or both. + Puts ticks inside the Axes, outside the Axes, or both. length : float Tick length in points. width : float @@ -3503,12 +3462,12 @@ def set_xlabel(self, xlabel, fontdict=None, labelpad=None, *, else mpl.rcParams['xaxis.labellocation']) _api.check_in_list(('left', 'center', 'right'), loc=loc) - if loc == 'left': - kwargs.update(x=0, horizontalalignment='left') - elif loc == 'center': - kwargs.update(x=0.5, horizontalalignment='center') - elif loc == 'right': - kwargs.update(x=1, horizontalalignment='right') + x = { + 'left': 0, + 'center': 0.5, + 'right': 1, + }[loc] + kwargs.update(x=x, horizontalalignment=loc) return self.xaxis.set_label_text(xlabel, fontdict, **kwargs) @@ -3585,7 +3544,7 @@ def get_xlim(self): See Also -------- - set_xlim + .Axes.set_xlim set_xbound, get_xbound invert_xaxis, xaxis_inverted @@ -3593,7 +3552,6 @@ def get_xlim(self): ----- The x-axis may be inverted, in which case the *left* value will be greater than the *right* value. - """ return tuple(self.viewLim.intervalx) @@ -3614,6 +3572,7 @@ def _validate_converted_limits(self, limit, convert): raise ValueError("Axis limits cannot be NaN or Inf") return converted_limit + @_api.make_keyword_only("3.6", "emit") def set_xlim(self, left=None, right=None, emit=True, auto=False, *, xmin=None, xmax=None): """ @@ -3643,9 +3602,8 @@ def set_xlim(self, left=None, right=None, emit=True, auto=False, False turns off, None leaves unchanged. xmin, xmax : float, optional - They are equivalent to left and right respectively, - and it is an error to pass both *xmin* and *left* or - *xmax* and *right*. + They are equivalent to left and right respectively, and it is an + error to pass both *xmin* and *left* or *xmax* and *right*. Returns ------- @@ -3680,126 +3638,28 @@ def set_xlim(self, left=None, right=None, emit=True, auto=False, present is on the right. >>> set_xlim(5000, 0) - """ if right is None and np.iterable(left): left, right = left if xmin is not None: if left is not None: - raise TypeError('Cannot pass both `xmin` and `left`') + raise TypeError("Cannot pass both 'left' and 'xmin'") left = xmin if xmax is not None: if right is not None: - raise TypeError('Cannot pass both `xmax` and `right`') + raise TypeError("Cannot pass both 'right' and 'xmax'") right = xmax - - self._process_unit_info([("x", (left, right))], convert=False) - left = self._validate_converted_limits(left, self.convert_xunits) - right = self._validate_converted_limits(right, self.convert_xunits) - - if left is None or right is None: - # Axes init calls set_xlim(0, 1) before get_xlim() can be called, - # so only grab the limits if we really need them. - old_left, old_right = self.get_xlim() - if left is None: - left = old_left - if right is None: - right = old_right - - if self.get_xscale() == 'log' and (left <= 0 or right <= 0): - # Axes init calls set_xlim(0, 1) before get_xlim() can be called, - # so only grab the limits if we really need them. - old_left, old_right = self.get_xlim() - if left <= 0: - _api.warn_external( - 'Attempted to set non-positive left xlim on a ' - 'log-scaled axis.\n' - 'Invalid limit will be ignored.') - left = old_left - if right <= 0: - _api.warn_external( - 'Attempted to set non-positive right xlim on a ' - 'log-scaled axis.\n' - 'Invalid limit will be ignored.') - right = old_right - if left == right: - _api.warn_external( - f"Attempting to set identical left == right == {left} results " - f"in singular transformations; automatically expanding.") - reverse = left > right - left, right = self.xaxis.get_major_locator().nonsingular(left, right) - left, right = self.xaxis.limit_range_for_scale(left, right) - # cast to bool to avoid bad interaction between python 3.8 and np.bool_ - left, right = sorted([left, right], reverse=bool(reverse)) - - self._viewLim.intervalx = (left, right) - # Mark viewlims as no longer stale without triggering an autoscale. - for ax in self._shared_axes["x"].get_siblings(self): - ax._stale_viewlims["x"] = False - if auto is not None: - self._autoscaleXon = bool(auto) - - if emit: - self.callbacks.process('xlim_changed', self) - # Call all of the other x-axes that are shared with this one - for other in self._shared_axes["x"].get_siblings(self): - if other is not self: - other.set_xlim(self.viewLim.intervalx, - emit=False, auto=auto) - if other.figure != self.figure: - other.figure.canvas.draw_idle() - self.stale = True - return left, right + return self.xaxis._set_lim(left, right, emit=emit, auto=auto) get_xscale = _axis_method_wrapper("xaxis", "get_scale") - - def set_xscale(self, value, **kwargs): - """ - Set the x-axis scale. - - Parameters - ---------- - value : {"linear", "log", "symlog", "logit", ...} or `.ScaleBase` - The axis scale type to apply. - - **kwargs - Different keyword arguments are accepted, depending on the scale. - See the respective class keyword arguments: - - - `matplotlib.scale.LinearScale` - - `matplotlib.scale.LogScale` - - `matplotlib.scale.SymmetricalLogScale` - - `matplotlib.scale.LogitScale` - - `matplotlib.scale.FuncScale` - - Notes - ----- - By default, Matplotlib supports the above mentioned scales. - Additionally, custom scales may be registered using - `matplotlib.scale.register_scale`. These scales can then also - be used here. - """ - old_default_lims = (self.xaxis.get_major_locator() - .nonsingular(-np.inf, np.inf)) - g = self.get_shared_x_axes() - for ax in g.get_siblings(self): - ax.xaxis._set_scale(value, **kwargs) - ax._update_transScale() - ax.stale = True - new_default_lims = (self.xaxis.get_major_locator() - .nonsingular(-np.inf, np.inf)) - if old_default_lims != new_default_lims: - # Force autoscaling now, to take advantage of the scale locator's - # nonsingular() before it possibly gets swapped out by the user. - self.autoscale_view(scaley=False) - + set_xscale = _axis_method_wrapper("xaxis", "_set_axes_scale") get_xticks = _axis_method_wrapper("xaxis", "get_ticklocs") set_xticks = _axis_method_wrapper("xaxis", "set_ticks") get_xmajorticklabels = _axis_method_wrapper("xaxis", "get_majorticklabels") get_xminorticklabels = _axis_method_wrapper("xaxis", "get_minorticklabels") get_xticklabels = _axis_method_wrapper("xaxis", "get_ticklabels") set_xticklabels = _axis_method_wrapper( - "xaxis", "_set_ticklabels", + "xaxis", "set_ticklabels", doc_sub={"Axis.set_ticks": "Axes.set_xticks"}) def get_ylabel(self): @@ -3851,12 +3711,12 @@ def set_ylabel(self, ylabel, fontdict=None, labelpad=None, *, else mpl.rcParams['yaxis.labellocation']) _api.check_in_list(('bottom', 'center', 'top'), loc=loc) - if loc == 'bottom': - kwargs.update(y=0, horizontalalignment='left') - elif loc == 'center': - kwargs.update(y=0.5, horizontalalignment='center') - elif loc == 'top': - kwargs.update(y=1, horizontalalignment='right') + y, ha = { + 'bottom': (0, 'left'), + 'center': (0.5, 'center'), + 'top': (1, 'right') + }[loc] + kwargs.update(y=y, horizontalalignment=ha) return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) @@ -3933,7 +3793,7 @@ def get_ylim(self): See Also -------- - set_ylim + .Axes.set_ylim set_ybound, get_ybound invert_yaxis, yaxis_inverted @@ -3941,10 +3801,10 @@ def get_ylim(self): ----- The y-axis may be inverted, in which case the *bottom* value will be greater than the *top* value. - """ return tuple(self.viewLim.intervaly) + @_api.make_keyword_only("3.6", "emit") def set_ylim(self, bottom=None, top=None, emit=True, auto=False, *, ymin=None, ymax=None): """ @@ -3974,9 +3834,8 @@ def set_ylim(self, bottom=None, top=None, emit=True, auto=False, *False* turns off, *None* leaves unchanged. ymin, ymax : float, optional - They are equivalent to bottom and top respectively, - and it is an error to pass both *ymin* and *bottom* or - *ymax* and *top*. + They are equivalent to bottom and top respectively, and it is an + error to pass both *ymin* and *bottom* or *ymax* and *top*. Returns ------- @@ -4016,121 +3875,23 @@ def set_ylim(self, bottom=None, top=None, emit=True, auto=False, bottom, top = bottom if ymin is not None: if bottom is not None: - raise TypeError('Cannot pass both `ymin` and `bottom`') + raise TypeError("Cannot pass both 'bottom' and 'ymin'") bottom = ymin if ymax is not None: if top is not None: - raise TypeError('Cannot pass both `ymax` and `top`') + raise TypeError("Cannot pass both 'top' and 'ymax'") top = ymax - - self._process_unit_info([("y", (bottom, top))], convert=False) - bottom = self._validate_converted_limits(bottom, self.convert_yunits) - top = self._validate_converted_limits(top, self.convert_yunits) - - if bottom is None or top is None: - # Axes init calls set_ylim(0, 1) before get_ylim() can be called, - # so only grab the limits if we really need them. - old_bottom, old_top = self.get_ylim() - if bottom is None: - bottom = old_bottom - if top is None: - top = old_top - - if self.get_yscale() == 'log' and (bottom <= 0 or top <= 0): - # Axes init calls set_xlim(0, 1) before get_xlim() can be called, - # so only grab the limits if we really need them. - old_bottom, old_top = self.get_ylim() - if bottom <= 0: - _api.warn_external( - 'Attempted to set non-positive bottom ylim on a ' - 'log-scaled axis.\n' - 'Invalid limit will be ignored.') - bottom = old_bottom - if top <= 0: - _api.warn_external( - 'Attempted to set non-positive top ylim on a ' - 'log-scaled axis.\n' - 'Invalid limit will be ignored.') - top = old_top - if bottom == top: - _api.warn_external( - f"Attempting to set identical bottom == top == {bottom} " - f"results in singular transformations; automatically " - f"expanding.") - reverse = bottom > top - bottom, top = self.yaxis.get_major_locator().nonsingular(bottom, top) - bottom, top = self.yaxis.limit_range_for_scale(bottom, top) - # cast to bool to avoid bad interaction between python 3.8 and np.bool_ - bottom, top = sorted([bottom, top], reverse=bool(reverse)) - - self._viewLim.intervaly = (bottom, top) - # Mark viewlims as no longer stale without triggering an autoscale. - for ax in self._shared_axes["y"].get_siblings(self): - ax._stale_viewlims["y"] = False - if auto is not None: - self._autoscaleYon = bool(auto) - - if emit: - self.callbacks.process('ylim_changed', self) - # Call all of the other y-axes that are shared with this one - for other in self._shared_axes["y"].get_siblings(self): - if other is not self: - other.set_ylim(self.viewLim.intervaly, - emit=False, auto=auto) - if other.figure != self.figure: - other.figure.canvas.draw_idle() - self.stale = True - return bottom, top + return self.yaxis._set_lim(bottom, top, emit=emit, auto=auto) get_yscale = _axis_method_wrapper("yaxis", "get_scale") - - def set_yscale(self, value, **kwargs): - """ - Set the y-axis scale. - - Parameters - ---------- - value : {"linear", "log", "symlog", "logit", ...} or `.ScaleBase` - The axis scale type to apply. - - **kwargs - Different keyword arguments are accepted, depending on the scale. - See the respective class keyword arguments: - - - `matplotlib.scale.LinearScale` - - `matplotlib.scale.LogScale` - - `matplotlib.scale.SymmetricalLogScale` - - `matplotlib.scale.LogitScale` - - `matplotlib.scale.FuncScale` - - Notes - ----- - By default, Matplotlib supports the above mentioned scales. - Additionally, custom scales may be registered using - `matplotlib.scale.register_scale`. These scales can then also - be used here. - """ - old_default_lims = (self.yaxis.get_major_locator() - .nonsingular(-np.inf, np.inf)) - g = self.get_shared_y_axes() - for ax in g.get_siblings(self): - ax.yaxis._set_scale(value, **kwargs) - ax._update_transScale() - ax.stale = True - new_default_lims = (self.yaxis.get_major_locator() - .nonsingular(-np.inf, np.inf)) - if old_default_lims != new_default_lims: - # Force autoscaling now, to take advantage of the scale locator's - # nonsingular() before it possibly gets swapped out by the user. - self.autoscale_view(scalex=False) - + set_yscale = _axis_method_wrapper("yaxis", "_set_axes_scale") get_yticks = _axis_method_wrapper("yaxis", "get_ticklocs") set_yticks = _axis_method_wrapper("yaxis", "set_ticks") get_ymajorticklabels = _axis_method_wrapper("yaxis", "get_majorticklabels") get_yminorticklabels = _axis_method_wrapper("yaxis", "get_minorticklabels") get_yticklabels = _axis_method_wrapper("yaxis", "get_ticklabels") set_yticklabels = _axis_method_wrapper( - "yaxis", "_set_ticklabels", + "yaxis", "set_ticklabels", doc_sub={"Axis.set_ticks": "Axes.set_yticks"}) xaxis_date = _axis_method_wrapper("xaxis", "axis_date") @@ -4148,7 +3909,7 @@ def format_xdata(self, x): def format_ydata(self, y): """ - Return *y* formatted as an y-value. + Return *y* formatted as a y-value. This function will use the `.fmt_ydata` attribute if it is not None, else will fall back on the yaxis major formatter. @@ -4158,15 +3919,10 @@ def format_ydata(self, y): def format_coord(self, x, y): """Return a format string formatting the *x*, *y* coordinates.""" - if x is None: - xs = '???' - else: - xs = self.format_xdata(x) - if y is None: - ys = '???' - else: - ys = self.format_ydata(y) - return 'x=%s y=%s' % (xs, ys) + return "x={} y={}".format( + "???" if x is None else self.format_xdata(x), + "???" if y is None else self.format_ydata(y), + ) def minorticks_on(self): """ @@ -4232,8 +3988,8 @@ def set_navigate_mode(self, b): """ Set the navigation toolbar button status. - .. warning :: - this is not a user-API function. + .. warning:: + This is not a user-API function. """ self._navigate_mode = b @@ -4519,7 +4275,7 @@ def get_children(self): return [ *self._children, *self.spines.values(), - *self._get_axis_list(), + *self._axis_map.values(), self.title, self._left_title, self._right_title, *self.child_axes, *([self.legend_] if self.legend_ is not None else []), @@ -4535,7 +4291,7 @@ def contains(self, mouseevent): def contains_point(self, point): """ - Return whether *point* (pair of pixel coordinates) is inside the axes + Return whether *point* (pair of pixel coordinates) is inside the Axes patch. """ return self.patch.contains_point(point, radius=1.0) @@ -4551,26 +4307,31 @@ def get_default_bbox_extra_artists(self): artists = self.get_children() + for axis in self._axis_map.values(): + # axis tight bboxes are calculated separately inside + # Axes.get_tightbbox() using for_layout_only=True + artists.remove(axis) if not (self.axison and self._frameon): # don't do bbox on spines if frame not on. for spine in self.spines.values(): artists.remove(spine) - if not self.axison: - for _axis in self._get_axis_list(): - artists.remove(_axis) - artists.remove(self.title) artists.remove(self._left_title) artists.remove(self._right_title) - return [artist for artist in artists - if (artist.get_visible() and artist.get_in_layout())] + # always include types that do not internally implement clipping + # to Axes. may have clip_on set to True and clip_box equivalent + # to ax.bbox but then ignore these properties during draws. + noclip = (_AxesBase, maxis.Axis, + offsetbox.AnnotationBbox, offsetbox.OffsetBox) + return [a for a in artists if a.get_visible() and a.get_in_layout() + and (isinstance(a, noclip) or not a._fully_clipped_to_axes())] - def get_tightbbox(self, renderer, call_axes_locator=True, + def get_tightbbox(self, renderer=None, call_axes_locator=True, bbox_extra_artists=None, *, for_layout_only=False): """ - Return the tight bounding box of the axes, including axis and their + Return the tight bounding box of the Axes, including axis and their decorators (xlabel, title, etc). Artists that have ``artist.set_in_layout(False)`` are not included @@ -4611,36 +4372,21 @@ def get_tightbbox(self, renderer, call_axes_locator=True, """ bb = [] + if renderer is None: + renderer = self.figure._get_renderer() if not self.get_visible(): return None locator = self.get_axes_locator() - if locator and call_axes_locator: - pos = locator(self, renderer) - self.apply_aspect(pos) - else: - self.apply_aspect() - - if self.axison: - if self.xaxis.get_visible(): - try: - bb_xaxis = self.xaxis.get_tightbbox( - renderer, for_layout_only=for_layout_only) - except TypeError: - # in case downstream library has redefined axis: - bb_xaxis = self.xaxis.get_tightbbox(renderer) - if bb_xaxis: - bb.append(bb_xaxis) - if self.yaxis.get_visible(): - try: - bb_yaxis = self.yaxis.get_tightbbox( - renderer, for_layout_only=for_layout_only) - except TypeError: - # in case downstream library has redefined axis: - bb_yaxis = self.yaxis.get_tightbbox(renderer) - if bb_yaxis: - bb.append(bb_yaxis) + self.apply_aspect( + locator(self, renderer) if locator and call_axes_locator else None) + + for axis in self._axis_map.values(): + if self.axison and axis.get_visible(): + ba = martist._get_tightbbox_for_layout_only(axis, renderer) + if ba: + bb.append(ba) self._update_title_position(renderer) axbbox = self.get_window_extent(renderer) bb.append(axbbox) @@ -4661,17 +4407,6 @@ def get_tightbbox(self, renderer, call_axes_locator=True, bbox_artists = self.get_default_bbox_extra_artists() for a in bbox_artists: - # Extra check here to quickly see if clipping is on and - # contained in the Axes. If it is, don't get the tightbbox for - # this artist because this can be expensive: - clip_extent = a._get_clipping_extent_bbox() - if clip_extent is not None: - clip_extent = mtransforms.Bbox.intersection( - clip_extent, axbbox) - if np.all(clip_extent.extents == axbbox.extents): - # clip extent is inside the Axes bbox so don't check - # this artist - continue bbox = a.get_tightbbox(renderer) if (bbox is not None and 0 < bbox.width < np.inf @@ -4682,17 +4417,23 @@ def get_tightbbox(self, renderer, call_axes_locator=True, def _make_twin_axes(self, *args, **kwargs): """Make a twinx Axes of self. This is used for twinx and twiny.""" - # Typically, SubplotBase._make_twin_axes is called instead of this. if 'sharex' in kwargs and 'sharey' in kwargs: - raise ValueError("Twinned Axes may share only one axis") - ax2 = self.figure.add_axes( - self.get_position(True), *args, **kwargs, - axes_locator=_TransformedBoundsLocator( - [0, 0, 1, 1], self.transAxes)) + # The following line is added in v2.2 to avoid breaking Seaborn, + # which currently uses this internal API. + if kwargs["sharex"] is not self and kwargs["sharey"] is not self: + raise ValueError("Twinned Axes may share only one axis") + ss = self.get_subplotspec() + if ss: + twin = self.figure.add_subplot(ss, *args, **kwargs) + else: + twin = self.figure.add_axes( + self.get_position(True), *args, **kwargs, + axes_locator=_TransformedBoundsLocator( + [0, 0, 1, 1], self.transAxes)) self.set_adjustable('datalim') - ax2.set_adjustable('datalim') - self._twinned_axes.join(self, ax2) - return ax2 + twin.set_adjustable('datalim') + self._twinned_axes.join(self, twin) + return twin def twinx(self): """ @@ -4754,9 +4495,119 @@ def twiny(self): return ax2 def get_shared_x_axes(self): - """Return a reference to the shared axes Grouper object for x axes.""" - return self._shared_axes["x"] + """Return an immutable view on the shared x-axes Grouper.""" + return cbook.GrouperView(self._shared_axes["x"]) def get_shared_y_axes(self): - """Return a reference to the shared axes Grouper object for y axes.""" - return self._shared_axes["y"] + """Return an immutable view on the shared y-axes Grouper.""" + return cbook.GrouperView(self._shared_axes["y"]) + + def label_outer(self): + """ + Only show "outer" labels and tick labels. + + x-labels are only kept for subplots on the last row (or first row, if + labels are on the top side); y-labels only for subplots on the first + column (or last column, if labels are on the right side). + """ + self._label_outer_xaxis(check_patch=False) + self._label_outer_yaxis(check_patch=False) + + def _label_outer_xaxis(self, *, check_patch): + # see documentation in label_outer. + if check_patch and not isinstance(self.patch, mpl.patches.Rectangle): + return + ss = self.get_subplotspec() + if not ss: + return + label_position = self.xaxis.get_label_position() + if not ss.is_first_row(): # Remove top label/ticklabels/offsettext. + if label_position == "top": + self.set_xlabel("") + self.xaxis.set_tick_params(which="both", labeltop=False) + if self.xaxis.offsetText.get_position()[1] == 1: + self.xaxis.offsetText.set_visible(False) + if not ss.is_last_row(): # Remove bottom label/ticklabels/offsettext. + if label_position == "bottom": + self.set_xlabel("") + self.xaxis.set_tick_params(which="both", labelbottom=False) + if self.xaxis.offsetText.get_position()[1] == 0: + self.xaxis.offsetText.set_visible(False) + + def _label_outer_yaxis(self, *, check_patch): + # see documentation in label_outer. + if check_patch and not isinstance(self.patch, mpl.patches.Rectangle): + return + ss = self.get_subplotspec() + if not ss: + return + label_position = self.yaxis.get_label_position() + if not ss.is_first_col(): # Remove left label/ticklabels/offsettext. + if label_position == "left": + self.set_ylabel("") + self.yaxis.set_tick_params(which="both", labelleft=False) + if self.yaxis.offsetText.get_position()[0] == 0: + self.yaxis.offsetText.set_visible(False) + if not ss.is_last_col(): # Remove right label/ticklabels/offsettext. + if label_position == "right": + self.set_ylabel("") + self.yaxis.set_tick_params(which="both", labelright=False) + if self.yaxis.offsetText.get_position()[0] == 1: + self.yaxis.offsetText.set_visible(False) + + +def _draw_rasterized(figure, artists, renderer): + """ + A helper function for rasterizing the list of artists. + + The bookkeeping to track if we are or are not in rasterizing mode + with the mixed-mode backends is relatively complicated and is now + handled in the matplotlib.artist.allow_rasterization decorator. + + This helper defines the absolute minimum methods and attributes on a + shim class to be compatible with that decorator and then uses it to + rasterize the list of artists. + + This is maybe too-clever, but allows us to re-use the same code that is + used on normal artists to participate in the "are we rasterizing" + accounting. + + Please do not use this outside of the "rasterize below a given zorder" + functionality of Axes. + + Parameters + ---------- + figure : matplotlib.figure.Figure + The figure all of the artists belong to (not checked). We need this + because we can at the figure level suppress composition and insert each + rasterized artist as its own image. + + artists : List[matplotlib.artist.Artist] + The list of Artists to be rasterized. These are assumed to all + be in the same Figure. + + renderer : matplotlib.backendbases.RendererBase + The currently active renderer + + Returns + ------- + None + + """ + class _MinimalArtist: + def get_rasterized(self): + return True + + def get_agg_filter(self): + return None + + def __init__(self, figure, artists): + self.figure = figure + self.artists = artists + + @martist.allow_rasterization + def draw(self, renderer): + for a in self.artists: + a.draw(renderer) + + return _MinimalArtist(figure, artists).draw(renderer) diff --git a/lib/matplotlib/axes/_secondary_axes.py b/lib/matplotlib/axes/_secondary_axes.py index f6248457d1da..5a65fee1542d 100644 --- a/lib/matplotlib/axes/_secondary_axes.py +++ b/lib/matplotlib/axes/_secondary_axes.py @@ -1,7 +1,8 @@ +import numbers + import numpy as np -from matplotlib import _api -import matplotlib.docstring as docstring +from matplotlib import _api, _docstring import matplotlib.ticker as mticker from matplotlib.axes._base import _AxesBase, _TransformedBoundsLocator from matplotlib.axis import Axis @@ -18,7 +19,7 @@ def __init__(self, parent, orientation, location, functions, **kwargs): While there is no need for this to be private, it should really be called by those higher level functions. """ - + _api.check_in_list(["x", "y"], orientation=orientation) self._functions = functions self._parent = parent self._orientation = orientation @@ -29,7 +30,7 @@ def __init__(self, parent, orientation, location, functions, **kwargs): self._axis = self.xaxis self._locstrings = ['top', 'bottom'] self._otherstrings = ['left', 'right'] - elif self._orientation == 'y': + else: # 'y' super().__init__(self._parent.figure, [0, 1., 0.0001, 1], **kwargs) self._axis = self.yaxis self._locstrings = ['right', 'left'] @@ -41,11 +42,7 @@ def __init__(self, parent, orientation, location, functions, **kwargs): self.set_functions(functions) # styling: - if self._orientation == 'x': - otheraxis = self.yaxis - else: - otheraxis = self.xaxis - + otheraxis = self.yaxis if self._orientation == 'x' else self.xaxis otheraxis.set_major_locator(mticker.NullLocator()) otheraxis.set_ticks_position('none') @@ -64,8 +61,8 @@ def set_alignment(self, align): Parameters ---------- - align : str - either 'top' or 'bottom' for orientation='x' or + align : {'top', 'bottom', 'left', 'right'} + Either 'top' or 'bottom' for orientation='x' or 'left' or 'right' for orientation='y' axis. """ _api.check_in_list(self._locstrings, align=align) @@ -93,23 +90,22 @@ def set_location(self, location): # This puts the rectangle into figure-relative coordinates. if isinstance(location, str): - if location in ['top', 'right']: - self._pos = 1. - elif location in ['bottom', 'left']: - self._pos = 0. - else: - raise ValueError( - f"location must be {self._locstrings[0]!r}, " - f"{self._locstrings[1]!r}, or a float, not {location!r}") - else: + _api.check_in_list(self._locstrings, location=location) + self._pos = 1. if location in ('top', 'right') else 0. + elif isinstance(location, numbers.Real): self._pos = location + else: + raise ValueError( + f"location must be {self._locstrings[0]!r}, " + f"{self._locstrings[1]!r}, or a float, not {location!r}") + self._loc = location if self._orientation == 'x': # An x-secondary axes is like an inset axes from x = 0 to x = 1 and # from y = pos to y = pos + eps, in the parent's transAxes coords. bounds = [0, self._pos, 1., 1e-10] - else: + else: # 'y' bounds = [self._pos, 0, 1e-10, 1] # this locator lets the axes move in the parent axes coordinates. @@ -124,7 +120,7 @@ def apply_aspect(self, position=None): self._set_lims() super().apply_aspect(position) - @docstring.copy(Axis.set_ticks) + @_docstring.copy(Axis.set_ticks) def set_ticks(self, ticks, labels=None, *, minor=False, **kwargs): ret = self._axis.set_ticks(ticks, labels, minor=minor, **kwargs) self.stale = True @@ -162,9 +158,7 @@ def set_functions(self, functions): 'and the second being the inverse') self._set_scale() - # Should be changed to draw(self, renderer) once the deprecation of - # renderer=None and of inframe expires. - def draw(self, *args, **kwargs): + def draw(self, renderer): """ Draw the secondary axes. @@ -176,7 +170,7 @@ def draw(self, *args, **kwargs): self._set_lims() # this sets the scale in case the parent has set its scale. self._set_scale() - super().draw(*args, **kwargs) + super().draw(renderer) def _set_scale(self): """ @@ -186,22 +180,18 @@ def _set_scale(self): if self._orientation == 'x': pscale = self._parent.xaxis.get_scale() set_scale = self.set_xscale - if self._orientation == 'y': + else: # 'y' pscale = self._parent.yaxis.get_scale() set_scale = self.set_yscale if pscale == self._parentscale: return - if pscale == 'log': - defscale = 'functionlog' - else: - defscale = 'function' - if self._ticks_set: ticks = self._axis.get_ticklocs() # need to invert the roles here for the ticks to line up. - set_scale(defscale, functions=self._functions[::-1]) + set_scale('functionlog' if pscale == 'log' else 'function', + functions=self._functions[::-1]) # OK, set_scale sets the locators, but if we've called # axsecond.set_ticks, we want to keep those. @@ -219,7 +209,7 @@ def _set_lims(self): if self._orientation == 'x': lims = self._parent.get_xlim() set_lim = self.set_xlim - if self._orientation == 'y': + else: # 'y' lims = self._parent.get_ylim() set_lim = self.set_ylim order = lims[0] < lims[1] @@ -250,7 +240,7 @@ def set_color(self, color): self.spines.bottom.set_color(color) self.spines.top.set_color(color) self.xaxis.label.set_color(color) - else: + else: # 'y' self.tick_params(axis='y', colors=color) self.spines.left.set_color(color) self.spines.right.set_color(color) @@ -294,4 +284,4 @@ def set_color(self, color): **kwargs : `~matplotlib.axes.Axes` properties. Other miscellaneous axes parameters. ''' -docstring.interpd.update(_secax_docstring=_secax_docstring) +_docstring.interpd.update(_secax_docstring=_secax_docstring) diff --git a/lib/matplotlib/axes/_subplots.py b/lib/matplotlib/axes/_subplots.py deleted file mode 100644 index 18faf2e9d086..000000000000 --- a/lib/matplotlib/axes/_subplots.py +++ /dev/null @@ -1,170 +0,0 @@ -import matplotlib as mpl -from matplotlib import _api, cbook -from matplotlib.axes._axes import Axes -from matplotlib.gridspec import GridSpec, SubplotSpec - - -class SubplotBase: - """ - Base class for subplots, which are :class:`Axes` instances with - additional methods to facilitate generating and manipulating a set - of :class:`Axes` within a figure. - """ - - def __init__(self, fig, *args, **kwargs): - """ - Parameters - ---------- - fig : `matplotlib.figure.Figure` - - *args : tuple (*nrows*, *ncols*, *index*) or int - The array of subplots in the figure has dimensions ``(nrows, - ncols)``, and *index* is the index of the subplot being created. - *index* starts at 1 in the upper left corner and increases to the - right. - - If *nrows*, *ncols*, and *index* are all single digit numbers, then - *args* can be passed as a single 3-digit number (e.g. 234 for - (2, 3, 4)). - - **kwargs - Keyword arguments are passed to the Axes (sub)class constructor. - """ - # _axes_class is set in the subplot_class_factory - self._axes_class.__init__(self, fig, [0, 0, 1, 1], **kwargs) - # This will also update the axes position. - self.set_subplotspec(SubplotSpec._from_subplot_args(fig, args)) - - @_api.deprecated( - "3.4", alternative="get_subplotspec", - addendum="(get_subplotspec returns a SubplotSpec instance.)") - def get_geometry(self): - """Get the subplot geometry, e.g., (2, 2, 3).""" - rows, cols, num1, num2 = self.get_subplotspec().get_geometry() - return rows, cols, num1 + 1 # for compatibility - - @_api.deprecated("3.4", alternative="set_subplotspec") - def change_geometry(self, numrows, numcols, num): - """Change subplot geometry, e.g., from (1, 1, 1) to (2, 2, 3).""" - self._subplotspec = GridSpec(numrows, numcols, - figure=self.figure)[num - 1] - self.update_params() - self.set_position(self.figbox) - - def get_subplotspec(self): - """Return the `.SubplotSpec` instance associated with the subplot.""" - return self._subplotspec - - def set_subplotspec(self, subplotspec): - """Set the `.SubplotSpec`. instance associated with the subplot.""" - self._subplotspec = subplotspec - self._set_position(subplotspec.get_position(self.figure)) - - def get_gridspec(self): - """Return the `.GridSpec` instance associated with the subplot.""" - return self._subplotspec.get_gridspec() - - @_api.deprecated( - "3.4", alternative="get_position()") - @property - def figbox(self): - return self.get_position() - - @_api.deprecated("3.4", alternative="get_gridspec().nrows") - @property - def numRows(self): - return self.get_gridspec().nrows - - @_api.deprecated("3.4", alternative="get_gridspec().ncols") - @property - def numCols(self): - return self.get_gridspec().ncols - - @_api.deprecated("3.4") - def update_params(self): - """Update the subplot position from ``self.figure.subplotpars``.""" - # Now a no-op, as figbox/numRows/numCols are (deprecated) auto-updating - # properties. - - @_api.deprecated("3.4", alternative="ax.get_subplotspec().is_first_row()") - def is_first_row(self): - return self.get_subplotspec().rowspan.start == 0 - - @_api.deprecated("3.4", alternative="ax.get_subplotspec().is_last_row()") - def is_last_row(self): - return self.get_subplotspec().rowspan.stop == self.get_gridspec().nrows - - @_api.deprecated("3.4", alternative="ax.get_subplotspec().is_first_col()") - def is_first_col(self): - return self.get_subplotspec().colspan.start == 0 - - @_api.deprecated("3.4", alternative="ax.get_subplotspec().is_last_col()") - def is_last_col(self): - return self.get_subplotspec().colspan.stop == self.get_gridspec().ncols - - def label_outer(self): - """ - Only show "outer" labels and tick labels. - - x-labels are only kept for subplots on the last row (or first row, if - labels are on the top side); y-labels only for subplots on the first - column (or last column, if labels are on the right side). - """ - self._label_outer_xaxis(check_patch=False) - self._label_outer_yaxis(check_patch=False) - - def _label_outer_xaxis(self, *, check_patch): - # see documentation in label_outer. - if check_patch and not isinstance(self.patch, mpl.patches.Rectangle): - return - ss = self.get_subplotspec() - label_position = self.xaxis.get_label_position() - if not ss.is_first_row(): # Remove top label/ticklabels/offsettext. - if label_position == "top": - self.set_xlabel("") - self.xaxis.set_tick_params(which="both", labeltop=False) - if self.xaxis.offsetText.get_position()[1] == 1: - self.xaxis.offsetText.set_visible(False) - if not ss.is_last_row(): # Remove bottom label/ticklabels/offsettext. - if label_position == "bottom": - self.set_xlabel("") - self.xaxis.set_tick_params(which="both", labelbottom=False) - if self.xaxis.offsetText.get_position()[1] == 0: - self.xaxis.offsetText.set_visible(False) - - def _label_outer_yaxis(self, *, check_patch): - # see documentation in label_outer. - if check_patch and not isinstance(self.patch, mpl.patches.Rectangle): - return - ss = self.get_subplotspec() - label_position = self.yaxis.get_label_position() - if not ss.is_first_col(): # Remove left label/ticklabels/offsettext. - if label_position == "left": - self.set_ylabel("") - self.yaxis.set_tick_params(which="both", labelleft=False) - if self.yaxis.offsetText.get_position()[0] == 0: - self.yaxis.offsetText.set_visible(False) - if not ss.is_last_col(): # Remove right label/ticklabels/offsettext. - if label_position == "right": - self.set_ylabel("") - self.yaxis.set_tick_params(which="both", labelright=False) - if self.yaxis.offsetText.get_position()[0] == 1: - self.yaxis.offsetText.set_visible(False) - - def _make_twin_axes(self, *args, **kwargs): - """Make a twinx axes of self. This is used for twinx and twiny.""" - if 'sharex' in kwargs and 'sharey' in kwargs: - # The following line is added in v2.2 to avoid breaking Seaborn, - # which currently uses this internal API. - if kwargs["sharex"] is not self and kwargs["sharey"] is not self: - raise ValueError("Twinned Axes may share only one axis") - twin = self.figure.add_subplot(self.get_subplotspec(), *args, **kwargs) - self.set_adjustable('datalim') - twin.set_adjustable('datalim') - self._twinned_axes.join(self, twin) - return twin - - -subplot_class_factory = cbook._make_class_factory( - SubplotBase, "{}Subplot", "_axes_class") -Subplot = subplot_class_factory(Axes) # Provided for backward compatibility. diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 10a6c7f747ba..0b4646cd5df7 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1,17 +1,18 @@ """ -Classes for the ticks and x and y axis. +Classes for the ticks and x- and y-axis. """ import datetime import functools import logging +from numbers import Number import numpy as np import matplotlib as mpl -from matplotlib import _api +from matplotlib import _api, cbook import matplotlib.artist as martist -import matplotlib.cbook as cbook +import matplotlib.colors as mcolors import matplotlib.lines as mlines import matplotlib.scale as mscale import matplotlib.text as mtext @@ -54,29 +55,29 @@ class Tick(martist.Artist): The right/top tick label. """ - def __init__(self, axes, loc, *, - size=None, # points - width=None, - color=None, - tickdir=None, - pad=None, - labelsize=None, - labelcolor=None, - zorder=None, - gridOn=None, # defaults to axes.grid depending on - # axes.grid.which - tick1On=True, - tick2On=True, - label1On=True, - label2On=False, - major=True, - labelrotation=0, - grid_color=None, - grid_linestyle=None, - grid_linewidth=None, - grid_alpha=None, - **kw # Other Line2D kwargs applied to gridlines. - ): + def __init__( + self, axes, loc, *, + size=None, # points + width=None, + color=None, + tickdir=None, + pad=None, + labelsize=None, + labelcolor=None, + zorder=None, + gridOn=None, # defaults to axes.grid depending on axes.grid.which + tick1On=True, + tick2On=True, + label1On=True, + label2On=False, + major=True, + labelrotation=0, + grid_color=None, + grid_linestyle=None, + grid_linewidth=None, + grid_alpha=None, + **kwargs, # Other Line2D kwargs applied to gridlines. + ): """ bbox is the Bound2D bounding box in display coords of the Axes loc is the tick location in data coords @@ -143,9 +144,14 @@ def __init__(self, axes, loc, *, grid_linestyle = mpl.rcParams["grid.linestyle"] if grid_linewidth is None: grid_linewidth = mpl.rcParams["grid.linewidth"] - if grid_alpha is None: + if grid_alpha is None and not mcolors._has_alpha_channel(grid_color): + # alpha precedence: kwarg > color alpha > rcParams['grid.alpha'] + # Note: only resolve to rcParams if the color does not have alpha + # otherwise `grid(color=(1, 1, 1, 0.5))` would work like + # grid(color=(1, 1, 1, 0.5), alpha=rcParams['grid.alpha']) + # so the that the rcParams default would override color alpha. grid_alpha = mpl.rcParams["grid.alpha"] - grid_kw = {k[5:]: v for k, v in kw.items()} + grid_kw = {k[5:]: v for k, v in kwargs.items()} self.tick1line = mlines.Line2D( [], [], @@ -183,7 +189,7 @@ def __init__(self, axes, loc, *, self.update_position(loc) @property - @_api.deprecated("3.1", alternative="Tick.label1", pending=True) + @_api.deprecated("3.1", alternative="Tick.label1", removal="3.8") def label(self): return self.label1 @@ -211,16 +217,11 @@ def _apply_tickdir(self, tickdir): self._tickdir = tickdir self._pad = self._base_pad + self.get_tick_padding() - @_api.deprecated("3.5", alternative="`.Axis.set_tick_params`") - def apply_tickdir(self, tickdir): - self._apply_tickdir(tickdir) - self.stale = True - def get_tickdir(self): return self._tickdir def get_tick_padding(self): - """Get the length of the tick outside of the axes.""" + """Get the length of the tick outside of the Axes.""" padding = { 'in': 0.0, 'inout': 0.5, @@ -239,6 +240,7 @@ def set_clip_path(self, clippath, transform=None): self.gridline.set_clip_path(clippath, transform) self.stale = True + @_api.deprecated("3.6") def get_pad_pixels(self): return self.figure.dpi * self._base_pad / 72 @@ -276,13 +278,13 @@ def _get_text2(self): """Get the default Text 2 instance.""" def _get_tick1line(self): - """Get the default line2D instance for tick1.""" + """Get the default `.Line2D` instance for tick1.""" def _get_tick2line(self): - """Get the default line2D instance for tick2.""" + """Get the default `.Line2D` instance for tick2.""" def _get_gridline(self): - """Get the default grid Line2d instance for this tick.""" + """Get the default grid `.Line2D` instance for this tick.""" def get_loc(self): """Return the tick location (data coords) as a scalar.""" @@ -346,23 +348,23 @@ def get_view_interval(self): """ raise NotImplementedError('Derived must override') - def _apply_params(self, **kw): + def _apply_params(self, **kwargs): for name, target in [("gridOn", self.gridline), ("tick1On", self.tick1line), ("tick2On", self.tick2line), ("label1On", self.label1), ("label2On", self.label2)]: - if name in kw: - target.set_visible(kw.pop(name)) - if any(k in kw for k in ['size', 'width', 'pad', 'tickdir']): - self._size = kw.pop('size', self._size) + if name in kwargs: + target.set_visible(kwargs.pop(name)) + if any(k in kwargs for k in ['size', 'width', 'pad', 'tickdir']): + self._size = kwargs.pop('size', self._size) # Width could be handled outside this block, but it is # convenient to leave it here. - self._width = kw.pop('width', self._width) - self._base_pad = kw.pop('pad', self._base_pad) + self._width = kwargs.pop('width', self._width) + self._base_pad = kwargs.pop('pad', self._base_pad) # _apply_tickdir uses _size and _base_pad to make _pad, and also # sets the ticklines markers. - self._apply_tickdir(kw.pop('tickdir', self._tickdir)) + self._apply_tickdir(kwargs.pop('tickdir', self._tickdir)) for line in (self.tick1line, self.tick2line): line.set_markersize(self._size) line.set_markeredgewidth(self._width) @@ -371,25 +373,25 @@ def _apply_params(self, **kw): self.label1.set_transform(trans) trans = self._get_text2_transform()[0] self.label2.set_transform(trans) - tick_kw = {k: v for k, v in kw.items() if k in ['color', 'zorder']} - if 'color' in kw: - tick_kw['markeredgecolor'] = kw['color'] + tick_kw = {k: v for k, v in kwargs.items() if k in ['color', 'zorder']} + if 'color' in kwargs: + tick_kw['markeredgecolor'] = kwargs['color'] self.tick1line.set(**tick_kw) self.tick2line.set(**tick_kw) for k, v in tick_kw.items(): setattr(self, '_' + k, v) - if 'labelrotation' in kw: - self._set_labelrotation(kw.pop('labelrotation')) + if 'labelrotation' in kwargs: + self._set_labelrotation(kwargs.pop('labelrotation')) self.label1.set(rotation=self._labelrotation[1]) self.label2.set(rotation=self._labelrotation[1]) - label_kw = {k[5:]: v for k, v in kw.items() + label_kw = {k[5:]: v for k, v in kwargs.items() if k in ['labelsize', 'labelcolor']} self.label1.set(**label_kw) self.label2.set(**label_kw) - grid_kw = {k[5:]: v for k, v in kw.items() + grid_kw = {k[5:]: v for k, v in kwargs.items() if k in _gridline_param_names} self.gridline.set(**grid_kw) @@ -630,11 +632,15 @@ class Axis(martist.Artist): The minor ticks. """ OFFSETTEXTPAD = 3 + # The class used in _get_tick() to create tick instances. Must either be + # overwritten in subclasses, or subclasses must reimplement _get_tick(). + _tick_class = None def __str__(self): return "{}({},{})".format( type(self).__name__, *self.axes.transAxes.transform((0, 0))) + @_api.make_keyword_only("3.6", name="pickradius") def __init__(self, axes, pickradius=15): """ Parameters @@ -655,7 +661,7 @@ def __init__(self, axes, pickradius=15): self.axes = axes self.major = Ticker() self.minor = Ticker() - self.callbacks = cbook.CallbackRegistry() + self.callbacks = cbook.CallbackRegistry(signals=["units"]) self._autolabelpos = True @@ -678,7 +684,7 @@ def __init__(self, axes, pickradius=15): self._minor_tick_kw = dict() self.clear() - self._set_scale('linear') + self._autoscale_on = True @property def isDefault_majloc(self): @@ -768,9 +774,68 @@ def _set_scale(self, value, **kwargs): self.isDefault_majfmt = True self.isDefault_minfmt = True + # This method is directly wrapped by Axes.set_{x,y}scale. + def _set_axes_scale(self, value, **kwargs): + """ + Set this Axis' scale. + + Parameters + ---------- + value : {"linear", "log", "symlog", "logit", ...} or `.ScaleBase` + The axis scale type to apply. + + **kwargs + Different keyword arguments are accepted, depending on the scale. + See the respective class keyword arguments: + + - `matplotlib.scale.LinearScale` + - `matplotlib.scale.LogScale` + - `matplotlib.scale.SymmetricalLogScale` + - `matplotlib.scale.LogitScale` + - `matplotlib.scale.FuncScale` + + Notes + ----- + By default, Matplotlib supports the above-mentioned scales. + Additionally, custom scales may be registered using + `matplotlib.scale.register_scale`. These scales can then also + be used here. + """ + name, = [name for name, axis in self.axes._axis_map.items() + if axis is self] # The axis name. + old_default_lims = (self.get_major_locator() + .nonsingular(-np.inf, np.inf)) + g = self.axes._shared_axes[name] + for ax in g.get_siblings(self.axes): + ax._axis_map[name]._set_scale(value, **kwargs) + ax._update_transScale() + ax.stale = True + new_default_lims = (self.get_major_locator() + .nonsingular(-np.inf, np.inf)) + if old_default_lims != new_default_lims: + # Force autoscaling now, to take advantage of the scale locator's + # nonsingular() before it possibly gets swapped out by the user. + self.axes.autoscale_view( + **{f"scale{k}": k == name for k in self.axes._axis_names}) + def limit_range_for_scale(self, vmin, vmax): return self._scale.limit_range_for_scale(vmin, vmax, self.get_minpos()) + def _get_autoscale_on(self): + """Return whether this Axis is autoscaled.""" + return self._autoscale_on + + def _set_autoscale_on(self, b): + """ + Set whether this Axis is autoscaled when drawing or by + `.Axes.autoscale_view`. + + Parameters + ---------- + b : bool + """ + self._autoscale_on = b + def get_children(self): return [self.label, self.offsetText, *self.get_major_ticks(), *self.get_minor_ticks()] @@ -800,13 +865,16 @@ def clear(self): - units - registered callbacks """ + self.label._reset_visual_defaults() + self.offsetText._reset_visual_defaults() + self.labelpad = mpl.rcParams['axes.labelpad'] - self.label.set_text('') # self.set_label_text would change isDefault_ + self._init() self._set_scale('linear') # Clear the callback registry for this axis, or it may "leak" - self.callbacks = cbook.CallbackRegistry() + self.callbacks = cbook.CallbackRegistry(signals=["units"]) # whether the grids are on self._major_tick_kw['gridOn'] = ( @@ -822,11 +890,6 @@ def clear(self): self.set_units(None) self.stale = True - @_api.deprecated("3.4", alternative="`.Axis.clear`") - def cla(self): - """Clear this axis.""" - return self.clear() - def reset_ticks(self): """ Re-initialize the major and minor Tick lists. @@ -847,15 +910,21 @@ def reset_ticks(self): except AttributeError: pass - def set_tick_params(self, which='major', reset=False, **kw): + def set_tick_params(self, which='major', reset=False, **kwargs): """ Set appearance parameters for ticks, ticklabels, and gridlines. For documentation of keyword arguments, see :meth:`matplotlib.axes.Axes.tick_params`. + + See Also + -------- + .Axis.get_tick_params + View the current style settings for ticks, ticklabels, and + gridlines. """ _api.check_in_list(['major', 'minor', 'both'], which=which) - kwtrans = self._translate_tick_kw(kw) + kwtrans = self._translate_tick_params(kwargs) # the kwargs are stored in self._major/minor_tick_kw so that any # future new ticks will automatically get them @@ -886,49 +955,123 @@ def set_tick_params(self, which='major', reset=False, **kw): self.stale = True + def get_tick_params(self, which='major'): + """ + Get appearance parameters for ticks, ticklabels, and gridlines. + + .. versionadded:: 3.7 + + Parameters + ---------- + which : {'major', 'minor'}, default: 'major' + The group of ticks for which the parameters are retrieved. + + Returns + ------- + dict + Properties for styling tick elements added to the axis. + + Notes + ----- + This method returns the appearance parameters for styling *new* + elements added to this axis and may be different from the values + on current elements if they were modified directly by the user + (e.g., via ``set_*`` methods on individual tick objects). + + Examples + -------- + :: + + >>> ax.yaxis.set_tick_params(labelsize=30, labelcolor='red', + direction='out', which='major') + >>> ax.yaxis.get_tick_params(which='major') + {'direction': 'out', + 'left': True, + 'right': False, + 'labelleft': True, + 'labelright': False, + 'gridOn': False, + 'labelsize': 30, + 'labelcolor': 'red'} + >>> ax.yaxis.get_tick_params(which='minor') + {'left': True, + 'right': False, + 'labelleft': True, + 'labelright': False, + 'gridOn': False} + + + """ + _api.check_in_list(['major', 'minor'], which=which) + if which == 'major': + return self._translate_tick_params( + self._major_tick_kw, reverse=True + ) + return self._translate_tick_params(self._minor_tick_kw, reverse=True) + @staticmethod - def _translate_tick_kw(kw): + def _translate_tick_params(kw, reverse=False): + """ + Translate the kwargs supported by `.Axis.set_tick_params` to kwargs + supported by `.Tick._apply_params`. + + In particular, this maps axis specific names like 'top', 'left' + to the generic tick1, tick2 logic of the axis. Additionally, there + are some other name translations. + + Returns a new dict of translated kwargs. + + Note: Use reverse=True to translate from those supported by + `.Tick._apply_params` back to those supported by + `.Axis.set_tick_params`. + """ + kw_ = {**kw} + # The following lists may be moved to a more accessible location. - kwkeys = ['size', 'width', 'color', 'tickdir', 'pad', - 'labelsize', 'labelcolor', 'zorder', 'gridOn', - 'tick1On', 'tick2On', 'label1On', 'label2On', - 'length', 'direction', 'left', 'bottom', 'right', 'top', - 'labelleft', 'labelbottom', 'labelright', 'labeltop', - 'labelrotation'] + _gridline_param_names - kwtrans = {} - if 'length' in kw: - kwtrans['size'] = kw.pop('length') - if 'direction' in kw: - kwtrans['tickdir'] = kw.pop('direction') - if 'rotation' in kw: - kwtrans['labelrotation'] = kw.pop('rotation') - if 'left' in kw: - kwtrans['tick1On'] = kw.pop('left') - if 'bottom' in kw: - kwtrans['tick1On'] = kw.pop('bottom') - if 'right' in kw: - kwtrans['tick2On'] = kw.pop('right') - if 'top' in kw: - kwtrans['tick2On'] = kw.pop('top') - if 'labelleft' in kw: - kwtrans['label1On'] = kw.pop('labelleft') - if 'labelbottom' in kw: - kwtrans['label1On'] = kw.pop('labelbottom') - if 'labelright' in kw: - kwtrans['label2On'] = kw.pop('labelright') - if 'labeltop' in kw: - kwtrans['label2On'] = kw.pop('labeltop') - if 'colors' in kw: - c = kw.pop('colors') + allowed_keys = [ + 'size', 'width', 'color', 'tickdir', 'pad', + 'labelsize', 'labelcolor', 'zorder', 'gridOn', + 'tick1On', 'tick2On', 'label1On', 'label2On', + 'length', 'direction', 'left', 'bottom', 'right', 'top', + 'labelleft', 'labelbottom', 'labelright', 'labeltop', + 'labelrotation', + *_gridline_param_names] + + keymap = { + # tick_params key -> axis key + 'length': 'size', + 'direction': 'tickdir', + 'rotation': 'labelrotation', + 'left': 'tick1On', + 'bottom': 'tick1On', + 'right': 'tick2On', + 'top': 'tick2On', + 'labelleft': 'label1On', + 'labelbottom': 'label1On', + 'labelright': 'label2On', + 'labeltop': 'label2On', + } + if reverse: + kwtrans = { + oldkey: kw_.pop(newkey) + for oldkey, newkey in keymap.items() if newkey in kw_ + } + else: + kwtrans = { + newkey: kw_.pop(oldkey) + for oldkey, newkey in keymap.items() if oldkey in kw_ + } + if 'colors' in kw_: + c = kw_.pop('colors') kwtrans['color'] = c kwtrans['labelcolor'] = c # Maybe move the checking up to the caller of this method. - for key in kw: - if key not in kwkeys: + for key in kw_: + if key not in allowed_keys: raise ValueError( "keyword %s is not recognized; valid keywords are %s" - % (key, kwkeys)) - kwtrans.update(kw) + % (key, allowed_keys)) + kwtrans.update(kw_) return kwtrans def set_clip_path(self, clippath, transform=None): @@ -993,10 +1136,9 @@ def set_inverted(self, inverted): the top for the y-axis; the "inverse" direction is increasing to the left for the x-axis and to the bottom for the y-axis. """ - # Currently, must be implemented in subclasses using set_xlim/set_ylim - # rather than generically using set_view_interval, so that shared - # axes get updated as well. - raise NotImplementedError('Derived must override') + a, b = self.get_view_interval() + # cast to bool to avoid bad interaction between python 3.8 and np.bool_ + self._set_lim(*sorted((a, b), reverse=bool(inverted)), auto=None) def set_default_intervals(self): """ @@ -1012,30 +1154,100 @@ def set_default_intervals(self): # attribute, and the derived code below will check for that # and use it if it's available (else just use 0..1) + def _set_lim(self, v0, v1, *, emit=True, auto): + """ + Set view limits. + + This method is a helper for the Axes ``set_xlim``, ``set_ylim``, and + ``set_zlim`` methods. + + Parameters + ---------- + v0, v1 : float + The view limits. (Passing *v0* as a (low, high) pair is not + supported; normalization must occur in the Axes setters.) + emit : bool, default: True + Whether to notify observers of limit change. + auto : bool or None, default: False + Whether to turn on autoscaling of the x-axis. True turns on, False + turns off, None leaves unchanged. + """ + name, = [name for name, axis in self.axes._axis_map.items() + if axis is self] # The axis name. + + self.axes._process_unit_info([(name, (v0, v1))], convert=False) + v0 = self.axes._validate_converted_limits(v0, self.convert_units) + v1 = self.axes._validate_converted_limits(v1, self.convert_units) + + if v0 is None or v1 is None: + # Axes init calls set_xlim(0, 1) before get_xlim() can be called, + # so only grab the limits if we really need them. + old0, old1 = self.get_view_interval() + if v0 is None: + v0 = old0 + if v1 is None: + v1 = old1 + + if self.get_scale() == 'log' and (v0 <= 0 or v1 <= 0): + # Axes init calls set_xlim(0, 1) before get_xlim() can be called, + # so only grab the limits if we really need them. + old0, old1 = self.get_view_interval() + if v0 <= 0: + _api.warn_external(f"Attempt to set non-positive {name}lim on " + f"a log-scaled axis will be ignored.") + v0 = old0 + if v1 <= 0: + _api.warn_external(f"Attempt to set non-positive {name}lim on " + f"a log-scaled axis will be ignored.") + v1 = old1 + if v0 == v1: + _api.warn_external( + f"Attempting to set identical low and high {name}lims " + f"makes transformation singular; automatically expanding.") + reverse = bool(v0 > v1) # explicit cast needed for python3.8+np.bool_. + v0, v1 = self.get_major_locator().nonsingular(v0, v1) + v0, v1 = self.limit_range_for_scale(v0, v1) + v0, v1 = sorted([v0, v1], reverse=bool(reverse)) + + self.set_view_interval(v0, v1, ignore=True) + # Mark viewlims as no longer stale without triggering an autoscale. + for ax in self.axes._shared_axes[name].get_siblings(self.axes): + ax._stale_viewlims[name] = False + if auto is not None: + self._set_autoscale_on(bool(auto)) + + if emit: + self.axes.callbacks.process(f"{name}lim_changed", self.axes) + # Call all of the other axes that are shared with this one + for other in self.axes._shared_axes[name].get_siblings(self.axes): + if other is not self.axes: + other._axis_map[name]._set_lim( + v0, v1, emit=False, auto=auto) + if other.figure != self.figure: + other.figure.canvas.draw_idle() + + self.stale = True + return v0, v1 + def _set_artist_props(self, a): if a is None: return a.set_figure(self.figure) + @_api.deprecated("3.6") def get_ticklabel_extents(self, renderer): - """ - Get the extents of the tick labels on either side - of the axes. - """ - + """Get the extents of the tick labels on either side of the axes.""" ticks_to_draw = self._update_ticks() - ticklabelBoxes, ticklabelBoxes2 = self._get_tick_bboxes(ticks_to_draw, - renderer) - - if len(ticklabelBoxes): - bbox = mtransforms.Bbox.union(ticklabelBoxes) + tlb1, tlb2 = self._get_ticklabel_bboxes(ticks_to_draw, renderer) + if len(tlb1): + bbox1 = mtransforms.Bbox.union(tlb1) else: - bbox = mtransforms.Bbox.from_extents(0, 0, 0, 0) - if len(ticklabelBoxes2): - bbox2 = mtransforms.Bbox.union(ticklabelBoxes2) + bbox1 = mtransforms.Bbox.from_extents(0, 0, 0, 0) + if len(tlb2): + bbox2 = mtransforms.Bbox.union(tlb2) else: bbox2 = mtransforms.Bbox.from_extents(0, 0, 0, 0) - return bbox, bbox2 + return bbox1, bbox2 def _update_ticks(self): """ @@ -1080,14 +1292,16 @@ def _update_ticks(self): return ticks_to_draw - def _get_tick_bboxes(self, ticks, renderer): + def _get_ticklabel_bboxes(self, ticks, renderer=None): """Return lists of bboxes for ticks' label1's and label2's.""" + if renderer is None: + renderer = self.figure._get_renderer() return ([tick.label1.get_window_extent(renderer) for tick in ticks if tick.label1.get_visible()], [tick.label2.get_window_extent(renderer) for tick in ticks if tick.label2.get_visible()]) - def get_tightbbox(self, renderer, *, for_layout_only=False): + def get_tightbbox(self, renderer=None, *, for_layout_only=False): """ Return a bounding box that encloses the axis. It only accounts tick labels, axis label, and offsetText. @@ -1099,24 +1313,23 @@ def get_tightbbox(self, renderer, *, for_layout_only=False): """ if not self.get_visible(): return - + if renderer is None: + renderer = self.figure._get_renderer() ticks_to_draw = self._update_ticks() self._update_label_position(renderer) # go back to just this axis's tick labels - ticklabelBoxes, ticklabelBoxes2 = self._get_tick_bboxes( - ticks_to_draw, renderer) + tlb1, tlb2 = self._get_ticklabel_bboxes(ticks_to_draw, renderer) - self._update_offset_text_position(ticklabelBoxes, ticklabelBoxes2) + self._update_offset_text_position(tlb1, tlb2) self.offsetText.set_text(self.major.formatter.get_offset()) bboxes = [ *(a.get_window_extent(renderer) for a in [self.offsetText] if a.get_visible()), - *ticklabelBoxes, - *ticklabelBoxes2, + *tlb1, *tlb2, ] # take care of label if self.label.get_visible(): @@ -1156,22 +1369,16 @@ def draw(self, renderer, *args, **kwargs): renderer.open_group(__name__, gid=self.get_gid()) ticks_to_draw = self._update_ticks() - ticklabelBoxes, ticklabelBoxes2 = self._get_tick_bboxes(ticks_to_draw, - renderer) + tlb1, tlb2 = self._get_ticklabel_bboxes(ticks_to_draw, renderer) for tick in ticks_to_draw: tick.draw(renderer) - # scale up the axis label box to also find the neighbors, not - # just the tick labels that actually overlap note we need a - # *copy* of the axis label box because we don't want to scale - # the actual bbox - + # Shift label away from axes to avoid overlapping ticklabels. self._update_label_position(renderer) - self.label.draw(renderer) - self._update_offset_text_position(ticklabelBoxes, ticklabelBoxes2) + self._update_offset_text_position(tlb1, tlb2) self.offsetText.set_text(self.major.formatter.get_offset()) self.offsetText.draw(renderer) @@ -1194,10 +1401,11 @@ def get_offset_text(self): def get_pickradius(self): """Return the depth of the axis used by the picker.""" - return self.pickradius + return self._pickradius def get_majorticklabels(self): """Return this Axis' major tick labels, as a list of `~.text.Text`.""" + self._update_ticks() ticks = self.get_major_ticks() labels1 = [tick.label1 for tick in ticks if tick.label1.get_visible()] labels2 = [tick.label2 for tick in ticks if tick.label2.get_visible()] @@ -1205,6 +1413,7 @@ def get_majorticklabels(self): def get_minorticklabels(self): """Return this Axis' minor tick labels, as a list of `~.text.Text`.""" + self._update_ticks() ticks = self.get_minor_ticks() labels1 = [tick.label1 for tick in ticks if tick.label1.get_visible()] labels2 = [tick.label2 for tick in ticks if tick.label2.get_visible()] @@ -1227,13 +1436,6 @@ def get_ticklabels(self, minor=False, which=None): Returns ------- list of `~matplotlib.text.Text` - - Notes - ----- - The tick label strings are not populated until a ``draw`` method has - been called. - - See also: `~.pyplot.draw` and `~.FigureCanvasBase.draw`. """ if which is not None: if which == 'minor': @@ -1279,23 +1481,38 @@ def get_majorticklocs(self): def get_minorticklocs(self): """Return this Axis' minor tick locations in data coordinates.""" # Remove minor ticks duplicating major ticks. - major_locs = self.major.locator() - minor_locs = self.minor.locator() - transform = self._scale.get_transform() - tr_minor_locs = transform.transform(minor_locs) - tr_major_locs = transform.transform(major_locs) - lo, hi = sorted(transform.transform(self.get_view_interval())) - # Use the transformed view limits as scale. 1e-5 is the default rtol - # for np.isclose. - tol = (hi - lo) * 1e-5 + minor_locs = np.asarray(self.minor.locator()) if self.remove_overlapping_locs: - minor_locs = [ - loc for loc, tr_loc in zip(minor_locs, tr_minor_locs) - if ~np.isclose(tr_loc, tr_major_locs, atol=tol, rtol=0).any()] + major_locs = self.major.locator() + transform = self._scale.get_transform() + tr_minor_locs = transform.transform(minor_locs) + tr_major_locs = transform.transform(major_locs) + lo, hi = sorted(transform.transform(self.get_view_interval())) + # Use the transformed view limits as scale. 1e-5 is the default + # rtol for np.isclose. + tol = (hi - lo) * 1e-5 + mask = np.isclose(tr_minor_locs[:, None], tr_major_locs[None, :], + atol=tol, rtol=0).any(axis=1) + minor_locs = minor_locs[~mask] return minor_locs def get_ticklocs(self, *, minor=False): - """Return this Axis' tick locations in data coordinates.""" + """ + Return this Axis' tick locations in data coordinates. + + The locations are not clipped to the current axis limits and hence + may contain locations that are not visible in the output. + + Parameters + ---------- + minor : bool, default: False + True to return the minor tick directions, + False to return the major tick directions. + + Returns + ------- + numpy array of tick locations + """ return self.get_minorticklocs() if minor else self.get_majorticklocs() def get_ticks_direction(self, minor=False): @@ -1321,7 +1538,12 @@ def get_ticks_direction(self, minor=False): def _get_tick(self, major): """Return the default tick instance.""" - raise NotImplementedError('derived must override') + if self._tick_class is None: + raise NotImplementedError( + f"The Axis subclass {self.__class__.__name__} must define " + "_tick_class or reimplement _get_tick()") + tick_kw = self._major_tick_kw if major else self._minor_tick_kw + return self._tick_class(self.axes, 0, major=major, **tick_kw) def _get_tick_label_size(self, axis_name): """ @@ -1391,7 +1613,6 @@ def get_minor_ticks(self, numticks=None): return self.minorTicks[:numticks] - @_api.rename_parameter("3.5", "b", "visible") def grid(self, visible=None, which='major', **kwargs): """ Configure the grid lines. @@ -1423,7 +1644,7 @@ def grid(self, visible=None, which='major', **kwargs): visible = True which = which.lower() _api.check_in_list(['major', 'minor', 'both'], which=which) - gridkw = {'grid_' + item[0]: item[1] for item in kwargs.items()} + gridkw = {f'grid_{name}': value for name, value in kwargs.items()} if which in ['minor', 'both']: gridkw['gridOn'] = (not self._minor_tick_kw['gridOn'] if visible is None else visible) @@ -1437,7 +1658,7 @@ def grid(self, visible=None, which='major', **kwargs): def update_units(self, data): """ Introspect *data* for units converter and update the - axis.converter instance if necessary. Return *True* + ``axis.converter`` instance if necessary. Return *True* if *data* is registered for unit conversion. """ converter = munits.registry.get_converter(data) @@ -1490,8 +1711,17 @@ def _update_axisinfo(self): self.set_default_intervals() def have_units(self): + """ + Return `True` if units or a converter have been set. + """ return self.converter is not None or self.units is not None + def _have_units_and_converter(self): + """ + Return `True` if units and a converter have been set. + """ + return self.converter is not None and self.units is not None + def convert_units(self, x): # If x is natively supported by Matplotlib, doesn't need converting if munits._is_natively_supported(x): @@ -1523,7 +1753,7 @@ def set_units(self, u): """ if u == self.units: return - for name, axis in self.axes._get_axis_map().items(): + for name, axis in self.axes._axis_map.items(): if self is axis: shared = [ getattr(ax, f"{name}axis") @@ -1536,7 +1766,6 @@ def set_units(self, u): axis.units = u axis._update_axisinfo() axis.callbacks.process('units') - axis.callbacks.process('units finalize') axis.stale = True def get_units(self): @@ -1668,24 +1897,34 @@ def set_pickradius(self, pickradius): Parameters ---------- - pickradius : float + pickradius : float + The acceptance radius for containment tests. + See also `.Axis.contains`. """ - self.pickradius = pickradius + if not isinstance(pickradius, Number) or pickradius < 0: + raise ValueError("pick radius should be a distance") + self._pickradius = pickradius + + pickradius = property( + get_pickradius, set_pickradius, doc="The acceptance radius for " + "containment tests. See also `.Axis.contains`.") - # Helper for set_ticklabels. Defining it here makes it pickleable. + # Helper for set_ticklabels. Defining it here makes it picklable. @staticmethod def _format_with_dict(tickd, x, pos): return tickd.get(x, "") - def set_ticklabels(self, ticklabels, *, minor=False, **kwargs): + @_api.rename_parameter("3.7", "ticklabels", "labels") + def set_ticklabels(self, labels, *, minor=False, fontdict=None, **kwargs): r""" - Set the text values of the tick labels. + [*Discouraged*] Set this Axis' tick labels with list of string labels. .. admonition:: Discouraged - The use of this method is discouraged, because of the dependency - on tick positions. In most cases, you'll want to use - ``set_[x/y]ticks(positions, labels)`` instead. + The use of this method is discouraged, because of the dependency on + tick positions. In most cases, you'll want to use + ``Axes.set_[x/y/z]ticks(positions, labels)`` or ``Axis.set_ticks`` + instead. If you are using this method, you should always fix the tick positions before, e.g. by using `.Axis.set_ticks` or by explicitly @@ -1694,12 +1933,23 @@ def set_ticklabels(self, ticklabels, *, minor=False, **kwargs): Parameters ---------- - ticklabels : sequence of str or of `.Text`\s + labels : sequence of str or of `.Text`\s Texts for labeling each tick location in the sequence set by `.Axis.set_ticks`; the number of labels must match the number of locations. + minor : bool If True, set minor ticks instead of major ticks. + + fontdict : dict, optional + A dictionary controlling the appearance of the ticklabels. + The default *fontdict* is:: + + {'fontsize': rcParams['axes.titlesize'], + 'fontweight': rcParams['axes.titleweight'], + 'verticalalignment': 'baseline', + 'horizontalalignment': loc} + **kwargs Text properties. @@ -1709,24 +1959,27 @@ def set_ticklabels(self, ticklabels, *, minor=False, **kwargs): For each tick, includes ``tick.label1`` if it is visible, then ``tick.label2`` if it is visible, in that order. """ - ticklabels = [t.get_text() if hasattr(t, 'get_text') else t - for t in ticklabels] + try: + labels = [t.get_text() if hasattr(t, 'get_text') else t + for t in labels] + except TypeError: + raise TypeError(f"{labels:=} must be a sequence") from None locator = (self.get_minor_locator() if minor else self.get_major_locator()) if isinstance(locator, mticker.FixedLocator): - # Passing [] as a list of ticklabels is often used as a way to - # remove all tick labels, so only error for > 0 ticklabels - if len(locator.locs) != len(ticklabels) and len(ticklabels) != 0: + # Passing [] as a list of labels is often used as a way to + # remove all tick labels, so only error for > 0 labels + if len(locator.locs) != len(labels) and len(labels) != 0: raise ValueError( "The number of FixedLocator locations" f" ({len(locator.locs)}), usually from a call to" " set_ticks, does not match" - f" the number of ticklabels ({len(ticklabels)}).") - tickd = {loc: lab for loc, lab in zip(locator.locs, ticklabels)} + f" the number of labels ({len(labels)}).") + tickd = {loc: lab for loc, lab in zip(locator.locs, labels)} func = functools.partial(self._format_with_dict, tickd) formatter = mticker.FuncFormatter(func) else: - formatter = mticker.FixedFormatter(ticklabels) + formatter = mticker.FixedFormatter(labels) if minor: self.set_minor_formatter(formatter) @@ -1738,15 +1991,17 @@ def set_ticklabels(self, ticklabels, *, minor=False, **kwargs): ticks = self.get_major_ticks(len(locs)) ret = [] + if fontdict is not None: + kwargs.update(fontdict) for pos, (loc, tick) in enumerate(zip(locs, ticks)): tick.update_position(loc) tick_label = formatter(loc, pos) # deal with label1 tick.label1.set_text(tick_label) - tick.label1.update(kwargs) + tick.label1._internal_update(kwargs) # deal with label2 tick.label2.set_text(tick_label) - tick.label2.update(kwargs) + tick.label2._internal_update(kwargs) # only return visible tick labels if tick.label1.get_visible(): ret.append(tick.label1) @@ -1756,53 +2011,13 @@ def set_ticklabels(self, ticklabels, *, minor=False, **kwargs): self.stale = True return ret - # Wrapper around set_ticklabels used to generate Axes.set_x/ytickabels; can - # go away once the API of Axes.set_x/yticklabels becomes consistent. - def _set_ticklabels(self, labels, *, fontdict=None, minor=False, **kwargs): - """ - Set this Axis' labels with list of string labels. - - .. warning:: - This method should only be used after fixing the tick positions - using `.Axis.set_ticks`. Otherwise, the labels may end up in - unexpected positions. - - Parameters - ---------- - labels : list of str - The label texts. - - fontdict : dict, optional - A dictionary controlling the appearance of the ticklabels. - The default *fontdict* is:: - - {'fontsize': rcParams['axes.titlesize'], - 'fontweight': rcParams['axes.titleweight'], - 'verticalalignment': 'baseline', - 'horizontalalignment': loc} - - minor : bool, default: False - Whether to set the minor ticklabels rather than the major ones. - - Returns - ------- - list of `.Text` - The labels. - - Other Parameters - ---------------- - **kwargs : `~.text.Text` properties. - """ - if fontdict is not None: - kwargs.update(fontdict) - return self.set_ticklabels(labels, minor=minor, **kwargs) - def _set_tick_locations(self, ticks, *, minor=False): # see docstring of set_ticks # XXX if the user changes units, the information will be lost here ticks = self.convert_units(ticks) - for name, axis in self.axes._get_axis_map().items(): + locator = mticker.FixedLocator(ticks) # validate ticks early. + for name, axis in self.axes._axis_map.items(): if self is axis: shared = [ getattr(ax, f"{name}axis") @@ -1811,19 +2026,16 @@ def _set_tick_locations(self, ticks, *, minor=False): break else: shared = [self] - for axis in shared: - if len(ticks) > 1: - xleft, xright = axis.get_view_interval() - if xright > xleft: - axis.set_view_interval(min(ticks), max(ticks)) - else: - axis.set_view_interval(max(ticks), min(ticks)) + if len(ticks): + for axis in shared: + # set_view_interval maintains any preexisting inversion. + axis.set_view_interval(min(ticks), max(ticks)) self.axes.stale = True if minor: - self.set_minor_locator(mticker.FixedLocator(ticks)) + self.set_minor_locator(locator) return self.get_minor_ticks(len(ticks)) else: - self.set_major_locator(mticker.FixedLocator(ticks)) + self.set_major_locator(locator) return self.get_major_ticks(len(ticks)) def set_ticks(self, ticks, labels=None, *, minor=False, **kwargs): @@ -1836,9 +2048,17 @@ def set_ticks(self, ticks, labels=None, *, minor=False, **kwargs): Parameters ---------- ticks : list of floats - List of tick locations. + List of tick locations. The axis `.Locator` is replaced by a + `~.ticker.FixedLocator`. + + Some tick formatters will not label arbitrary tick positions; + e.g. log formatters only label decade ticks by default. In + such a case you can set a formatter explicitly on the axis + using `.Axis.set_major_formatter` or provide formatted + *labels* yourself. labels : list of str, optional - List of tick labels. If not set, the labels show the data value. + List of tick labels. If not set, the labels are generated with + the axis tick `.Formatter`. minor : bool, default: False If ``False``, set the major ticks; if ``True``, the minor ticks. **kwargs @@ -1852,6 +2072,9 @@ def set_ticks(self, ticks, labels=None, *, minor=False, **kwargs): other limits, you should set the limits explicitly after setting the ticks. """ + if labels is None and kwargs: + raise ValueError('labels argument cannot be None when ' + 'kwargs are passed') result = self._set_tick_locations(ticks, minor=minor) if labels is not None: self.set_ticklabels(labels, minor=minor, **kwargs) @@ -1862,11 +2085,11 @@ def _get_tick_boxes_siblings(self, renderer): Get the bounding boxes for this `.axis` and its siblings as set by `.Figure.align_xlabels` or `.Figure.align_ylabels`. - By default it just gets bboxes for self. + By default, it just gets bboxes for *self*. """ # Get the Grouper keeping track of x or y label groups for this figure. axis_names = [ - name for name, axis in self.axes._get_axis_map().items() + name for name, axis in self.axes._axis_map.items() if name in self.figure._align_label_groups and axis is self] if len(axis_names) != 1: return [], [] @@ -1874,11 +2097,11 @@ def _get_tick_boxes_siblings(self, renderer): grouper = self.figure._align_label_groups[axis_name] bboxes = [] bboxes2 = [] - # If we want to align labels from other axes: + # If we want to align labels from other Axes: for ax in grouper.get_siblings(self.axes): axis = getattr(ax, f"{axis_name}axis") ticks_to_draw = axis._update_ticks() - tlb, tlb2 = axis._get_tick_bboxes(ticks_to_draw, renderer) + tlb, tlb2 = axis._get_ticklabel_bboxes(ticks_to_draw, renderer) bboxes.extend(tlb) bboxes2.extend(tlb2) return bboxes, bboxes2 @@ -2009,9 +2232,17 @@ def setter(self, vmin, vmax, ignore=False): class XAxis(Axis): __name__ = 'xaxis' axis_name = 'x' #: Read-only name identifying the axis. + _tick_class = XTick def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._init() + + def _init(self): + """ + Initialize the label and offsetText instance values and + `label_position` / `offset_text_position`. + """ # x in axes coords, y in display coords (to be updated at draw time by # _update_label_positions and _update_offset_text_position). self.label.set( @@ -2021,6 +2252,7 @@ def __init__(self, *args, **kwargs): self.axes.transAxes, mtransforms.IdentityTransform()), ) self.label_position = 'bottom' + self.offsetText.set( x=1, y=0, verticalalignment='top', horizontalalignment='right', @@ -2032,7 +2264,7 @@ def __init__(self, *args, **kwargs): self.offset_text_position = 'bottom' def contains(self, mouseevent): - """Test whether the mouse event occurred in the x axis.""" + """Test whether the mouse event occurred in the x-axis.""" inside, info = self._default_contains(mouseevent) if inside is not None: return inside, info @@ -2045,17 +2277,10 @@ def contains(self, mouseevent): return False, {} (l, b), (r, t) = self.axes.transAxes.transform([(0, 0), (1, 1)]) inaxis = 0 <= xaxes <= 1 and ( - b - self.pickradius < y < b or - t < y < t + self.pickradius) + b - self._pickradius < y < b or + t < y < t + self._pickradius) return inaxis, {} - def _get_tick(self, major): - if major: - tick_kw = self._major_tick_kw - else: - tick_kw = self._minor_tick_kw - return XTick(self.axes, 0, major=major, **tick_kw) - def set_label_position(self, position): """ Set the label position (top or bottom) @@ -2088,7 +2313,7 @@ def _update_label_position(self, renderer): spine = self.axes.spines['bottom'] spinebbox = spine.get_window_extent() except KeyError: - # use axes if spine doesn't exist + # use Axes if spine doesn't exist spinebbox = self.axes.bbox bbox = mtransforms.Bbox.union(bboxes + [spinebbox]) bottom = bbox.y0 @@ -2101,7 +2326,7 @@ def _update_label_position(self, renderer): spine = self.axes.spines['top'] spinebbox = spine.get_window_extent() except KeyError: - # use axes if spine doesn't exist + # use Axes if spine doesn't exist spinebbox = self.axes.bbox bbox = mtransforms.Bbox.union(bboxes2 + [spinebbox]) top = bbox.y1 @@ -2134,26 +2359,27 @@ def _update_offset_text_position(self, bboxes, bboxes2): y = top + self.OFFSETTEXTPAD * self.figure.dpi / 72 self.offsetText.set_position((x, y)) + @_api.deprecated("3.6") def get_text_heights(self, renderer): """ Return how much space should be reserved for text above and below the - axes, as a pair of floats. + Axes, as a pair of floats. """ bbox, bbox2 = self.get_ticklabel_extents(renderer) # MGDTODO: Need a better way to get the pad - padPixels = self.majorTicks[0].get_pad_pixels() + pad_pixels = self.majorTicks[0].get_pad_pixels() above = 0.0 if bbox2.height: - above += bbox2.height + padPixels + above += bbox2.height + pad_pixels below = 0.0 if bbox.height: - below += bbox.height + padPixels + below += bbox.height + pad_pixels if self.get_label_position() == 'top': - above += self.label.get_window_extent(renderer).height + padPixels + above += self.label.get_window_extent(renderer).height + pad_pixels else: - below += self.label.get_window_extent(renderer).height + padPixels + below += self.label.get_window_extent(renderer).height + pad_pixels return above, below def set_ticks_position(self, position): @@ -2198,7 +2424,7 @@ def set_ticks_position(self, position): def tick_top(self): """ - Move ticks and ticklabels (if present) to the top of the axes. + Move ticks and ticklabels (if present) to the top of the Axes. """ label = True if 'label1On' in self._major_tick_kw: @@ -2210,7 +2436,7 @@ def tick_top(self): def tick_bottom(self): """ - Move ticks and ticklabels (if present) to the bottom of the axes. + Move ticks and ticklabels (if present) to the bottom of the Axes. """ label = True if 'label1On' in self._major_tick_kw: @@ -2236,12 +2462,6 @@ def get_ticks_position(self): def get_minpos(self): return self.axes.dataLim.minposx - def set_inverted(self, inverted): - # docstring inherited - a, b = self.get_view_interval() - # cast to bool to avoid bad interaction between python 3.8 and np.bool_ - self.axes.set_xlim(sorted((a, b), reverse=bool(inverted)), auto=None) - def set_default_intervals(self): # docstring inherited # only change view if dataLim has not changed and user has @@ -2256,9 +2476,8 @@ def set_default_intervals(self): self.stale = True def get_tick_space(self): - ends = mtransforms.Bbox.from_bounds(0, 0, 1, 1) - ends = ends.transformed(self.axes.transAxes - - self.figure.dpi_scale_trans) + ends = mtransforms.Bbox.unit().transformed( + self.axes.transAxes - self.figure.dpi_scale_trans) length = ends.width * 72 # There is a heuristic here that the aspect ratio of tick text # is no more than 3:1 @@ -2272,9 +2491,17 @@ def get_tick_space(self): class YAxis(Axis): __name__ = 'yaxis' axis_name = 'y' #: Read-only name identifying the axis. + _tick_class = YTick def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._init() + + def _init(self): + """ + Initialize the label and offsetText instance values and + `label_position` / `offset_text_position`. + """ # x in display coords, y in axes coords (to be updated at draw time by # _update_label_positions and _update_offset_text_position). self.label.set( @@ -2310,17 +2537,10 @@ def contains(self, mouseevent): return False, {} (l, b), (r, t) = self.axes.transAxes.transform([(0, 0), (1, 1)]) inaxis = 0 <= yaxes <= 1 and ( - l - self.pickradius < x < l or - r < x < r + self.pickradius) + l - self._pickradius < x < l or + r < x < r + self._pickradius) return inaxis, {} - def _get_tick(self, major): - if major: - tick_kw = self._major_tick_kw - else: - tick_kw = self._minor_tick_kw - return YTick(self.axes, 0, major=major, **tick_kw) - def set_label_position(self, position): """ Set the label position (left or right) @@ -2353,7 +2573,7 @@ def _update_label_position(self, renderer): spine = self.axes.spines['left'] spinebbox = spine.get_window_extent() except KeyError: - # use axes if spine doesn't exist + # use Axes if spine doesn't exist spinebbox = self.axes.bbox bbox = mtransforms.Bbox.union(bboxes + [spinebbox]) left = bbox.x0 @@ -2366,7 +2586,7 @@ def _update_label_position(self, renderer): spine = self.axes.spines['right'] spinebbox = spine.get_window_extent() except KeyError: - # use axes if spine doesn't exist + # use Axes if spine doesn't exist spinebbox = self.axes.bbox bbox = mtransforms.Bbox.union(bboxes2 + [spinebbox]) @@ -2404,22 +2624,23 @@ def set_offset_position(self, position): self.offsetText.set_position((x, y)) self.stale = True + @_api.deprecated("3.6") def get_text_widths(self, renderer): bbox, bbox2 = self.get_ticklabel_extents(renderer) # MGDTODO: Need a better way to get the pad - padPixels = self.majorTicks[0].get_pad_pixels() + pad_pixels = self.majorTicks[0].get_pad_pixels() left = 0.0 if bbox.width: - left += bbox.width + padPixels + left += bbox.width + pad_pixels right = 0.0 if bbox2.width: - right += bbox2.width + padPixels + right += bbox2.width + pad_pixels if self.get_label_position() == 'left': - left += self.label.get_window_extent(renderer).width + padPixels + left += self.label.get_window_extent(renderer).width + pad_pixels else: - right += self.label.get_window_extent(renderer).width + padPixels + right += self.label.get_window_extent(renderer).width + pad_pixels return left, right def set_ticks_position(self, position): @@ -2460,7 +2681,7 @@ def set_ticks_position(self, position): def tick_right(self): """ - Move ticks and ticklabels (if present) to the right of the axes. + Move ticks and ticklabels (if present) to the right of the Axes. """ label = True if 'label1On' in self._major_tick_kw: @@ -2473,7 +2694,7 @@ def tick_right(self): def tick_left(self): """ - Move ticks and ticklabels (if present) to the left of the axes. + Move ticks and ticklabels (if present) to the left of the Axes. """ label = True if 'label1On' in self._major_tick_kw: @@ -2500,12 +2721,6 @@ def get_ticks_position(self): def get_minpos(self): return self.axes.dataLim.minposy - def set_inverted(self, inverted): - # docstring inherited - a, b = self.get_view_interval() - # cast to bool to avoid bad interaction between python 3.8 and np.bool_ - self.axes.set_ylim(sorted((a, b), reverse=bool(inverted)), auto=None) - def set_default_intervals(self): # docstring inherited # only change view if dataLim has not changed and user has @@ -2520,9 +2735,8 @@ def set_default_intervals(self): self.stale = True def get_tick_space(self): - ends = mtransforms.Bbox.from_bounds(0, 0, 1, 1) - ends = ends.transformed(self.axes.transAxes - - self.figure.dpi_scale_trans) + ends = mtransforms.Bbox.unit().transformed( + self.axes.transAxes - self.figure.dpi_scale_trans) length = ends.height * 72 # Having a spacing of at least 2 just looks good. size = self._get_tick_label_size('y') * 2 diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 7a34a02bcb29..6ce8ce61a119 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -32,24 +32,24 @@ import importlib import inspect import io +import itertools import logging import os -import re import sys import time -import traceback from weakref import WeakKeyDictionary import numpy as np import matplotlib as mpl from matplotlib import ( - _api, backend_tools as tools, cbook, colors, docstring, textpath, - tight_bbox, transforms, widgets, get_backend, is_interactive, rcParams) + _api, backend_tools as tools, cbook, colors, _docstring, text, + _tight_bbox, transforms, widgets, get_backend, is_interactive, rcParams) from matplotlib._pylab_helpers import Gcf from matplotlib.backend_managers import ToolManager from matplotlib.cbook import _setattr_cm from matplotlib.path import Path +from matplotlib.texmanager import TexManager from matplotlib.transforms import Affine2D from matplotlib._enums import JoinStyle, CapStyle @@ -69,6 +69,7 @@ 'svgz': 'Scalable Vector Graphics', 'tif': 'Tagged Image File Format', 'tiff': 'Tagged Image File Format', + 'webp': 'WebP Image Format', } _default_backends = { 'eps': 'matplotlib.backends.backend_ps', @@ -84,6 +85,7 @@ 'svgz': 'matplotlib.backends.backend_svg', 'tif': 'matplotlib.backends.backend_agg', 'tiff': 'matplotlib.backends.backend_agg', + 'webp': 'matplotlib.backends.backend_agg', } @@ -151,26 +153,26 @@ class RendererBase: An abstract base class to handle drawing/rendering operations. The following methods must be implemented in the backend for full - functionality (though just implementing :meth:`draw_path` alone would - give a highly capable backend): + functionality (though just implementing `draw_path` alone would give a + highly capable backend): - * :meth:`draw_path` - * :meth:`draw_image` - * :meth:`draw_gouraud_triangle` + * `draw_path` + * `draw_image` + * `draw_gouraud_triangles` The following methods *should* be implemented in the backend for optimization reasons: - * :meth:`draw_text` - * :meth:`draw_markers` - * :meth:`draw_path_collection` - * :meth:`draw_quad_mesh` + * `draw_text` + * `draw_markers` + * `draw_path_collection` + * `draw_quad_mesh` """ def __init__(self): super().__init__() self._texmanager = None - self._text2path = textpath.TextToPath() + self._text2path = text.TextToPath() self._raster_depth = 0 self._rasterizing = False @@ -197,10 +199,9 @@ def draw_markers(self, gc, marker_path, marker_trans, path, """ Draw a marker at each of *path*'s vertices (excluding control points). - This provides a fallback implementation of draw_markers that - makes multiple calls to :meth:`draw_path`. Some backends may - want to override this method in order to draw the marker only - once and reuse it multiple times. + The base (fallback) implementation makes multiple calls to `draw_path`. + Backends may want to override this method in order to draw the marker + only once and reuse it multiple times. Parameters ---------- @@ -220,38 +221,38 @@ def draw_markers(self, gc, marker_path, marker_trans, path, rgbFace) def draw_path_collection(self, gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): """ - Draw a collection of paths selecting drawing properties from - the lists *facecolors*, *edgecolors*, *linewidths*, - *linestyles* and *antialiaseds*. *offsets* is a list of - offsets to apply to each of the paths. The offsets in - *offsets* are first transformed by *offsetTrans* before being - applied. + Draw a collection of *paths*. + + Each path is first transformed by the corresponding entry + in *all_transforms* (a list of (3, 3) matrices) and then by + *master_transform*. They are then translated by the corresponding + entry in *offsets*, which has been first transformed by *offset_trans*. + + *facecolors*, *edgecolors*, *linewidths*, *linestyles*, and + *antialiased* are lists that set the corresponding properties. *offset_position* is unused now, but the argument is kept for backwards compatibility. - This provides a fallback implementation of - :meth:`draw_path_collection` that makes multiple calls to - :meth:`draw_path`. Some backends may want to override this in - order to render each set of path data only once, and then - reference that path multiple times with the different offsets, - colors, styles etc. The generator methods - :meth:`_iter_collection_raw_paths` and - :meth:`_iter_collection` are provided to help with (and - standardize) the implementation across backends. It is highly - recommended to use those generators, so that changes to the - behavior of :meth:`draw_path_collection` can be made globally. + The base (fallback) implementation makes multiple calls to `draw_path`. + Backends may want to override this in order to render each set of + path data only once, and then reference that path multiple times with + the different offsets, colors, styles etc. The generator methods + `_iter_collection_raw_paths` and `_iter_collection` are provided to + help with (and standardize) the implementation across backends. It + is highly recommended to use those generators, so that changes to the + behavior of `draw_path_collection` can be made globally. """ path_ids = self._iter_collection_raw_paths(master_transform, paths, all_transforms) for xo, yo, path_id, gc0, rgbFace in self._iter_collection( - gc, master_transform, all_transforms, list(path_ids), offsets, - offsetTrans, facecolors, edgecolors, linewidths, linestyles, + gc, list(path_ids), offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): path, transform = path_id # Only apply another translation if we have an offset, else we @@ -268,8 +269,10 @@ def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight, coordinates, offsets, offsetTrans, facecolors, antialiased, edgecolors): """ - Fallback implementation of :meth:`draw_quad_mesh` that generates paths - and then calls :meth:`draw_path_collection`. + Draw a quadmesh. + + The base (fallback) implementation converts the quadmesh to paths and + then calls `draw_path_collection`. """ from matplotlib.collections import QuadMesh @@ -283,6 +286,7 @@ def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight, gc, master_transform, paths, [], offsets, offsetTrans, facecolors, edgecolors, linewidths, [], [antialiased], [None], 'screen') + @_api.deprecated("3.7", alternative="draw_gouraud_triangles") def draw_gouraud_triangle(self, gc, points, colors, transform): """ Draw a Gouraud-shaded triangle. @@ -314,25 +318,22 @@ def draw_gouraud_triangles(self, gc, triangles_array, colors_array, transform : `matplotlib.transforms.Transform` An affine transform to apply to the points. """ - transform = transform.frozen() - for tri, col in zip(triangles_array, colors_array): - self.draw_gouraud_triangle(gc, tri, col, transform) + raise NotImplementedError def _iter_collection_raw_paths(self, master_transform, paths, all_transforms): """ - Helper method (along with :meth:`_iter_collection`) to implement - :meth:`draw_path_collection` in a space-efficient manner. + Helper method (along with `_iter_collection`) to implement + `draw_path_collection` in a memory-efficient manner. - This method yields all of the base path/transform - combinations, given a master transform, a list of paths and - list of transforms. + This method yields all of the base path/transform combinations, given a + master transform, a list of paths and list of transforms. The arguments should be exactly what is passed in to - :meth:`draw_path_collection`. + `draw_path_collection`. - The backend should take each yielded path and transform and - create an object that can be referenced (reused) later. + The backend should take each yielded path and transform and create an + object that can be referenced (reused) later. """ Npaths = len(paths) Ntransforms = len(all_transforms) @@ -352,8 +353,8 @@ def _iter_collection_uses_per_path(self, paths, all_transforms, offsets, facecolors, edgecolors): """ Compute how many times each raw path object returned by - _iter_collection_raw_paths would be used when calling - _iter_collection. This is intended for the backend to decide + `_iter_collection_raw_paths` would be used when calling + `_iter_collection`. This is intended for the backend to decide on the tradeoff between using the paths in-line and storing them once and reusing. Rounds up in case the number of uses is not the same for every path. @@ -365,24 +366,22 @@ def _iter_collection_uses_per_path(self, paths, all_transforms, N = max(Npath_ids, len(offsets)) return (N + Npath_ids - 1) // Npath_ids - def _iter_collection(self, gc, master_transform, all_transforms, - path_ids, offsets, offsetTrans, facecolors, + def _iter_collection(self, gc, path_ids, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): """ - Helper method (along with :meth:`_iter_collection_raw_paths`) to - implement :meth:`draw_path_collection` in a space-efficient manner. + Helper method (along with `_iter_collection_raw_paths`) to implement + `draw_path_collection` in a memory-efficient manner. - This method yields all of the path, offset and graphics - context combinations to draw the path collection. The caller - should already have looped over the results of - :meth:`_iter_collection_raw_paths` to draw this collection. + This method yields all of the path, offset and graphics context + combinations to draw the path collection. The caller should already + have looped over the results of `_iter_collection_raw_paths` to draw + this collection. The arguments should be the same as that passed into - :meth:`draw_path_collection`, with the exception of - *path_ids*, which is a list of arbitrary objects that the - backend will use to reference one of the paths created in the - :meth:`_iter_collection_raw_paths` stage. + `draw_path_collection`, with the exception of *path_ids*, which is a + list of arbitrary objects that the backend will use to reference one of + the paths created in the `_iter_collection_raw_paths` stage. Each yielded result is of the form:: @@ -392,7 +391,6 @@ def _iter_collection(self, gc, master_transform, all_transforms, *path_ids*; *gc* is a graphics context and *rgbFace* is a color to use for filling the path. """ - Ntransforms = len(all_transforms) Npaths = len(path_ids) Noffsets = len(offsets) N = max(Npaths, Noffsets) @@ -400,58 +398,55 @@ def _iter_collection(self, gc, master_transform, all_transforms, Nedgecolors = len(edgecolors) Nlinewidths = len(linewidths) Nlinestyles = len(linestyles) - Naa = len(antialiaseds) Nurls = len(urls) if (Nfacecolors == 0 and Nedgecolors == 0) or Npaths == 0: return - if Noffsets: - toffsets = offsetTrans.transform(offsets) gc0 = self.new_gc() gc0.copy_properties(gc) - if Nfacecolors == 0: - rgbFace = None + def cycle_or_default(seq, default=None): + # Cycle over *seq* if it is not empty; else always yield *default*. + return (itertools.cycle(seq) if len(seq) + else itertools.repeat(default)) + + pathids = cycle_or_default(path_ids) + toffsets = cycle_or_default(offset_trans.transform(offsets), (0, 0)) + fcs = cycle_or_default(facecolors) + ecs = cycle_or_default(edgecolors) + lws = cycle_or_default(linewidths) + lss = cycle_or_default(linestyles) + aas = cycle_or_default(antialiaseds) + urls = cycle_or_default(urls) if Nedgecolors == 0: gc0.set_linewidth(0.0) - xo, yo = 0, 0 - for i in range(N): - path_id = path_ids[i % Npaths] - if Noffsets: - xo, yo = toffsets[i % Noffsets] + for pathid, (xo, yo), fc, ec, lw, ls, aa, url in itertools.islice( + zip(pathids, toffsets, fcs, ecs, lws, lss, aas, urls), N): if not (np.isfinite(xo) and np.isfinite(yo)): continue - if Nfacecolors: - rgbFace = facecolors[i % Nfacecolors] if Nedgecolors: if Nlinewidths: - gc0.set_linewidth(linewidths[i % Nlinewidths]) + gc0.set_linewidth(lw) if Nlinestyles: - gc0.set_dashes(*linestyles[i % Nlinestyles]) - fg = edgecolors[i % Nedgecolors] - if len(fg) == 4: - if fg[3] == 0.0: - gc0.set_linewidth(0) - else: - gc0.set_foreground(fg) + gc0.set_dashes(*ls) + if len(ec) == 4 and ec[3] == 0.0: + gc0.set_linewidth(0) else: - gc0.set_foreground(fg) - if rgbFace is not None and len(rgbFace) == 4: - if rgbFace[3] == 0: - rgbFace = None - gc0.set_antialiased(antialiaseds[i % Naa]) + gc0.set_foreground(ec) + if fc is not None and len(fc) == 4 and fc[3] == 0: + fc = None + gc0.set_antialiased(aa) if Nurls: - gc0.set_url(urls[i % Nurls]) - - yield xo, yo, path_id, gc0, rgbFace + gc0.set_url(url) + yield xo, yo, pathid, gc0, fc gc0.restore() def get_image_magnification(self): """ - Get the factor by which to magnify images passed to :meth:`draw_image`. + Get the factor by which to magnify images passed to `draw_image`. Allows a backend to have images at a different resolution to other artists. """ @@ -479,14 +474,13 @@ def draw_image(self, gc, x, y, im, transform=None): transform : `matplotlib.transforms.Affine2DBase` If and only if the concrete backend is written such that - :meth:`option_scale_image` returns ``True``, an affine - transformation (i.e., an `.Affine2DBase`) *may* be passed to - :meth:`draw_image`. The translation vector of the transformation - is given in physical units (i.e., dots or pixels). Note that - the transformation does not override *x* and *y*, and has to be - applied *before* translating the result by *x* and *y* (this can - be accomplished by adding *x* and *y* to the translation vector - defined by *transform*). + `option_scale_image` returns ``True``, an affine transformation + (i.e., an `.Affine2DBase`) *may* be passed to `draw_image`. The + translation vector of the transformation is given in physical units + (i.e., dots or pixels). Note that the transformation does not + override *x* and *y*, and has to be applied *before* translating + the result by *x* and *y* (this can be accomplished by adding *x* + and *y* to the translation vector defined by *transform*). """ raise NotImplementedError @@ -502,19 +496,37 @@ def option_image_nocomposite(self): def option_scale_image(self): """ - Return whether arbitrary affine transformations in :meth:`draw_image` - are supported (True for most vector backends). + Return whether arbitrary affine transformations in `draw_image` are + supported (True for most vector backends). """ return False def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): """ + Draw a TeX instance. + + Parameters + ---------- + gc : `.GraphicsContextBase` + The graphics context. + x : float + The x location of the text in display coords. + y : float + The y location of the text baseline in display coords. + s : str + The TeX text string. + prop : `~matplotlib.font_manager.FontProperties` + The font properties. + angle : float + The rotation angle in degrees anti-clockwise. + mtext : `matplotlib.text.Text` + The original text object to be rendered. """ self._draw_text_as_path(gc, x, y, s, prop, angle, ismath="TeX") def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): """ - Draw the text instance. + Draw a text instance. Parameters ---------- @@ -526,10 +538,12 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): The y location of the text baseline in display coords. s : str The text string. - prop : `matplotlib.font_manager.FontProperties` + prop : `~matplotlib.font_manager.FontProperties` The font properties. angle : float The rotation angle in degrees anti-clockwise. + ismath : bool or "TeX" + If True, use mathtext parser. If "TeX", use tex for rendering. mtext : `matplotlib.text.Text` The original text object to be rendered. @@ -555,12 +569,18 @@ def _get_text_path_transform(self, x, y, s, prop, angle, ismath): Parameters ---------- - prop : `matplotlib.font_manager.FontProperties` - The font property. + x : float + The x location of the text in display coords. + y : float + The y location of the text baseline in display coords. s : str The text to be converted. + prop : `~matplotlib.font_manager.FontProperties` + The font property. + angle : float + Angle in degrees to render the text at. ismath : bool or "TeX" - If True, use mathtext parser. If "TeX", use *usetex* mode. + If True, use mathtext parser. If "TeX", use tex for rendering. """ text2path = self._text2path @@ -585,18 +605,22 @@ def _get_text_path_transform(self, x, y, s, prop, angle, ismath): def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): """ - Draw the text by converting them to paths using textpath module. + Draw the text by converting them to paths using `.TextToPath`. Parameters ---------- - prop : `matplotlib.font_manager.FontProperties` - The font property. + x : float + The x location of the text in display coords. + y : float + The y location of the text baseline in display coords. s : str The text to be converted. - usetex : bool - Whether to use usetex mode. + prop : `~matplotlib.font_manager.FontProperties` + The font property. + angle : float + Angle in degrees to render the text at. ismath : bool or "TeX" - If True, use mathtext parser. If "TeX", use *usetex* mode. + If True, use mathtext parser. If "TeX", use tex for rendering. """ path, transform = self._get_text_path_transform( x, y, s, prop, angle, ismath) @@ -610,13 +634,12 @@ def get_text_width_height_descent(self, s, prop, ismath): to the baseline), in display coords, of the string *s* with `.FontProperties` *prop*. """ + fontsize = prop.get_size_in_points() + if ismath == 'TeX': - # todo: handle props - texmanager = self._text2path.get_texmanager() - fontsize = prop.get_size_in_points() - w, h, d = texmanager.get_text_width_height_descent( + # todo: handle properties + return self.get_texmanager().get_text_width_height_descent( s, fontsize, renderer=self) - return w, h, d dpi = self.points_to_pixels(72) if ismath: @@ -625,8 +648,7 @@ def get_text_width_height_descent(self, s, prop, ismath): flags = self._text2path._get_hinting_flag() font = self._text2path._get_font(prop) - size = prop.get_size_in_points() - font.set_size(size, dpi) + font.set_size(fontsize, dpi) # the width and height of unrotated string font.set_text(s, 0.0, flags=flags) w, h = font.get_width_height() @@ -640,7 +662,7 @@ def flipy(self): """ Return whether y values increase from top to bottom. - Note that this only affects drawing of texts and images. + Note that this only affects drawing of texts. """ return True @@ -651,7 +673,6 @@ def get_canvas_width_height(self): def get_texmanager(self): """Return the `.TexManager` instance.""" if self._texmanager is None: - from matplotlib.texmanager import TexManager self._texmanager = TexManager() return self._texmanager @@ -819,12 +840,9 @@ def get_dashes(self): """ Return the dash style as an (offset, dash-list) pair. - The dash list is a even-length list that gives the ink on, ink off in - points. See p. 107 of to PostScript `blue book`_ for more info. + See `.set_dashes` for details. Default value is (None, None). - - .. _blue book: https://www-cdf.fnal.gov/offline/PostScript/BLUEBOOK.PDF """ return self._dashes @@ -888,7 +906,7 @@ def set_antialiased(self, b): # Use ints to make life easier on extension code trying to read the gc. self._antialiased = int(bool(b)) - @docstring.interpd + @_docstring.interpd def set_capstyle(self, cs): """ Set how to draw endpoints of lines. @@ -915,21 +933,27 @@ def set_dashes(self, dash_offset, dash_list): Parameters ---------- dash_offset : float - The offset (usually 0). + Distance, in points, into the dash pattern at which to + start the pattern. It is usually set to 0. dash_list : array-like or None - The on-off sequence as points. None specifies a solid line. + The on-off sequence as points. None specifies a solid line. All + values must otherwise be non-negative (:math:`\\ge 0`). Notes ----- - See p. 107 of to PostScript `blue book`_ for more info. - - .. _blue book: https://www-cdf.fnal.gov/offline/PostScript/BLUEBOOK.PDF + See p. 666 of the PostScript + `Language Reference + `_ + for more info. """ if dash_list is not None: dl = np.asarray(dash_list) if np.any(dl < 0.0): raise ValueError( - "All values in the dash list must be positive") + "All values in the dash list must be non-negative") + if dl.size and not np.any(dl > 0.0): + raise ValueError( + 'At least one value in the dash list must be positive') self._dashes = dash_offset, dash_list def set_foreground(self, fg, isRGBA=False): @@ -952,7 +976,7 @@ def set_foreground(self, fg, isRGBA=False): else: self._rgb = colors.to_rgba(fg) - @docstring.interpd + @_docstring.interpd def set_joinstyle(self, js): """ Set how to draw connections between line segments. @@ -1224,11 +1248,16 @@ class Event: guiEvent The GUI event that triggered the Matplotlib event. """ + def __init__(self, name, canvas, guiEvent=None): self.name = name self.canvas = canvas self.guiEvent = guiEvent + def _process(self): + """Generate an event with name ``self.name`` on ``self.canvas``.""" + self.canvas.callbacks.process(self.name, self) + class DrawEvent(Event): """ @@ -1271,6 +1300,7 @@ class ResizeEvent(Event): height : int Height of the canvas in pixels. """ + def __init__(self, name, canvas): super().__init__(name, canvas) self.width, self.height = canvas.get_width_height() @@ -1296,23 +1326,25 @@ class LocationEvent(Event): xdata, ydata : float or None Data coordinates of the mouse within *inaxes*, or *None* if the mouse is not over an Axes. + modifiers : frozenset + The keyboard modifiers currently being pressed (except for KeyEvent). """ - lastevent = None # the last event that was triggered before this one + lastevent = None # The last event processed so far. - def __init__(self, name, canvas, x, y, guiEvent=None): + def __init__(self, name, canvas, x, y, guiEvent=None, *, modifiers=None): super().__init__(name, canvas, guiEvent=guiEvent) # x position - pixels from left of canvas self.x = int(x) if x is not None else x # y position - pixels from right of canvas self.y = int(y) if y is not None else y - self.inaxes = None # the Axes instance if mouse us over axes + self.inaxes = None # the Axes instance the mouse is over self.xdata = None # x coord of mouse in data coords self.ydata = None # y coord of mouse in data coords + self.modifiers = frozenset(modifiers if modifiers is not None else []) if x is None or y is None: - # cannot check if event was in axes if no (x, y) info - self._update_enter_leave() + # cannot check if event was in Axes if no (x, y) info return if self.canvas.mouse_grabber is None: @@ -1330,34 +1362,6 @@ def __init__(self, name, canvas, x, y, guiEvent=None): self.xdata = xdata self.ydata = ydata - self._update_enter_leave() - - def _update_enter_leave(self): - """Process the figure/axes enter leave events.""" - if LocationEvent.lastevent is not None: - last = LocationEvent.lastevent - if last.inaxes != self.inaxes: - # process axes enter/leave events - try: - if last.inaxes is not None: - last.canvas.callbacks.process('axes_leave_event', last) - except Exception: - pass - # See ticket 2901582. - # I think this is a valid exception to the rule - # against catching all exceptions; if anything goes - # wrong, we simply want to move on and process the - # current event. - if self.inaxes is not None: - self.canvas.callbacks.process('axes_enter_event', self) - - else: - # process a figure enter event - if self.inaxes is not None: - self.canvas.callbacks.process('axes_enter_event', self) - - LocationEvent.lastevent = self - class MouseButton(IntEnum): LEFT = 1 @@ -1379,11 +1383,15 @@ class MouseEvent(LocationEvent): ---------- button : None or `MouseButton` or {'up', 'down'} The button pressed. 'up' and 'down' are used for scroll events. + Note that LEFT and RIGHT actually refer to the "primary" and "secondary" buttons, i.e. if the user inverts their left and right buttons ("left-handed setting") then the LEFT button will be the one physically on the right. + If this is unset, *name* is "scroll_event", and *step* is nonzero, then + this will be set to "up" or "down" depending on the sign of *step*. + key : None or str The key pressed when the mouse event triggered, e.g. 'shift'. See `KeyEvent`. @@ -1392,7 +1400,9 @@ class MouseEvent(LocationEvent): This key is currently obtained from the last 'key_press_event' or 'key_release_event' that occurred within the canvas. Thus, if the last change of keyboard state occurred while the canvas did not have - focus, this attribute will be wrong. + focus, this attribute will be wrong. On the other hand, the + ``modifiers`` attribute should always be correct, but it can only + report on modifier keys. step : float The number of scroll steps (positive for 'up', negative for 'down'). @@ -1414,18 +1424,21 @@ def on_press(event): """ def __init__(self, name, canvas, x, y, button=None, key=None, - step=0, dblclick=False, guiEvent=None): + step=0, dblclick=False, guiEvent=None, *, modifiers=None): + super().__init__( + name, canvas, x, y, guiEvent=guiEvent, modifiers=modifiers) if button in MouseButton.__members__.values(): button = MouseButton(button) + if name == "scroll_event" and button is None: + if step > 0: + button = "up" + elif step < 0: + button = "down" self.button = button self.key = key self.step = step self.dblclick = dblclick - # super-init is deferred to the end because it calls back on - # 'axes_enter_event', which requires a fully initialized event. - super().__init__(name, canvas, x, y, guiEvent=guiEvent) - def __str__(self): return (f"{self.name}: " f"xy=({self.x}, {self.y}) xydata=({self.xdata}, {self.ydata}) " @@ -1467,12 +1480,15 @@ def on_pick(event): line = event.artist xdata, ydata = line.get_data() ind = event.ind - print('on pick line:', np.array([xdata[ind], ydata[ind]]).T) + print(f'on pick line: {xdata[ind]:.3f}, {ydata[ind]:.3f}') cid = fig.canvas.mpl_connect('pick_event', on_pick) """ + def __init__(self, name, canvas, mouseevent, artist, guiEvent=None, **kwargs): + if guiEvent is None: + guiEvent = mouseevent.guiEvent super().__init__(name, canvas, guiEvent) self.mouseevent = mouseevent self.artist = artist @@ -1510,16 +1526,51 @@ def on_key(event): cid = fig.canvas.mpl_connect('key_press_event', on_key) """ + def __init__(self, name, canvas, key, x=0, y=0, guiEvent=None): - self.key = key - # super-init deferred to the end: callback errors if called before super().__init__(name, canvas, x, y, guiEvent=guiEvent) + self.key = key + + +# Default callback for key events. +def _key_handler(event): + # Dead reckoning of key. + if event.name == "key_press_event": + event.canvas._key = event.key + elif event.name == "key_release_event": + event.canvas._key = None + + +# Default callback for mouse events. +def _mouse_handler(event): + # Dead-reckoning of button and key. + if event.name == "button_press_event": + event.canvas._button = event.button + elif event.name == "button_release_event": + event.canvas._button = None + elif event.name == "motion_notify_event" and event.button is None: + event.button = event.canvas._button + if event.key is None: + event.key = event.canvas._key + # Emit axes_enter/axes_leave. + if event.name == "motion_notify_event": + last = LocationEvent.lastevent + last_axes = last.inaxes if last is not None else None + if last_axes != event.inaxes: + if last_axes is not None: + try: + last.canvas.callbacks.process("axes_leave_event", last) + except Exception: + pass # The last canvas may already have been torn down. + if event.inaxes is not None: + event.canvas.callbacks.process("axes_enter_event", event) + LocationEvent.lastevent = ( + None if event.name == "figure_leave_event" else event) def _get_renderer(figure, print_method=None): """ - Get the renderer that would be used to save a `.Figure`, and cache it on - the figure. + Get the renderer that would be used to save a `.Figure`. If you need a renderer without any active draw methods use renderer._draw_disabled to temporary patch them out at your call site. @@ -1532,24 +1583,21 @@ class Done(Exception): def _draw(renderer): raise Done(renderer) - with cbook._setattr_cm(figure, draw=_draw): - orig_canvas = figure.canvas + with cbook._setattr_cm(figure, draw=_draw), ExitStack() as stack: if print_method is None: fmt = figure.canvas.get_default_filetype() # Even for a canvas' default output type, a canvas switch may be # needed, e.g. for FigureCanvasBase. - print_method = getattr( - figure.canvas._get_output_canvas(None, fmt), f"print_{fmt}") + print_method = stack.enter_context( + figure.canvas._switch_canvas_and_return_print_method(fmt)) try: print_method(io.BytesIO()) except Done as exc: - renderer, = figure._cachedRenderer, = exc.args + renderer, = exc.args return renderer else: raise RuntimeError(f"{print_method} did not call Figure.draw, so " f"no renderer is available") - finally: - figure.canvas = orig_canvas def _no_output_draw(figure): @@ -1572,84 +1620,6 @@ def _is_non_interactive_terminal_ipython(ip): and getattr(ip.parent, 'interact', None) is False) -def _check_savefig_extra_args(func=None, extra_kwargs=()): - """ - Decorator for the final print_* methods that accept keyword arguments. - - If any unused keyword arguments are left, this decorator will warn about - them, and as part of the warning, will attempt to specify the function that - the user actually called, instead of the backend-specific method. If unable - to determine which function the user called, it will specify `.savefig`. - - For compatibility across backends, this does not warn about keyword - arguments added by `FigureCanvasBase.print_figure` for use in a subset of - backends, because the user would not have added them directly. - """ - - if func is None: - return functools.partial(_check_savefig_extra_args, - extra_kwargs=extra_kwargs) - - old_sig = inspect.signature(func) - - @functools.wraps(func) - def wrapper(*args, **kwargs): - name = 'savefig' # Reasonable default guess. - public_api = re.compile( - r'^savefig|print_[A-Za-z0-9]+|_no_output_draw$' - ) - seen_print_figure = False - if sys.version_info < (3, 11): - current_frame = None - else: - import inspect - current_frame = inspect.currentframe() - for frame, line in traceback.walk_stack(current_frame): - if frame is None: - # when called in embedded context may hit frame is None. - break - # Work around sphinx-gallery not setting __name__. - frame_name = frame.f_globals.get('__name__', '') - if re.match(r'\A(matplotlib|mpl_toolkits)(\Z|\.(?!tests\.))', - frame_name): - name = frame.f_code.co_name - if public_api.match(name): - if name in ('print_figure', '_no_output_draw'): - seen_print_figure = True - - elif frame_name == '_functools': - # PyPy adds an extra frame without module prefix for this - # functools wrapper, which we ignore to assume we're still in - # Matplotlib code. - continue - else: - break - - accepted_kwargs = {*old_sig.parameters, *extra_kwargs} - if seen_print_figure: - for kw in ['dpi', 'facecolor', 'edgecolor', 'orientation', - 'bbox_inches_restore']: - # Ignore keyword arguments that are passed in by print_figure - # for the use of other renderers. - if kw not in accepted_kwargs: - kwargs.pop(kw, None) - - for arg in list(kwargs): - if arg in accepted_kwargs: - continue - _api.warn_deprecated( - '3.3', name=name, removal='3.6', - message='%(name)s() got unexpected keyword argument "' - + arg + '" which is no longer supported as of ' - '%(since)s and will become an error ' - '%(removal)s') - kwargs.pop(arg) - - return func(*args, **kwargs) - - return wrapper - - class FigureCanvasBase: """ The canvas the figure renders into. @@ -1664,6 +1634,13 @@ class FigureCanvasBase: # interactive framework is required, or None otherwise. required_interactive_framework = None + # The manager class instantiated by new_manager. + # (This is defined as a classproperty because the manager class is + # currently defined *after* the canvas class, but one could also assign + # ``FigureCanvasBase.manager_class = FigureManagerBase`` + # after defining both classes.) + manager_class = _api.classproperty(lambda cls: FigureManagerBase) + events = [ 'resize_event', 'draw_event', @@ -1705,12 +1682,13 @@ def __init__(self, figure=None): self._button = None # the button pressed self._key = None # the key pressed self._lastx, self._lasty = None, None - self.mouse_grabber = None # the axes currently grabbing mouse + self.mouse_grabber = None # the Axes currently grabbing mouse self.toolbar = None # NavigationToolbar2 will set me self._is_idle_drawing = False # We don't want to scale up the figure DPI more than once. figure._original_dpi = figure.dpi self._device_pixel_ratio = 1 + super().__init__() # Typically the GUI widget init (if any). callbacks = property(lambda self: self.figure._canvas_callbacks) button_pick_id = property(lambda self: self.figure._button_pick_id) @@ -1735,13 +1713,30 @@ def _fix_ipython_backend2gui(cls): # In case we ever move the patch to IPython and remove these APIs, # don't break on our side. return - rif = getattr(cls, "required_interactive_framework", None) - backend2gui_rif = {"qt": "qt", "gtk3": "gtk3", "gtk4": "gtk4", - "wx": "wx", "macosx": "osx"}.get(rif) + backend2gui_rif = { + "qt": "qt", + "gtk3": "gtk3", + "gtk4": "gtk4", + "wx": "wx", + "macosx": "osx", + }.get(cls.required_interactive_framework) if backend2gui_rif: if _is_non_interactive_terminal_ipython(ip): ip.enable_gui(backend2gui_rif) + @classmethod + def new_manager(cls, figure, num): + """ + Create a new figure manager for *figure*, using this canvas class. + + Notes + ----- + This method should not be reimplemented in subclasses. If + custom manager creation logic is needed, please reimplement + ``FigureManager.create_with_canvas``. + """ + return cls.manager_class.create_with_canvas(cls, figure, num) + @contextmanager def _idle_draw_cntx(self): self._is_idle_drawing = True @@ -1757,6 +1752,7 @@ def is_saving(self): """ return self._is_saving + @_api.deprecated("3.6", alternative="canvas.figure.pick") def pick(self, mouseevent): if not self.widgetlock.locked(): self.figure.pick(mouseevent) @@ -1771,13 +1767,24 @@ def resize(self, w, h): Certain backends may implement a similar method internally, but this is not a requirement of, nor is it used by, Matplotlib itself. """ + # The entire method is actually deprecated, but we allow pass-through + # to a parent class to support e.g. QWidget.resize. + if hasattr(super(), "resize"): + return super().resize(w, h) + else: + _api.warn_deprecated("3.6", name="resize", obj_type="method", + alternative="FigureManagerBase.resize") + @_api.deprecated("3.6", alternative=( + "callbacks.process('draw_event', DrawEvent(...))")) def draw_event(self, renderer): """Pass a `DrawEvent` to all functions connected to ``draw_event``.""" s = 'draw_event' event = DrawEvent(s, self, renderer) self.callbacks.process(s, event) + @_api.deprecated("3.6", alternative=( + "callbacks.process('resize_event', ResizeEvent(...))")) def resize_event(self): """ Pass a `ResizeEvent` to all functions connected to ``resize_event``. @@ -1787,6 +1794,8 @@ def resize_event(self): self.callbacks.process(s, event) self.draw_idle() + @_api.deprecated("3.6", alternative=( + "callbacks.process('close_event', CloseEvent(...))")) def close_event(self, guiEvent=None): """ Pass a `CloseEvent` to all functions connected to ``close_event``. @@ -1803,6 +1812,8 @@ def close_event(self, guiEvent=None): # AttributeError occurs on OSX with qt4agg upon exiting # with an open window; 'callbacks' attribute no longer exists. + @_api.deprecated("3.6", alternative=( + "callbacks.process('key_press_event', KeyEvent(...))")) def key_press_event(self, key, guiEvent=None): """ Pass a `KeyEvent` to all functions connected to ``key_press_event``. @@ -1813,6 +1824,8 @@ def key_press_event(self, key, guiEvent=None): s, self, key, self._lastx, self._lasty, guiEvent=guiEvent) self.callbacks.process(s, event) + @_api.deprecated("3.6", alternative=( + "callbacks.process('key_release_event', KeyEvent(...))")) def key_release_event(self, key, guiEvent=None): """ Pass a `KeyEvent` to all functions connected to ``key_release_event``. @@ -1823,6 +1836,8 @@ def key_release_event(self, key, guiEvent=None): self.callbacks.process(s, event) self._key = None + @_api.deprecated("3.6", alternative=( + "callbacks.process('pick_event', PickEvent(...))")) def pick_event(self, mouseevent, artist, **kwargs): """ Callback processing for pick events. @@ -1839,6 +1854,8 @@ def pick_event(self, mouseevent, artist, **kwargs): **kwargs) self.callbacks.process(s, event) + @_api.deprecated("3.6", alternative=( + "callbacks.process('scroll_event', MouseEvent(...))")) def scroll_event(self, x, y, step, guiEvent=None): """ Callback processing for scroll events. @@ -1859,6 +1876,8 @@ def scroll_event(self, x, y, step, guiEvent=None): step=step, guiEvent=guiEvent) self.callbacks.process(s, mouseevent) + @_api.deprecated("3.6", alternative=( + "callbacks.process('button_press_event', MouseEvent(...))")) def button_press_event(self, x, y, button, dblclick=False, guiEvent=None): """ Callback processing for mouse button press events. @@ -1876,6 +1895,8 @@ def button_press_event(self, x, y, button, dblclick=False, guiEvent=None): dblclick=dblclick, guiEvent=guiEvent) self.callbacks.process(s, mouseevent) + @_api.deprecated("3.6", alternative=( + "callbacks.process('button_release_event', MouseEvent(...))")) def button_release_event(self, x, y, button, guiEvent=None): """ Callback processing for mouse button release events. @@ -1900,6 +1921,9 @@ def button_release_event(self, x, y, button, guiEvent=None): self.callbacks.process(s, event) self._button = None + # Also remove _lastx, _lasty when this goes away. + @_api.deprecated("3.6", alternative=( + "callbacks.process('motion_notify_event', MouseEvent(...))")) def motion_notify_event(self, x, y, guiEvent=None): """ Callback processing for mouse movement events. @@ -1925,6 +1949,8 @@ def motion_notify_event(self, x, y, guiEvent=None): guiEvent=guiEvent) self.callbacks.process(s, event) + @_api.deprecated("3.6", alternative=( + "callbacks.process('leave_notify_event', LocationEvent(...))")) def leave_notify_event(self, guiEvent=None): """ Callback processing for the mouse cursor leaving the canvas. @@ -1941,7 +1967,9 @@ def leave_notify_event(self, guiEvent=None): LocationEvent.lastevent = None self._lastx, self._lasty = None, None - def enter_notify_event(self, guiEvent=None, xy=None): + @_api.deprecated("3.6", alternative=( + "callbacks.process('enter_notify_event', LocationEvent(...))")) + def enter_notify_event(self, guiEvent=None, *, xy): """ Callback processing for the mouse cursor entering the canvas. @@ -1955,18 +1983,7 @@ def enter_notify_event(self, guiEvent=None, xy=None): xy : (float, float) The coordinate location of the pointer when the canvas is entered. """ - if xy is not None: - x, y = xy - self._lastx, self._lasty = x, y - else: - x = None - y = None - _api.warn_deprecated( - '3.0', removal='3.5', name='enter_notify_event', - message='Since %(since)s, %(name)s expects a location but ' - 'your backend did not pass one. This will become an error ' - '%(removal)s.') - + self._lastx, self._lasty = x, y = xy event = LocationEvent('figure_enter_event', self, x, y, guiEvent) self.callbacks.process('figure_enter_event', event) @@ -1982,7 +1999,8 @@ def inaxes(self, xy): Returns ------- `~matplotlib.axes.Axes` or None - The topmost visible axes containing the point, or None if no axes. + The topmost visible Axes containing the point, or None if there + is no Axes at the point. """ axes_list = [a for a in self.figure.get_axes() if a.patch.contains_point(xy) and a.get_visible()] @@ -1998,7 +2016,7 @@ def grab_mouse(self, ax): Set the child `~.axes.Axes` which is grabbing the mouse events. Usually called by the widgets themselves. It is an error to call this - if the mouse is already grabbed by another axes. + if the mouse is already grabbed by another Axes. """ if self.mouse_grabber not in (None, ax): raise RuntimeError("Another Axes already grabs mouse input") @@ -2036,10 +2054,10 @@ def draw(self, *args, **kwargs): """ Render the `.Figure`. - It is important that this method actually walk the artist tree - even if not output is produced because this will trigger - deferred work (like computing limits auto-limits and tick - values) that users may want access to before saving to disk. + This method must walk the artist tree, even if no output is produced, + because it triggers deferred work that users may want to access + before saving output to disk. For example computing limits, + auto-limits, and tick values. """ def draw_idle(self, *args, **kwargs): @@ -2153,21 +2171,30 @@ def get_supported_filetypes_grouped(cls): groupings[name].sort() return groupings - def _get_output_canvas(self, backend, fmt): + @contextmanager + def _switch_canvas_and_return_print_method(self, fmt, backend=None): """ - Set the canvas in preparation for saving the figure. + Context manager temporarily setting the canvas for saving the figure:: + + with canvas._switch_canvas_and_return_print_method(fmt, backend) \\ + as print_method: + # ``print_method`` is a suitable ``print_{fmt}`` method, and + # the figure's canvas is temporarily switched to the method's + # canvas within the with... block. ``print_method`` is also + # wrapped to suppress extra kwargs passed by ``print_figure``. Parameters ---------- - backend : str or None - If not None, switch the figure canvas to the ``FigureCanvas`` class - of the given backend. fmt : str If *backend* is None, then determine a suitable canvas class for saving to format *fmt* -- either the current canvas class, if it supports *fmt*, or whatever `get_registered_canvas_class` returns; switch the figure canvas to that canvas class. + backend : str or None, default: None + If not None, switch the figure canvas to the ``FigureCanvas`` class + of the given backend. """ + canvas = None if backend is not None: # Return a specific canvas class, if requested. canvas_class = ( @@ -2178,16 +2205,34 @@ def _get_output_canvas(self, backend, fmt): f"The {backend!r} backend does not support {fmt} output") elif hasattr(self, f"print_{fmt}"): # Return the current canvas if it supports the requested format. - return self + canvas = self + canvas_class = None # Skip call to switch_backends. else: # Return a default canvas for the requested format, if it exists. canvas_class = get_registered_canvas_class(fmt) if canvas_class: - return self.switch_backends(canvas_class) - # Else report error for unsupported format. - raise ValueError( - "Format {!r} is not supported (supported formats: {})" - .format(fmt, ", ".join(sorted(self.get_supported_filetypes())))) + canvas = self.switch_backends(canvas_class) + if canvas is None: + raise ValueError( + "Format {!r} is not supported (supported formats: {})".format( + fmt, ", ".join(sorted(self.get_supported_filetypes())))) + meth = getattr(canvas, f"print_{fmt}") + mod = (meth.func.__module__ + if hasattr(meth, "func") # partialmethod, e.g. backend_wx. + else meth.__module__) + if mod.startswith(("matplotlib.", "mpl_toolkits.")): + optional_kws = { # Passed by print_figure for other renderers. + "dpi", "facecolor", "edgecolor", "orientation", + "bbox_inches_restore"} + skip = optional_kws - {*inspect.signature(meth).parameters} + print_method = functools.wraps(meth)(lambda *args, **kwargs: meth( + *args, **{k: v for k, v in kwargs.items() if k not in skip})) + else: # Let third-parties do as they see fit. + print_method = meth + try: + yield print_method + finally: + self.figure.canvas = self def print_figure( self, filename, dpi=None, facecolor=None, edgecolor=None, @@ -2255,10 +2300,6 @@ def print_figure( filename = filename.rstrip('.') + '.' + format format = format.lower() - # get canvas object and print method for format - canvas = self._get_output_canvas(backend, format) - print_method = getattr(canvas, 'print_%s' % format) - if dpi is None: dpi = rcParams['savefig.dpi'] if dpi == 'figure': @@ -2266,9 +2307,11 @@ def print_figure( # Remove the figure manager, if any, to avoid resizing the GUI widget. with cbook._setattr_cm(self, manager=None), \ + self._switch_canvas_and_return_print_method(format, backend) \ + as print_method, \ cbook._setattr_cm(self.figure, dpi=dpi), \ - cbook._setattr_cm(canvas, _device_pixel_ratio=1), \ - cbook._setattr_cm(canvas, _is_saving=True), \ + cbook._setattr_cm(self.figure.canvas, _device_pixel_ratio=1), \ + cbook._setattr_cm(self.figure.canvas, _is_saving=True), \ ExitStack() as stack: for prop in ["facecolor", "edgecolor"]: @@ -2281,7 +2324,7 @@ def print_figure( if bbox_inches is None: bbox_inches = rcParams['savefig.bbox'] - if (self.figure.get_constrained_layout() or + if (self.figure.get_layout_engine() is not None or bbox_inches == "tight"): # we need to trigger a draw before printing to make sure # CL works. "tight" also needs a draw to get the right @@ -2303,15 +2346,15 @@ def print_figure( bbox_inches = bbox_inches.padded(pad_inches) # call adjust_bbox to save only the given area - restore_bbox = tight_bbox.adjust_bbox(self.figure, bbox_inches, - canvas.fixed_dpi) + restore_bbox = _tight_bbox.adjust_bbox( + self.figure, bbox_inches, self.figure.canvas.fixed_dpi) _bbox_inches_restore = (bbox_inches, restore_bbox) else: _bbox_inches_restore = None - # we have already done CL above, so turn it off: - stack.enter_context(self.figure._cm_set(constrained_layout=False)) + # we have already done layout above, so turn it off: + stack.enter_context(self.figure._cm_set(layout_engine='none')) try: # _get_renderer may change the figure dpi (as vector formats # force the figure dpi to 72), so we need to set it again here. @@ -2327,7 +2370,6 @@ def print_figure( if bbox_inches and restore_bbox: restore_bbox() - self.figure.set_canvas(self) return result @classmethod @@ -2341,26 +2383,6 @@ def get_default_filetype(cls): """ return rcParams['savefig.format'] - @_api.deprecated("3.4", alternative="`.FigureManagerBase.get_window_title`" - " or GUI-specific methods") - def get_window_title(self): - """ - Return the title text of the window containing the figure, or None - if there is no window (e.g., a PS backend). - """ - if self.manager is not None: - return self.manager.get_window_title() - - @_api.deprecated("3.4", alternative="`.FigureManagerBase.set_window_title`" - " or GUI-specific methods") - def set_window_title(self, title): - """ - Set the title text of the window containing the figure. Note that - this has no effect if there is no window (e.g., a PS backend). - """ - if self.manager is not None: - self.manager.set_window_title(title) - def get_default_filename(self): """ Return a string, which includes extension, suitable for use as @@ -2417,12 +2439,20 @@ def mpl_connect(self, s, func): def func(event: Event) -> Any For the location events (button and key press/release), if the - mouse is over the axes, the ``inaxes`` attribute of the event will + mouse is over the Axes, the ``inaxes`` attribute of the event will be set to the `~matplotlib.axes.Axes` the event occurs is over, and additionally, the variables ``xdata`` and ``ydata`` attributes will be set to the mouse location in data coordinates. See `.KeyEvent` and `.MouseEvent` for more info. + .. note:: + + If func is a method, this only stores a weak reference to the + method. Thus, the figure does not influence the lifetime of + the associated object. Usually, you want to make sure that the + object is kept alive throughout the lifetime of the figure by + holding a reference to it. + Returns ------- cid @@ -2547,7 +2577,7 @@ def key_press_handler(event, canvas=None, toolbar=None): back-compatibility, but, if set, should always be equal to ``event.canvas.toolbar``. """ - # these bindings happen whether you are over an axes or not + # these bindings happen whether you are over an Axes or not if event.key is None: return @@ -2610,7 +2640,7 @@ def key_press_handler(event, canvas=None, toolbar=None): if event.inaxes is None: return - # these bindings require the mouse to be over an axes to trigger + # these bindings require the mouse to be over an Axes to trigger def _get_uniform_gridstate(ticks): # Return True/False if all grid lines are on or off, None if they are # not all in the same state. @@ -2622,7 +2652,7 @@ def _get_uniform_gridstate(ticks): return None ax = event.inaxes - # toggle major grids in current axes (default key 'g') + # toggle major grids in current Axes (default key 'g') # Both here and below (for 'G'), we do nothing if *any* grid (major or # minor, x or y) is not in a uniform state, to avoid messing up user # customization. @@ -2644,7 +2674,7 @@ def _get_uniform_gridstate(ticks): ax.grid(x_state, which="major" if x_state else "both", axis="x") ax.grid(y_state, which="major" if y_state else "both", axis="y") canvas.draw_idle() - # toggle major and minor grids in current axes (default key 'G') + # toggle major and minor grids in current Axes (default key 'G') if (event.key in grid_minor_keys # Exclude major grids not in a uniform state. and None not in [_get_uniform_gridstate(ax.xaxis.majorTicks), @@ -2773,6 +2803,9 @@ class FigureManagerBase: figure.canvas.manager.button_press_handler_id) """ + _toolbar2_class = None + _toolmanager_toolbar_class = None + def __init__(self, canvas, num): self.canvas = canvas canvas.manager = self # store a pointer to parent @@ -2790,14 +2823,83 @@ def __init__(self, canvas, num): self.toolmanager = (ToolManager(canvas.figure) if mpl.rcParams['toolbar'] == 'toolmanager' else None) - self.toolbar = None + if (mpl.rcParams["toolbar"] == "toolbar2" + and self._toolbar2_class): + self.toolbar = self._toolbar2_class(self.canvas) + elif (mpl.rcParams["toolbar"] == "toolmanager" + and self._toolmanager_toolbar_class): + self.toolbar = self._toolmanager_toolbar_class(self.toolmanager) + else: + self.toolbar = None + + if self.toolmanager: + tools.add_tools_to_manager(self.toolmanager) + if self.toolbar: + tools.add_tools_to_container(self.toolbar) @self.canvas.figure.add_axobserver def notify_axes_change(fig): - # Called whenever the current axes is changed. + # Called whenever the current Axes is changed. if self.toolmanager is None and self.toolbar is not None: self.toolbar.update() + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + """ + Create a manager for a given *figure* using a specific *canvas_class*. + + Backends should override this method if they have specific needs for + setting up the canvas or the manager. + """ + return cls(canvas_class(figure), num) + + @classmethod + def start_main_loop(cls): + """ + Start the main event loop. + + This method is called by `.FigureManagerBase.pyplot_show`, which is the + implementation of `.pyplot.show`. To customize the behavior of + `.pyplot.show`, interactive backends should usually override + `~.FigureManagerBase.start_main_loop`; if more customized logic is + necessary, `~.FigureManagerBase.pyplot_show` can also be overridden. + """ + + @classmethod + def pyplot_show(cls, *, block=None): + """ + Show all figures. This method is the implementation of `.pyplot.show`. + + To customize the behavior of `.pyplot.show`, interactive backends + should usually override `~.FigureManagerBase.start_main_loop`; if more + customized logic is necessary, `~.FigureManagerBase.pyplot_show` can + also be overridden. + + Parameters + ---------- + block : bool, optional + Whether to block by calling ``start_main_loop``. The default, + None, means to block if we are neither in IPython's ``%pylab`` mode + nor in ``interactive`` mode. + """ + managers = Gcf.get_all_fig_managers() + if not managers: + return + for manager in managers: + try: + manager.show() # Emits a warning for non-interactive backend. + except NonGuiException as exc: + _api.warn_external(str(exc)) + if block is None: + # Hack: Are we in IPython's %pylab mode? In pylab mode, IPython + # (>= 0.10) tacks a _needmain attribute onto pyplot.show (always + # set to False). + ipython_pylab = hasattr( + getattr(sys.modules.get("pyplot"), "show", None), "_needmain") + block = not ipython_pylab and not is_interactive() + if block: + cls.start_main_loop() + def show(self): """ For GUI backends, show the figure window and redraw. @@ -2825,23 +2927,6 @@ def full_screen_toggle(self): def resize(self, w, h): """For GUI backends, resize the window (in physical pixels).""" - @_api.deprecated( - "3.4", alternative="self.canvas.callbacks.process(event.name, event)") - def key_press(self, event): - """ - Implement the default Matplotlib key bindings defined at - :ref:`key-event-handling`. - """ - if rcParams['toolbar'] != 'toolmanager': - key_press_handler(event) - - @_api.deprecated( - "3.4", alternative="self.canvas.callbacks.process(event.name, event)") - def button_press(self, event): - """The default Matplotlib button actions for extra mouse buttons.""" - if rcParams['toolbar'] != 'toolmanager': - button_press_handler(event) - def get_window_title(self): """ Return the title text of the window containing the figure, or None @@ -2932,7 +3017,7 @@ def __init__(self, canvas): canvas.toolbar = self self._nav_stack = cbook.Stack() # This cursor will be set after the initial draw. - self._lastCursor = tools.Cursors.POINTER + self._last_cursor = tools.Cursors.POINTER self._id_press = self.canvas.mpl_connect( 'button_press_event', self._zoom_pan_handler) @@ -3001,16 +3086,16 @@ def _update_cursor(self, event): """ if self.mode and event.inaxes and event.inaxes.get_navigate(): if (self.mode == _Mode.ZOOM - and self._lastCursor != tools.Cursors.SELECT_REGION): + and self._last_cursor != tools.Cursors.SELECT_REGION): self.canvas.set_cursor(tools.Cursors.SELECT_REGION) - self._lastCursor = tools.Cursors.SELECT_REGION + self._last_cursor = tools.Cursors.SELECT_REGION elif (self.mode == _Mode.PAN - and self._lastCursor != tools.Cursors.MOVE): + and self._last_cursor != tools.Cursors.MOVE): self.canvas.set_cursor(tools.Cursors.MOVE) - self._lastCursor = tools.Cursors.MOVE - elif self._lastCursor != tools.Cursors.POINTER: + self._last_cursor = tools.Cursors.MOVE + elif self._last_cursor != tools.Cursors.POINTER: self.canvas.set_cursor(tools.Cursors.POINTER) - self._lastCursor = tools.Cursors.POINTER + self._last_cursor = tools.Cursors.POINTER @contextmanager def _wait_cursor_for_draw_cm(self): @@ -3030,7 +3115,7 @@ def _wait_cursor_for_draw_cm(self): self.canvas.set_cursor(tools.Cursors.WAIT) yield finally: - self.canvas.set_cursor(self._lastCursor) + self.canvas.set_cursor(self._last_cursor) else: yield @@ -3054,15 +3139,11 @@ def _mouse_event_to_message(event): if data_str: s = s + '\n' + data_str return s + return "" def mouse_move(self, event): self._update_cursor(event) - - s = self._mouse_event_to_message(event) - if s is not None: - self.set_message(s) - else: - self.set_message(self.mode) + self.set_message(self._mouse_event_to_message(event)) def _zoom_pan_handler(self, event): if self.mode == _Mode.PAN: @@ -3082,6 +3163,9 @@ def pan(self, *args): Pan with left button, zoom with right. """ + if not self.canvas.widgetlock.available(self): + self.set_message("pan unavailable") + return if self.mode == _Mode.PAN: self.mode = _Mode.NONE self.canvas.widgetlock.release(self) @@ -3090,7 +3174,6 @@ def pan(self, *args): self.canvas.widgetlock(self) for a in self.canvas.figure.get_axes(): a.set_navigate_mode(self.mode._navigate_mode) - self.set_message(self.mode) _PanInfo = namedtuple("_PanInfo", "button axes cid") @@ -3134,6 +3217,9 @@ def release_pan(self, event): self.push_current() def zoom(self, *args): + if not self.canvas.widgetlock.available(self): + self.set_message("zoom unavailable") + return """Toggle zoom to rect mode.""" if self.mode == _Mode.ZOOM: self.mode = _Mode.NONE @@ -3143,7 +3229,6 @@ def zoom(self, *args): self.canvas.widgetlock(self) for a in self.canvas.figure.get_axes(): a.set_navigate_mode(self.mode._navigate_mode) - self.set_message(self.mode) _ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid cbar") @@ -3161,7 +3246,7 @@ def press_zoom(self, event): id_zoom = self.canvas.mpl_connect( "motion_notify_event", self.drag_zoom) # A colorbar is one-dimensional, so we extend the zoom rectangle out - # to the edge of the axes bbox in the other dimension. To do that we + # to the edge of the Axes bbox in the other dimension. To do that we # store the orientation of the colorbar for later. if hasattr(axes[0], "_colorbar"): cbar = axes[0]._colorbar.orientation @@ -3217,8 +3302,8 @@ def release_zoom(self, event): return for i, ax in enumerate(self._zoom_info.axes): - # Detect whether this axes is twinned with an earlier axes in the - # list of zoomed axes, to avoid double zooming. + # Detect whether this Axes is twinned with an earlier Axes in the + # list of zoomed Axes, to avoid double zooming. twinx = any(ax.get_shared_x_axes().joined(ax, prev) for prev in self._zoom_info.axes[:i]) twiny = any(ax.get_shared_y_axes().joined(ax, prev) @@ -3245,7 +3330,7 @@ def push_current(self): def _update_view(self): """ Update the viewlim and position from the view and position stack for - each axes. + each Axes. """ nav_info = self._nav_stack() if nav_info is None: @@ -3264,19 +3349,22 @@ def configure_subplots(self, *args): if hasattr(self, "subplot_tool"): self.subplot_tool.figure.canvas.manager.show() return - plt = _safe_pyplot_import() + # This import needs to happen here due to circular imports. + from matplotlib.figure import Figure with mpl.rc_context({"toolbar": "none"}): # No navbar for the toolfig. - # Use new_figure_manager() instead of figure() so that the figure - # doesn't get registered with pyplot. - manager = plt.new_figure_manager(-1, (6, 3)) + manager = type(self.canvas).new_manager(Figure(figsize=(6, 3)), -1) manager.set_window_title("Subplot configuration tool") tool_fig = manager.canvas.figure tool_fig.subplots_adjust(top=0.9) self.subplot_tool = widgets.SubplotTool(self.canvas.figure, tool_fig) - tool_fig.canvas.mpl_connect( - "close_event", lambda e: delattr(self, "subplot_tool")) - self.canvas.mpl_connect( + cid = self.canvas.mpl_connect( "close_event", lambda e: manager.destroy()) + + def on_tool_fig_close(e): + self.canvas.mpl_disconnect(cid) + del self.subplot_tool + + tool_fig.canvas.mpl_connect("close_event", on_tool_fig_close) manager.show() return self.subplot_tool @@ -3284,20 +3372,8 @@ def save_figure(self, *args): """Save the current figure.""" raise NotImplementedError - @_api.deprecated("3.5", alternative="`.FigureCanvasBase.set_cursor`") - def set_cursor(self, cursor): - """ - Set the current cursor to one of the :class:`Cursors` enums values. - - If required by the backend, this method should trigger an update in - the backend event loop after the cursor is set, as this method may be - called e.g. before a long-running task during which the GUI is not - updated. - """ - self.canvas.set_cursor(cursor) - def update(self): - """Reset the axes stack.""" + """Reset the Axes stack.""" self._nav_stack.clear() self.set_history_buttons() @@ -3496,13 +3572,16 @@ def new_figure_manager(cls, num, *args, **kwargs): @classmethod def new_figure_manager_given_figure(cls, num, figure): """Create a new figure manager instance for the given figure.""" - canvas = cls.FigureCanvas(figure) - manager = cls.FigureManager(canvas, num) - return manager + return cls.FigureCanvas.new_manager(figure, num) @classmethod def draw_if_interactive(cls): - if cls.mainloop is not None and is_interactive(): + manager_class = cls.FigureCanvas.manager_class + # Interactive backends reimplement start_main_loop or pyplot_show. + backend_is_interactive = ( + manager_class.start_main_loop != FigureManagerBase.start_main_loop + or manager_class.pyplot_show != FigureManagerBase.pyplot_show) + if backend_is_interactive and is_interactive(): manager = Gcf.get_active() if manager: manager.canvas.draw_idle() @@ -3527,19 +3606,12 @@ def show(cls, *, block=None): if cls.mainloop is None: return if block is None: - # Hack: Are we in IPython's pylab mode? - from matplotlib import pyplot - try: - # IPython versions >= 0.10 tack the _needmain attribute onto - # pyplot.show, and always set it to False, when in %pylab mode. - ipython_pylab = not pyplot.show._needmain - except AttributeError: - ipython_pylab = False + # Hack: Are we in IPython's %pylab mode? In pylab mode, IPython + # (>= 0.10) tacks a _needmain attribute onto pyplot.show (always + # set to False). + ipython_pylab = hasattr( + getattr(sys.modules.get("pyplot"), "show", None), "_needmain") block = not ipython_pylab and not is_interactive() - # TODO: The above is a hack to get the WebAgg backend working with - # ipython's `%pylab` mode until proper integration is implemented. - if get_backend() == "WebAgg": - block = True if block: cls.mainloop() diff --git a/lib/matplotlib/backend_managers.py b/lib/matplotlib/backend_managers.py index 6d2d9595545d..5366e8e611f4 100644 --- a/lib/matplotlib/backend_managers.py +++ b/lib/matplotlib/backend_managers.py @@ -1,5 +1,4 @@ -from matplotlib import _api, cbook, widgets -import matplotlib.backend_tools as tools +from matplotlib import _api, backend_tools, cbook, widgets class ToolEvent: @@ -186,7 +185,7 @@ def update_keymap(self, name, key): Keys to associate with the tool. """ if name not in self._tools: - raise KeyError(f'{name} not in Tools') + raise KeyError(f'{name!r} not in Tools') self._remove_keys(name) if isinstance(key, str): key = [key] @@ -207,7 +206,11 @@ def remove_tool(self, name): """ tool = self.get_tool(name) - tool.destroy() + destroy = _api.deprecate_method_override( + backend_tools.ToolBase.destroy, tool, since="3.6", + alternative="tool_removed_event") + if destroy is not None: + destroy() # If it's a toggle tool and toggled, untoggle if getattr(tool, 'toggled', False): @@ -215,9 +218,8 @@ def remove_tool(self, name): self._remove_keys(name) - s = 'tool_removed_event' - event = ToolEvent(s, self, tool) - self._callbacks.process(s, event) + event = ToolEvent('tool_removed_event', self, tool) + self._callbacks.process(event.name, event) del self._tools[name] @@ -233,8 +235,9 @@ def add_tool(self, name, tool, *args, **kwargs): ---------- name : str Name of the tool, treated as the ID, has to be unique. - tool : class_like, i.e. str or type - Reference to find the class of the Tool to added. + tool : type + Class of the tool to be added. A subclass will be used + instead if one was registered for the current canvas class. Notes ----- @@ -245,7 +248,7 @@ def add_tool(self, name, tool, *args, **kwargs): matplotlib.backend_tools.ToolBase : The base class for tools. """ - tool_cls = self._get_cls_to_instantiate(tool) + tool_cls = backend_tools._find_tool_class(type(self.canvas), tool) if not tool_cls: raise ValueError('Impossible to find class for %s' % str(tool)) @@ -254,24 +257,14 @@ def add_tool(self, name, tool, *args, **kwargs): 'exists, not added') return self._tools[name] - if name == 'cursor' and tool_cls != tools.SetCursorBase: - _api.warn_deprecated("3.5", - message="Overriding ToolSetCursor with " - f"{tool_cls.__qualname__} was only " - "necessary to provide the .set_cursor() " - "method, which is deprecated since " - "%(since)s and will be removed " - "%(removal)s. Please report this to the " - f"{tool_cls.__module__} author.") - tool_obj = tool_cls(self, name, *args, **kwargs) self._tools[name] = tool_obj - if tool_cls.default_keymap is not None: - self.update_keymap(name, tool_cls.default_keymap) + if tool_obj.default_keymap is not None: + self.update_keymap(name, tool_obj.default_keymap) # For toggle tools init the radio_group in self._toggled - if isinstance(tool_obj, tools.ToolToggleBase): + if isinstance(tool_obj, backend_tools.ToolToggleBase): # None group is not mutually exclusive, a set is used to keep track # of all toggled tools in this group if tool_obj.radio_group is None: @@ -281,18 +274,15 @@ def add_tool(self, name, tool, *args, **kwargs): # If initially toggled if tool_obj.toggled: - self._handle_toggle(tool_obj, None, None, None) + self._handle_toggle(tool_obj, None, None) tool_obj.set_figure(self.figure) - self._tool_added_event(tool_obj) - return tool_obj + event = ToolEvent('tool_added_event', self, tool_obj) + self._callbacks.process(event.name, event) - def _tool_added_event(self, tool): - s = 'tool_added_event' - event = ToolEvent(s, self, tool) - self._callbacks.process(s, event) + return tool_obj - def _handle_toggle(self, tool, sender, canvasevent, data): + def _handle_toggle(self, tool, canvasevent, data): """ Toggle tools, need to untoggle prior to using other Toggle tool. Called from trigger_tool. @@ -300,8 +290,6 @@ def _handle_toggle(self, tool, sender, canvasevent, data): Parameters ---------- tool : `.ToolBase` - sender : object - Object that wishes to trigger the tool. canvasevent : Event Original Canvas event or None. data : object @@ -337,23 +325,6 @@ def _handle_toggle(self, tool, sender, canvasevent, data): # Keep track of the toggled tool in the radio_group self._toggled[radio_group] = toggled - def _get_cls_to_instantiate(self, callback_class): - # Find the class that corresponds to the tool - if isinstance(callback_class, str): - # FIXME: make more complete searching structure - if callback_class in globals(): - callback_class = globals()[callback_class] - else: - mod = 'backend_tools' - current_module = __import__(mod, - globals(), locals(), [mod], 1) - - callback_class = getattr(current_module, callback_class, False) - if callable(callback_class): - return callback_class - else: - return None - def trigger_tool(self, name, sender=None, canvasevent=None, data=None): """ Trigger a tool and emit the ``tool_trigger_{name}`` event. @@ -376,23 +347,15 @@ def trigger_tool(self, name, sender=None, canvasevent=None, data=None): if sender is None: sender = self - self._trigger_tool(name, sender, canvasevent, data) + if isinstance(tool, backend_tools.ToolToggleBase): + self._handle_toggle(tool, canvasevent, data) + + tool.trigger(sender, canvasevent, data) # Actually trigger Tool. s = 'tool_trigger_%s' % name event = ToolTriggerEvent(s, sender, tool, canvasevent, data) self._callbacks.process(s, event) - def _trigger_tool(self, name, sender=None, canvasevent=None, data=None): - """Actually trigger a tool.""" - tool = self.get_tool(name) - - if isinstance(tool, tools.ToolToggleBase): - self._handle_toggle(tool, sender, canvasevent, data) - - # Important!!! - # This is where the Tool object gets triggered - tool.trigger(sender, canvasevent, data) - def _key_press(self, event): if event.key is None or self.keypresslock.locked(): return @@ -426,10 +389,12 @@ def get_tool(self, name, warn=True): `.ToolBase` or None The tool or None if no tool with the given name exists. """ - if isinstance(name, tools.ToolBase) and name.name in self._tools: + if (isinstance(name, backend_tools.ToolBase) + and name.name in self._tools): return name if name not in self._tools: if warn: - _api.warn_external(f"ToolManager does not control tool {name}") + _api.warn_external( + f"ToolManager does not control tool {name!r}") return None return self._tools[name] diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py index 04ba8f665f1f..73425f915093 100644 --- a/lib/matplotlib/backend_tools.py +++ b/lib/matplotlib/backend_tools.py @@ -12,6 +12,7 @@ """ import enum +import functools import re import time from types import SimpleNamespace @@ -36,6 +37,35 @@ class Cursors(enum.IntEnum): # Must subclass int for the macOS backend. RESIZE_VERTICAL = enum.auto() cursors = Cursors # Backcompat. + +# _tool_registry, _register_tool_class, and _find_tool_class implement a +# mechanism through which ToolManager.add_tool can determine whether a subclass +# of the requested tool class has been registered (either for the current +# canvas class or for a parent class), in which case that tool subclass will be +# instantiated instead. This is the mechanism used e.g. to allow different +# GUI backends to implement different specializations for ConfigureSubplots. + + +_tool_registry = set() + + +def _register_tool_class(canvas_cls, tool_cls=None): + """Decorator registering *tool_cls* as a tool class for *canvas_cls*.""" + if tool_cls is None: + return functools.partial(_register_tool_class, canvas_cls) + _tool_registry.add((canvas_cls, tool_cls)) + return tool_cls + + +def _find_tool_class(canvas_cls, tool_cls): + """Find a subclass of *tool_cls* registered for *canvas_cls*.""" + for canvas_parent in canvas_cls.__mro__: + for tool_child in _api.recursive_subclasses(tool_cls): + if (canvas_parent, tool_child) in _tool_registry: + return tool_child + return tool_cls + + # Views positions tool _views_positions = 'viewpos' @@ -53,7 +83,9 @@ class ToolBase: Keymap to associate with this tool. ``list[str]``: List of keys that will trigger this tool when a keypress - event is emitted on ``self.figure.canvas``. + event is emitted on ``self.figure.canvas``. Note that this attribute is + looked up on the instance, and can therefore be a property (this is used + e.g. by the built-in tools to load the rcParams at instantiation time). """ description = None @@ -123,6 +155,7 @@ def trigger(self, sender, event, data=None): """ pass + @_api.deprecated("3.6", alternative="tool_removed_event") def destroy(self): """ Destroy the tool. @@ -219,12 +252,12 @@ def set_figure(self, figure): self._toggled = True -class SetCursorBase(ToolBase): +class ToolSetCursor(ToolBase): """ Change to the current cursor while inaxes. - This tool, keeps track of all `ToolToggleBase` derived tools, and calls - `set_cursor` when a tool gets triggered. + This tool, keeps track of all `ToolToggleBase` derived tools, and updates + the cursor when a tool gets triggered. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -277,18 +310,6 @@ def _set_cursor_cbk(self, event): self.canvas.set_cursor(self._default_cursor) self._last_cursor = self._default_cursor - @_api.deprecated("3.5", alternative="`.FigureCanvasBase.set_cursor`") - def set_cursor(self, cursor): - """ - Set the cursor. - """ - self.canvas.set_cursor(cursor) - - -# This exists solely for deprecation warnings; remove with -# SetCursorBase.set_cursor. -ToolSetCursor = SetCursorBase - class ToolCursorPosition(ToolBase): """ @@ -315,14 +336,12 @@ def send_message(self, event): from matplotlib.backend_bases import NavigationToolbar2 message = NavigationToolbar2._mouse_event_to_message(event) - if message is None: - message = ' ' self.toolmanager.message_event(message, self) class RubberbandBase(ToolBase): """Draw and remove a rubberband.""" - def trigger(self, sender, event, data): + def trigger(self, sender, event, data=None): """Call `draw_rubberband` or `remove_rubberband` based on data.""" if not self.figure.canvas.widgetlock.available(sender): return @@ -352,7 +371,7 @@ class ToolQuit(ToolBase): """Tool to call the figure manager destroy method.""" description = 'Quit the figure' - default_keymap = mpl.rcParams['keymap.quit'] + default_keymap = property(lambda self: mpl.rcParams['keymap.quit']) def trigger(self, sender, event, data=None): Gcf.destroy_fig(self.figure) @@ -362,7 +381,7 @@ class ToolQuitAll(ToolBase): """Tool to call the figure manager destroy method.""" description = 'Quit all figures' - default_keymap = mpl.rcParams['keymap.quit_all'] + default_keymap = property(lambda self: mpl.rcParams['keymap.quit_all']) def trigger(self, sender, event, data=None): Gcf.destroy_all() @@ -372,7 +391,7 @@ class ToolGrid(ToolBase): """Tool to toggle the major grids of the figure.""" description = 'Toggle major grids' - default_keymap = mpl.rcParams['keymap.grid'] + default_keymap = property(lambda self: mpl.rcParams['keymap.grid']) def trigger(self, sender, event, data=None): sentinel = str(uuid.uuid4()) @@ -387,7 +406,7 @@ class ToolMinorGrid(ToolBase): """Tool to toggle the major and minor grids of the figure.""" description = 'Toggle major and minor grids' - default_keymap = mpl.rcParams['keymap.grid_minor'] + default_keymap = property(lambda self: mpl.rcParams['keymap.grid_minor']) def trigger(self, sender, event, data=None): sentinel = str(uuid.uuid4()) @@ -398,16 +417,13 @@ def trigger(self, sender, event, data=None): mpl.backend_bases.key_press_handler(event, self.figure.canvas) -class ToolFullScreen(ToolToggleBase): +class ToolFullScreen(ToolBase): """Tool to toggle full screen.""" description = 'Toggle fullscreen mode' - default_keymap = mpl.rcParams['keymap.fullscreen'] - - def enable(self, event): - self.figure.canvas.manager.full_screen_toggle() + default_keymap = property(lambda self: mpl.rcParams['keymap.fullscreen']) - def disable(self, event): + def trigger(self, sender, event, data=None): self.figure.canvas.manager.full_screen_toggle() @@ -419,11 +435,11 @@ def trigger(self, sender, event, data=None): return super().trigger(sender, event, data) - def enable(self, event): + def enable(self, event=None): self.set_scale(event.inaxes, 'log') self.figure.canvas.draw_idle() - def disable(self, event): + def disable(self, event=None): self.set_scale(event.inaxes, 'linear') self.figure.canvas.draw_idle() @@ -432,7 +448,7 @@ class ToolYScale(AxisScaleBase): """Tool to toggle between linear and logarithmic scales on the Y axis.""" description = 'Toggle scale Y axis' - default_keymap = mpl.rcParams['keymap.yscale'] + default_keymap = property(lambda self: mpl.rcParams['keymap.yscale']) def set_scale(self, ax, scale): ax.set_yscale(scale) @@ -442,7 +458,7 @@ class ToolXScale(AxisScaleBase): """Tool to toggle between linear and logarithmic scales on the X axis.""" description = 'Toggle scale X axis' - default_keymap = mpl.rcParams['keymap.xscale'] + default_keymap = property(lambda self: mpl.rcParams['keymap.xscale']) def set_scale(self, ax, scale): ax.set_xscale(scale) @@ -596,7 +612,7 @@ class ToolHome(ViewsPositionsBase): description = 'Reset original view' image = 'home' - default_keymap = mpl.rcParams['keymap.home'] + default_keymap = property(lambda self: mpl.rcParams['keymap.home']) _on_trigger = 'home' @@ -605,7 +621,7 @@ class ToolBack(ViewsPositionsBase): description = 'Back to previous view' image = 'back' - default_keymap = mpl.rcParams['keymap.back'] + default_keymap = property(lambda self: mpl.rcParams['keymap.back']) _on_trigger = 'back' @@ -614,7 +630,7 @@ class ToolForward(ViewsPositionsBase): description = 'Forward to next view' image = 'forward' - default_keymap = mpl.rcParams['keymap.forward'] + default_keymap = property(lambda self: mpl.rcParams['keymap.forward']) _on_trigger = 'forward' @@ -630,7 +646,7 @@ class SaveFigureBase(ToolBase): description = 'Save the figure' image = 'filesave' - default_keymap = mpl.rcParams['keymap.save'] + default_keymap = property(lambda self: mpl.rcParams['keymap.save']) class ZoomPanBase(ToolToggleBase): @@ -646,7 +662,7 @@ def __init__(self, *args): self.scrollthresh = .5 # .5 second scroll threshold self.lastscroll = time.time()-self.scrollthresh - def enable(self, event): + def enable(self, event=None): """Connect press/release events and lock the canvas.""" self.figure.canvas.widgetlock(self) self._idPress = self.figure.canvas.mpl_connect( @@ -656,7 +672,7 @@ def enable(self, event): self._idScroll = self.figure.canvas.mpl_connect( 'scroll_event', self.scroll_zoom) - def disable(self, event): + def disable(self, event=None): """Release the canvas and disconnect press/release events.""" self._cancel_action() self.figure.canvas.widgetlock.release(self) @@ -705,7 +721,7 @@ class ToolZoom(ZoomPanBase): description = 'Zoom to rectangle' image = 'zoom_to_rect' - default_keymap = mpl.rcParams['keymap.zoom'] + default_keymap = property(lambda self: mpl.rcParams['keymap.zoom']) cursor = cursors.SELECT_REGION radio_group = 'default' @@ -791,7 +807,7 @@ def _release(self, event): self._cancel_action() return - last_a = [] + done_ax = [] for cur_xypress in self._xypress: x, y = event.x, event.y @@ -802,14 +818,9 @@ def _release(self, event): return # detect twinx, twiny axes and avoid double zooming - twinx, twiny = False, False - if last_a: - for la in last_a: - if a.get_shared_x_axes().joined(a, la): - twinx = True - if a.get_shared_y_axes().joined(a, la): - twiny = True - last_a.append(a) + twinx = any(a.get_shared_x_axes().joined(a, a1) for a1 in done_ax) + twiny = any(a.get_shared_y_axes().joined(a, a1) for a1 in done_ax) + done_ax.append(a) if self._button_pressed == 1: direction = 'in' @@ -829,7 +840,7 @@ def _release(self, event): class ToolPan(ZoomPanBase): """Pan axes with left mouse, zoom with right.""" - default_keymap = mpl.rcParams['keymap.pan'] + default_keymap = property(lambda self: mpl.rcParams['keymap.pan']) description = 'Pan axes with left mouse, zoom with right' image = 'move' cursor = cursors.MOVE @@ -894,7 +905,7 @@ def _mouse_move(self, event): class ToolHelpBase(ToolBase): description = 'Print tool list, shortcuts and description' - default_keymap = mpl.rcParams['keymap.help'] + default_keymap = property(lambda self: mpl.rcParams['keymap.help']) image = 'help' @staticmethod @@ -934,7 +945,7 @@ class ToolCopyToClipboardBase(ToolBase): """Tool to copy the figure to the clipboard.""" description = 'Copy the canvas figure to clipboard' - default_keymap = mpl.rcParams['keymap.copy'] + default_keymap = property(lambda self: mpl.rcParams['keymap.copy']) def trigger(self, *args, **kwargs): message = "Copy tool is not available" @@ -943,8 +954,8 @@ def trigger(self, *args, **kwargs): default_tools = {'home': ToolHome, 'back': ToolBack, 'forward': ToolForward, 'zoom': ToolZoom, 'pan': ToolPan, - 'subplots': 'ToolConfigureSubplots', - 'save': 'ToolSaveFigure', + 'subplots': ConfigureSubplotsBase, + 'save': SaveFigureBase, 'grid': ToolGrid, 'grid_minor': ToolMinorGrid, 'fullscreen': ToolFullScreen, @@ -954,17 +965,15 @@ def trigger(self, *args, **kwargs): 'yscale': ToolYScale, 'position': ToolCursorPosition, _views_positions: ToolViewsPositions, - 'cursor': 'ToolSetCursor', - 'rubberband': 'ToolRubberband', - 'help': 'ToolHelp', - 'copy': 'ToolCopyToClipboard', + 'cursor': ToolSetCursor, + 'rubberband': RubberbandBase, + 'help': ToolHelpBase, + 'copy': ToolCopyToClipboardBase, } -"""Default tools""" default_toolbar_tools = [['navigation', ['home', 'back', 'forward']], ['zoompan', ['pan', 'zoom', 'subplots']], ['io', ['save', 'help']]] -"""Default tools in the toolbar""" def add_tools_to_manager(toolmanager, tools=default_tools): @@ -976,8 +985,8 @@ def add_tools_to_manager(toolmanager, tools=default_tools): toolmanager : `.backend_managers.ToolManager` Manager to which the tools are added. tools : {str: class_like}, optional - The tools to add in a {name: tool} dict, see `add_tool` for more - info. + The tools to add in a {name: tool} dict, see + `.backend_managers.ToolManager.add_tool` for more info. """ for name, tool in tools.items(): @@ -991,11 +1000,12 @@ def add_tools_to_container(container, tools=default_toolbar_tools): Parameters ---------- container : Container - `backend_bases.ToolContainerBase` object that will get the tools added. + `.backend_bases.ToolContainerBase` object that will get the tools + added. tools : list, optional List in the form ``[[group1, [tool1, tool2 ...]], [group2, [...]]]`` where the tools ``[tool1, tool2, ...]`` will display in group1. - See `add_tool` for details. + See `.backend_bases.ToolContainerBase.add_tool` for details. """ for group, grouptools in tools: diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index 443ae3766cef..1fadc49a0d37 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -3,25 +3,29 @@ """ import logging +import sys import matplotlib as mpl -from matplotlib import backend_tools, cbook +from matplotlib import _api, backend_tools, cbook +from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( - _Backend, NavigationToolbar2, TimerBase, -) + _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, + TimerBase) +from matplotlib.backend_tools import Cursors +import gi # The GTK3/GTK4 backends will have already called `gi.require_version` to set # the desired GTK. -from gi.repository import Gio, GLib, Gtk +from gi.repository import Gdk, Gio, GLib, Gtk -_log = logging.getLogger(__name__) - -backend_version = "%s.%s.%s" % ( - Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()) +try: + gi.require_foreign("cairo") +except ImportError as e: + raise ImportError("Gtk-based backends require cairo") from e -# Placeholder -_application = None +_log = logging.getLogger(__name__) +_application = None # Placeholder def _shutdown_application(app): @@ -62,6 +66,18 @@ def _create_application(): return _application +def mpl_to_gtk_cursor_name(mpl_cursor): + return _api.check_getitem({ + Cursors.MOVE: "move", + Cursors.HAND: "pointer", + Cursors.POINTER: "default", + Cursors.SELECT_REGION: "crosshair", + Cursors.WAIT: "wait", + Cursors.RESIZE_HORIZONTAL: "ew-resize", + Cursors.RESIZE_VERTICAL: "ns-resize", + }, cursor=mpl_cursor) + + class TimerGTK(TimerBase): """Subclass of `.TimerBase` using GTK timer events.""" @@ -98,6 +114,157 @@ def _on_timer(self): return False +class _FigureCanvasGTK(FigureCanvasBase): + _timer_cls = TimerGTK + + +class _FigureManagerGTK(FigureManagerBase): + """ + Attributes + ---------- + canvas : `FigureCanvas` + The FigureCanvas instance + num : int or str + The Figure number + toolbar : Gtk.Toolbar or Gtk.Box + The toolbar + vbox : Gtk.VBox + The Gtk.VBox containing the canvas and toolbar + window : Gtk.Window + The Gtk.Window + """ + + def __init__(self, canvas, num): + self._gtk_ver = gtk_ver = Gtk.get_major_version() + + app = _create_application() + self.window = Gtk.Window() + app.add_window(self.window) + super().__init__(canvas, num) + + if gtk_ver == 3: + self.window.set_wmclass("matplotlib", "Matplotlib") + icon_ext = "png" if sys.platform == "win32" else "svg" + self.window.set_icon_from_file( + str(cbook._get_data_path(f"images/matplotlib.{icon_ext}"))) + + self.vbox = Gtk.Box() + self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL) + + if gtk_ver == 3: + self.window.add(self.vbox) + self.vbox.show() + self.canvas.show() + self.vbox.pack_start(self.canvas, True, True, 0) + elif gtk_ver == 4: + self.window.set_child(self.vbox) + self.vbox.prepend(self.canvas) + + # calculate size for window + w, h = self.canvas.get_width_height() + + if self.toolbar is not None: + if gtk_ver == 3: + self.toolbar.show() + self.vbox.pack_end(self.toolbar, False, False, 0) + elif gtk_ver == 4: + sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) + sw.set_child(self.toolbar) + self.vbox.append(sw) + min_size, nat_size = self.toolbar.get_preferred_size() + h += nat_size.height + + self.window.set_default_size(w, h) + + self._destroying = False + self.window.connect("destroy", lambda *args: Gcf.destroy(self)) + self.window.connect({3: "delete_event", 4: "close-request"}[gtk_ver], + lambda *args: Gcf.destroy(self)) + if mpl.is_interactive(): + self.window.show() + self.canvas.draw_idle() + + self.canvas.grab_focus() + + def destroy(self, *args): + if self._destroying: + # Otherwise, this can be called twice when the user presses 'q', + # which calls Gcf.destroy(self), then this destroy(), then triggers + # Gcf.destroy(self) once again via + # `connect("destroy", lambda *args: Gcf.destroy(self))`. + return + self._destroying = True + self.window.destroy() + self.canvas.destroy() + + @classmethod + def start_main_loop(cls): + global _application + if _application is None: + return + + try: + _application.run() # Quits when all added windows close. + except KeyboardInterrupt: + # Ensure all windows can process their close event from + # _shutdown_application. + context = GLib.MainContext.default() + while context.pending(): + context.iteration(True) + raise + finally: + # Running after quit is undefined, so create a new one next time. + _application = None + + def show(self): + # show the figure window + self.window.show() + self.canvas.draw() + if mpl.rcParams["figure.raise_window"]: + meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver] + if getattr(self.window, meth_name)(): + self.window.present() + else: + # If this is called by a callback early during init, + # self.window (a GtkWindow) may not have an associated + # low-level GdkWindow (on GTK3) or GdkSurface (on GTK4) yet, + # and present() would crash. + _api.warn_external("Cannot raise window yet to be setup") + + def full_screen_toggle(self): + is_fullscreen = { + 3: lambda w: (w.get_window().get_state() + & Gdk.WindowState.FULLSCREEN), + 4: lambda w: w.is_fullscreen(), + }[self._gtk_ver] + if is_fullscreen(self.window): + self.window.unfullscreen() + else: + self.window.fullscreen() + + def get_window_title(self): + return self.window.get_title() + + def set_window_title(self, title): + self.window.set_title(title) + + def resize(self, width, height): + width = int(width / self.canvas.device_pixel_ratio) + height = int(height / self.canvas.device_pixel_ratio) + if self.toolbar: + min_size, nat_size = self.toolbar.get_preferred_size() + height += nat_size.height + canvas_size = self.canvas.get_allocation() + if self._gtk_ver >= 4 or canvas_size.width == canvas_size.height == 1: + # A canvas size of (1, 1) cannot exist in most cases, because + # window decorations would prevent such a small window. This call + # must be before the window has been mapped and widgets have been + # sized, so just change the window's starting size. + self.window.set_default_size(width, height) + else: + self.window.resize(width, height) + + class _NavigationToolbar2GTK(NavigationToolbar2): # Must be implemented in GTK3/GTK4 backends: # * __init__ @@ -151,30 +318,15 @@ def remove_rubberband(self): self._make_classic_style_pseudo_toolbar()) -class ConfigureSubplotsGTK(backend_tools.ConfigureSubplotsBase, Gtk.Window): - def _get_canvas(self, fig): - return self.canvas.__class__(fig) - +class ConfigureSubplotsGTK(backend_tools.ConfigureSubplotsBase): def trigger(self, *args): _NavigationToolbar2GTK.configure_subplots(self, None) class _BackendGTK(_Backend): - @staticmethod - def mainloop(): - global _application - if _application is None: - return - - try: - _application.run() # Quits when all added windows close. - except KeyboardInterrupt: - # Ensure all windows can process their close event from - # _shutdown_application. - context = GLib.MainContext.default() - while context.pending(): - context.iteration(True) - raise - finally: - # Running after quit is undefined, so create a new one next time. - _application = None + backend_version = "%s.%s.%s" % ( + Gtk.get_major_version(), + Gtk.get_minor_version(), + Gtk.get_micro_version(), + ) + mainloop = _FigureManagerGTK.start_main_loop diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index cc15d9b7e3f7..30d952e7fe34 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -9,7 +9,7 @@ import matplotlib as mpl from .. import font_manager, ft2font -from ..afm import AFM +from .._afm import AFM from ..backend_bases import RendererBase @@ -65,7 +65,9 @@ def __init__(self): def track(self, font, s): """Record that string *s* is being typeset using font *font*.""" - self.used.setdefault(font.fname, set()).update(map(ord, s)) + char_to_font = font._get_fontmap(s) + for _c, _f in char_to_font.items(): + self.used.setdefault(_f.fname, set()).add(ord(_c)) def track_glyph(self, font, glyph): """Record that codepoint *glyph* is being typeset using font *font*.""" @@ -102,18 +104,9 @@ def get_canvas_width_height(self): def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited if ismath == "TeX": - texmanager = self.get_texmanager() - fontsize = prop.get_size_in_points() - w, h, d = texmanager.get_text_width_height_descent( - s, fontsize, renderer=self) - return w, h, d + return super().get_text_width_height_descent(s, prop, ismath) elif ismath: - # Circular import. - from matplotlib.backends.backend_ps import RendererPS - parse = self._text2path.mathtext_parser.parse( - s, 72, prop, - _force_standard_ps_fonts=(isinstance(self, RendererPS) - and mpl.rcParams["ps.useafm"])) + parse = self._text2path.mathtext_parser.parse(s, 72, prop) return parse.width, parse.height, parse.depth elif mpl.rcParams[self._use_afm_rc_name]: font = self._get_font_afm(prop) @@ -140,8 +133,8 @@ def _get_font_afm(self, prop): return _cached_get_afm_from_fname(fname) def _get_font_ttf(self, prop): - fname = font_manager.findfont(prop) - font = font_manager.get_font(fname) + fnames = font_manager.fontManager._find_fonts_by_props(prop) + font = font_manager.get_font(fnames) font.clear() font.set_size(prop.get_size_in_points(), 72) return font diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index d439c07b90bd..f26619c55ec1 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -1,4 +1,5 @@ import uuid +import weakref from contextlib import contextmanager import logging import math @@ -17,17 +18,13 @@ from matplotlib import _api, backend_tools, cbook, _c_internal_utils from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase, ToolContainerBase, cursors, _Mode) + TimerBase, ToolContainerBase, cursors, _Mode, + CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) from matplotlib._pylab_helpers import Gcf -from matplotlib.figure import Figure -from matplotlib.widgets import SubplotTool from . import _tkagg _log = logging.getLogger(__name__) - -backend_version = tk.TkVersion - cursord = { cursors.MOVE: "fleur", cursors.HAND: "hand2", @@ -80,6 +77,8 @@ def blit(photoimage, aggimage, offsets, bbox=None): If *bbox* is passed, it defines the region that gets blitted. That region will be composed with the previous data according to the alpha channel. + Blitting will be clipped to pixels inside the canvas, including silently + doing nothing if the *bbox* region is entirely outside the canvas. Tcl events must be dispatched to trigger a blit from a non-Tcl thread. """ @@ -92,6 +91,8 @@ def blit(photoimage, aggimage, offsets, bbox=None): x2 = min(math.ceil(x2), width) y1 = max(math.floor(y1), 0) y2 = min(math.ceil(y2), height) + if (x1 > x2) or (y1 > y2): + return bboxptr = (x1, x2, y1, y2) comp_rule = TK_PHOTO_COMPOSITE_OVERLAY else: @@ -160,11 +161,9 @@ def _on_timer(self): class FigureCanvasTk(FigureCanvasBase): required_interactive_framework = "tk" + manager_class = _api.classproperty(lambda cls: FigureManagerTk) - @_api.delete_parameter( - "3.4", "resize_callback", - alternative="get_tk_widget().bind('', ..., True)") - def __init__(self, figure=None, master=None, resize_callback=None): + def __init__(self, figure=None, master=None): super().__init__(figure) self._idle_draw_id = None self._event_loop_id = None @@ -175,7 +174,6 @@ def __init__(self, figure=None, master=None, resize_callback=None): self._tkphoto = tk.PhotoImage( master=self._tkcanvas, width=w, height=h) self._tkcanvas.create_image(w//2, h//2, image=self._tkphoto) - self._resize_callback = resize_callback self._tkcanvas.bind("", self.resize) if sys.platform == 'win32': self._tkcanvas.bind("", self._update_device_pixel_ratio) @@ -201,17 +199,39 @@ def __init__(self, figure=None, master=None, resize_callback=None): # event to the window containing the canvas instead. # See https://wiki.tcl-lang.org/3893 (mousewheel) for details root = self._tkcanvas.winfo_toplevel() - root.bind("", self.scroll_event_windows, "+") + + # Prevent long-lived references via tkinter callback structure GH-24820 + weakself = weakref.ref(self) + weakroot = weakref.ref(root) + + def scroll_event_windows(event): + self = weakself() + if self is None: + root = weakroot() + if root is not None: + root.unbind("", scroll_event_windows_id) + return + return self.scroll_event_windows(event) + scroll_event_windows_id = root.bind("", scroll_event_windows, "+") # Can't get destroy events by binding to _tkcanvas. Therefore, bind # to the window and filter. def filter_destroy(event): + self = weakself() + if self is None: + root = weakroot() + if root is not None: + root.unbind("", filter_destroy_id) + return if event.widget is self._tkcanvas: - self.close_event() - root.bind("", filter_destroy, "+") + CloseEvent("close_event", self)._process() + filter_destroy_id = root.bind("", filter_destroy, "+") self._tkcanvas.focus_set() + self._rubberband_rect_black = None + self._rubberband_rect_white = None + def _update_device_pixel_ratio(self, event=None): # Tk gives scaling with respect to 72 DPI, but Windows screens are # scaled vs 96 dpi, and pixel ratio settings are given in whole @@ -226,8 +246,6 @@ def _update_device_pixel_ratio(self, event=None): def resize(self, event): width, height = event.width, event.height - if self._resize_callback is not None: - self._resize_callback(event) # compute desired figure size in inches dpival = self.figure.dpi @@ -240,7 +258,8 @@ def resize(self, event): master=self._tkcanvas, width=int(width), height=int(height)) self._tkcanvas.create_image( int(width / 2), int(height / 2), image=self._tkphoto) - self.resize_event() + ResizeEvent("resize_event", self)._process() + self.draw_idle() def draw_idle(self): # docstring inherited @@ -272,12 +291,22 @@ def _event_mpl_coords(self, event): self.figure.bbox.height - self._tkcanvas.canvasy(event.y)) def motion_notify_event(self, event): - super().motion_notify_event( - *self._event_mpl_coords(event), guiEvent=event) + MouseEvent("motion_notify_event", self, + *self._event_mpl_coords(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() def enter_notify_event(self, event): - super().enter_notify_event( - guiEvent=event, xy=self._event_mpl_coords(event)) + LocationEvent("figure_enter_event", self, + *self._event_mpl_coords(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + + def leave_notify_event(self, event): + LocationEvent("figure_leave_event", self, + *self._event_mpl_coords(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() def button_press_event(self, event, dblclick=False): # set focus to the canvas so that it can receive keyboard events @@ -286,9 +315,10 @@ def button_press_event(self, event, dblclick=False): num = getattr(event, 'num', None) if sys.platform == 'darwin': # 2 and 3 are reversed. num = {2: 3, 3: 2}.get(num, num) - super().button_press_event( - *self._event_mpl_coords(event), num, dblclick=dblclick, - guiEvent=event) + MouseEvent("button_press_event", self, + *self._event_mpl_coords(event), num, dblclick=dblclick, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() def button_dblclick_event(self, event): self.button_press_event(event, dblclick=True) @@ -297,30 +327,35 @@ def button_release_event(self, event): num = getattr(event, 'num', None) if sys.platform == 'darwin': # 2 and 3 are reversed. num = {2: 3, 3: 2}.get(num, num) - super().button_release_event( - *self._event_mpl_coords(event), num, guiEvent=event) + MouseEvent("button_release_event", self, + *self._event_mpl_coords(event), num, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() def scroll_event(self, event): num = getattr(event, 'num', None) step = 1 if num == 4 else -1 if num == 5 else 0 - super().scroll_event( - *self._event_mpl_coords(event), step, guiEvent=event) + MouseEvent("scroll_event", self, + *self._event_mpl_coords(event), step=step, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() def scroll_event_windows(self, event): """MouseWheel event processor""" # need to find the window that contains the mouse w = event.widget.winfo_containing(event.x_root, event.y_root) - if w == self._tkcanvas: - x = self._tkcanvas.canvasx(event.x_root - w.winfo_rootx()) - y = (self.figure.bbox.height - - self._tkcanvas.canvasy(event.y_root - w.winfo_rooty())) - step = event.delta/120. - FigureCanvasBase.scroll_event(self, x, y, step, guiEvent=event) - - def _get_key(self, event): - unikey = event.char - key = cbook._unikey_or_keysym_to_mplkey(unikey, event.keysym) + if w != self._tkcanvas: + return + x = self._tkcanvas.canvasx(event.x_root - w.winfo_rootx()) + y = (self.figure.bbox.height + - self._tkcanvas.canvasy(event.y_root - w.winfo_rooty())) + step = event.delta / 120 + MouseEvent("scroll_event", self, + x, y, step=step, modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() + @staticmethod + def _mpl_modifiers(event, *, exclude=None): # add modifier keys to the key string. Bit details originate from # http://effbot.org/tkinterbook/tkinter-events-and-bindings.htm # BIT_SHIFT = 0x001; BIT_CAPSLOCK = 0x002; BIT_CONTROL = 0x004; @@ -329,40 +364,43 @@ def _get_key(self, event): # In general, the modifier key is excluded from the modifier flag, # however this is not the case on "darwin", so double check that # we aren't adding repeat modifier flags to a modifier key. - if sys.platform == 'win32': - modifiers = [(2, 'ctrl', 'control'), - (17, 'alt', 'alt'), - (0, 'shift', 'shift'), - ] - elif sys.platform == 'darwin': - modifiers = [(2, 'ctrl', 'control'), - (4, 'alt', 'alt'), - (0, 'shift', 'shift'), - (3, 'super', 'super'), - ] - else: - modifiers = [(2, 'ctrl', 'control'), - (3, 'alt', 'alt'), - (0, 'shift', 'shift'), - (6, 'super', 'super'), - ] + modifiers = [ + ("ctrl", 1 << 2, "control"), + ("alt", 1 << 17, "alt"), + ("shift", 1 << 0, "shift"), + ] if sys.platform == "win32" else [ + ("ctrl", 1 << 2, "control"), + ("alt", 1 << 4, "alt"), + ("shift", 1 << 0, "shift"), + ("super", 1 << 3, "super"), + ] if sys.platform == "darwin" else [ + ("ctrl", 1 << 2, "control"), + ("alt", 1 << 3, "alt"), + ("shift", 1 << 0, "shift"), + ("super", 1 << 6, "super"), + ] + return [name for name, mask, key in modifiers + if event.state & mask and exclude != key] + def _get_key(self, event): + unikey = event.char + key = cbook._unikey_or_keysym_to_mplkey(unikey, event.keysym) if key is not None: - # shift is not added to the keys as this is already accounted for - for bitmask, prefix, key_name in modifiers: - if event.state & (1 << bitmask) and key_name not in key: - if not (prefix == 'shift' and unikey): - key = '{0}+{1}'.format(prefix, key) - - return key + mods = self._mpl_modifiers(event, exclude=key) + # shift is not added to the keys as this is already accounted for. + if "shift" in mods and unikey: + mods.remove("shift") + return "+".join([*mods, key]) def key_press(self, event): - key = self._get_key(event) - FigureCanvasBase.key_press_event(self, key, guiEvent=event) + KeyEvent("key_press_event", self, + self._get_key(event), *self._event_mpl_coords(event), + guiEvent=event)._process() def key_release(self, event): - key = self._get_key(event) - FigureCanvasBase.key_release_event(self, key, guiEvent=event) + KeyEvent("key_release_event", self, + self._get_key(event), *self._event_mpl_coords(event), + guiEvent=event)._process() def new_timer(self, *args, **kwargs): # docstring inherited @@ -420,14 +458,8 @@ def __init__(self, canvas, num, window): self.window.withdraw() # packing toolbar first, because if space is getting low, last packed # widget is getting shrunk first (-> the canvas) - self.toolbar = self._get_toolbar() self.canvas._tkcanvas.pack(side=tk.TOP, fill=tk.BOTH, expand=1) - if self.toolmanager: - backend_tools.add_tools_to_manager(self.toolmanager) - if self.toolbar: - backend_tools.add_tools_to_container(self.toolbar) - # If the window has per-monitor DPI awareness, then setup a Tk variable # to store the DPI, which will be updated by the C code, and the trace # will handle it on the Python side. @@ -441,14 +473,56 @@ def __init__(self, canvas, num, window): self._shown = False - def _get_toolbar(self): - if mpl.rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2Tk(self.canvas, self.window) - elif mpl.rcParams['toolbar'] == 'toolmanager': - toolbar = ToolbarTk(self.toolmanager, self.window) - else: - toolbar = None - return toolbar + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + # docstring inherited + with _restore_foreground_window_at_end(): + if cbook._get_running_interactive_framework() is None: + cbook._setup_new_guiapp() + _c_internal_utils.Win32_SetProcessDpiAwareness_max() + window = tk.Tk(className="matplotlib") + window.withdraw() + + # Put a Matplotlib icon on the window rather than the default tk + # icon. See https://www.tcl.tk/man/tcl/TkCmd/wm.html#M50 + # + # `ImageTk` can be replaced with `tk` whenever the minimum + # supported Tk version is increased to 8.6, as Tk 8.6+ natively + # supports PNG images. + icon_fname = str(cbook._get_data_path( + 'images/matplotlib.png')) + icon_img = ImageTk.PhotoImage(file=icon_fname, master=window) + + icon_fname_large = str(cbook._get_data_path( + 'images/matplotlib_large.png')) + icon_img_large = ImageTk.PhotoImage( + file=icon_fname_large, master=window) + try: + window.iconphoto(False, icon_img_large, icon_img) + except Exception as exc: + # log the failure (due e.g. to Tk version), but carry on + _log.info('Could not load matplotlib icon: %s', exc) + + canvas = canvas_class(figure, master=window) + manager = cls(canvas, num, window) + if mpl.is_interactive(): + manager.show() + canvas.draw_idle() + return manager + + @classmethod + def start_main_loop(cls): + managers = Gcf.get_all_fig_managers() + if managers: + first_manager = managers[0] + manager_class = type(first_manager) + if manager_class._owns_mainloop: + return + manager_class._owns_mainloop = True + try: + first_manager.window.mainloop() + finally: + manager_class._owns_mainloop = False def _update_window_dpi(self, *args): newdpi = self._window_dpi.get() @@ -524,24 +598,26 @@ def full_screen_toggle(self): class NavigationToolbar2Tk(NavigationToolbar2, tk.Frame): - """ - Attributes - ---------- - canvas : `FigureCanvas` - The figure canvas on which to operate. - win : tk.Window - The tk.Window which owns this toolbar. - pack_toolbar : bool, default: True - If True, add the toolbar to the parent's pack manager's packing list - during initialization with ``side='bottom'`` and ``fill='x'``. - If you want to use the toolbar with a different layout manager, use - ``pack_toolbar=False``. - """ - def __init__(self, canvas, window, *, pack_toolbar=True): - # Avoid using self.window (prefer self.canvas.get_tk_widget().master), - # so that Tool implementations can reuse the methods. - self.window = window + window = _api.deprecated("3.6", alternative="self.master")( + property(lambda self: self.master)) + def __init__(self, canvas, window=None, *, pack_toolbar=True): + """ + Parameters + ---------- + canvas : `FigureCanvas` + The figure canvas on which to operate. + window : tk.Window + The tk.Window which owns this toolbar. + pack_toolbar : bool, default: True + If True, add the toolbar to the parent's pack manager's packing + list during initialization with ``side="bottom"`` and ``fill="x"``. + If you want to use the toolbar with a different layout manager, use + ``pack_toolbar=False``. + """ + + if window is None: + window = canvas.get_tk_widget().master tk.Frame.__init__(self, master=window, borderwidth=2, width=int(canvas.figure.bbox.width), height=50) @@ -572,7 +648,8 @@ def __init__(self, canvas, window, *, pack_toolbar=True): self.message = tk.StringVar(master=self) self._message_label = tk.Label(master=self, font=self._label_font, - textvariable=self.message) + textvariable=self.message, + justify=tk.RIGHT) self._message_label.pack(side=tk.RIGHT) NavigationToolbar2.__init__(self, canvas) @@ -627,16 +704,31 @@ def set_message(self, s): self.message.set(s) def draw_rubberband(self, event, x0, y0, x1, y1): - self.remove_rubberband() + # Block copied from remove_rubberband for backend_tools convenience. + if self.canvas._rubberband_rect_white: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white) + if self.canvas._rubberband_rect_black: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black) height = self.canvas.figure.bbox.height y0 = height - y0 y1 = height - y1 - self.lastrect = self.canvas._tkcanvas.create_rectangle(x0, y0, x1, y1) + self.canvas._rubberband_rect_black = ( + self.canvas._tkcanvas.create_rectangle( + x0, y0, x1, y1)) + self.canvas._rubberband_rect_white = ( + self.canvas._tkcanvas.create_rectangle( + x0, y0, x1, y1, outline='white', dash=(3, 3))) def remove_rubberband(self): - if hasattr(self, "lastrect"): - self.canvas._tkcanvas.delete(self.lastrect) - del self.lastrect + if self.canvas._rubberband_rect_white: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white) + self.canvas._rubberband_rect_white = None + if self.canvas._rubberband_rect_black: + self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black) + self.canvas._rubberband_rect_black = None + + lastrect = _api.deprecated("3.6")( + property(lambda self: self.canvas._rubberband_rect_black)) def _set_image_for_button(self, button): """ @@ -653,16 +745,76 @@ def _set_image_for_button(self, button): path_large = path_regular.with_name( path_regular.name.replace('.png', '_large.png')) size = button.winfo_pixels('18p') + + # Nested functions because ToolbarTk calls _Button. + def _get_color(color_name): + # `winfo_rgb` returns an (r, g, b) tuple in the range 0-65535 + return button.winfo_rgb(button.cget(color_name)) + + def _is_dark(color): + if isinstance(color, str): + color = _get_color(color) + return max(color) < 65535 / 2 + + def _recolor_icon(image, color): + image_data = np.asarray(image).copy() + black_mask = (image_data[..., :3] == 0).all(axis=-1) + image_data[black_mask, :3] = color + return Image.fromarray(image_data, mode="RGBA") + # Use the high-resolution (48x48 px) icon if it exists and is needed with Image.open(path_large if (size > 24 and path_large.exists()) else path_regular) as im: + # assure a RGBA image as foreground color is RGB + im = im.convert("RGBA") image = ImageTk.PhotoImage(im.resize((size, size)), master=self) - button.configure(image=image, height='18p', width='18p') - button._ntimage = image # Prevent garbage collection. + button._ntimage = image + + # create a version of the icon with the button's text color + foreground = (255 / 65535) * np.array( + button.winfo_rgb(button.cget("foreground"))) + im_alt = _recolor_icon(im, foreground) + image_alt = ImageTk.PhotoImage( + im_alt.resize((size, size)), master=self) + button._ntimage_alt = image_alt + + if _is_dark("background"): + # For Checkbuttons, we need to set `image` and `selectimage` at + # the same time. Otherwise, when updating the `image` option + # (such as when changing DPI), if the old `selectimage` has + # just been overwritten, Tk will throw an error. + image_kwargs = {"image": image_alt} + else: + image_kwargs = {"image": image} + # Checkbuttons may switch the background to `selectcolor` in the + # checked state, so check separately which image it needs to use in + # that state to still ensure enough contrast with the background. + if ( + isinstance(button, tk.Checkbutton) + and button.cget("selectcolor") != "" + ): + if self._windowingsystem != "x11": + selectcolor = "selectcolor" + else: + # On X11, selectcolor isn't used directly for indicator-less + # buttons. See `::tk::CheckEnter` in the Tk button.tcl source + # code for details. + r1, g1, b1 = _get_color("selectcolor") + r2, g2, b2 = _get_color("activebackground") + selectcolor = ((r1+r2)/2, (g1+g2)/2, (b1+b2)/2) + if _is_dark(selectcolor): + image_kwargs["selectimage"] = image_alt + else: + image_kwargs["selectimage"] = image + + button.configure(**image_kwargs, height='18p', width='18p') def _Button(self, text, image_file, toggle, command): if not toggle: - b = tk.Button(master=self, text=text, command=command) + b = tk.Button( + master=self, text=text, command=command, + relief="flat", overrelief="groove", borderwidth=1, + ) else: # There is a bug in tkinter included in some python 3.6 versions # that without this variable, produces a "visual" toggling of @@ -671,8 +823,10 @@ def _Button(self, text, image_file, toggle, command): # https://bugs.python.org/issue25684 var = tk.IntVar(master=self) b = tk.Checkbutton( - master=self, text=text, command=command, - indicatoron=False, variable=var) + master=self, text=text, command=command, indicatoron=False, + variable=var, offrelief="flat", overrelief="groove", + borderwidth=1 + ) b.var = var b._image_file = image_file if image_file is not None: @@ -791,31 +945,25 @@ def hidetip(self): tw.destroy() +@backend_tools._register_tool_class(FigureCanvasTk) class RubberbandTk(backend_tools.RubberbandBase): def draw_rubberband(self, x0, y0, x1, y1): - self.remove_rubberband() - height = self.figure.canvas.figure.bbox.height - y0 = height - y0 - y1 = height - y1 - self.lastrect = self.figure.canvas._tkcanvas.create_rectangle( - x0, y0, x1, y1) + NavigationToolbar2Tk.draw_rubberband( + self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1) def remove_rubberband(self): - if hasattr(self, "lastrect"): - self.figure.canvas._tkcanvas.delete(self.lastrect) - del self.lastrect - + NavigationToolbar2Tk.remove_rubberband( + self._make_classic_style_pseudo_toolbar()) -@_api.deprecated("3.5", alternative="ToolSetCursor") -class SetCursorTk(backend_tools.SetCursorBase): - def set_cursor(self, cursor): - NavigationToolbar2Tk.set_cursor( - self._make_classic_style_pseudo_toolbar(), cursor) + lastrect = _api.deprecated("3.6")( + property(lambda self: self.figure.canvas._rubberband_rect_black)) class ToolbarTk(ToolContainerBase, tk.Frame): - def __init__(self, toolmanager, window): + def __init__(self, toolmanager, window=None): ToolContainerBase.__init__(self, toolmanager) + if window is None: + window = self.toolmanager.canvas.get_tk_widget().master xmin, xmax = self.toolmanager.canvas.figure.bbox.intervalx height, width = 50, xmax - xmin tk.Frame.__init__(self, master=window, @@ -883,41 +1031,20 @@ def set_message(self, s): self._message.set(s) +@backend_tools._register_tool_class(FigureCanvasTk) class SaveFigureTk(backend_tools.SaveFigureBase): def trigger(self, *args): NavigationToolbar2Tk.save_figure( self._make_classic_style_pseudo_toolbar()) +@backend_tools._register_tool_class(FigureCanvasTk) class ConfigureSubplotsTk(backend_tools.ConfigureSubplotsBase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.window = None - def trigger(self, *args): - self.init_window() - self.window.lift() - - def init_window(self): - if self.window: - return - - toolfig = Figure(figsize=(6, 3)) - self.window = tk.Tk() - - canvas = type(self.canvas)(toolfig, master=self.window) - toolfig.subplots_adjust(top=0.9) - SubplotTool(self.figure, toolfig) - canvas.draw() - canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=1) - self.window.protocol("WM_DELETE_WINDOW", self.destroy) - - def destroy(self, *args, **kwargs): - if self.window is not None: - self.window.destroy() - self.window = None + NavigationToolbar2Tk.configure_subplots(self) +@backend_tools._register_tool_class(FigureCanvasTk) class HelpTk(backend_tools.ToolHelpBase): def trigger(self, *args): dialog = SimpleDialog( @@ -925,60 +1052,14 @@ def trigger(self, *args): dialog.done = lambda num: dialog.frame.master.withdraw() -backend_tools.ToolSaveFigure = SaveFigureTk -backend_tools.ToolConfigureSubplots = ConfigureSubplotsTk -backend_tools.ToolRubberband = RubberbandTk -backend_tools.ToolHelp = HelpTk -backend_tools.ToolCopyToClipboard = backend_tools.ToolCopyToClipboardBase Toolbar = ToolbarTk +FigureManagerTk._toolbar2_class = NavigationToolbar2Tk +FigureManagerTk._toolmanager_toolbar_class = ToolbarTk @_Backend.export class _BackendTk(_Backend): + backend_version = tk.TkVersion + FigureCanvas = FigureCanvasTk FigureManager = FigureManagerTk - - @classmethod - def new_figure_manager_given_figure(cls, num, figure): - """ - Create a new figure manager instance for the given figure. - """ - with _restore_foreground_window_at_end(): - if cbook._get_running_interactive_framework() is None: - cbook._setup_new_guiapp() - _c_internal_utils.Win32_SetProcessDpiAwareness_max() - window = tk.Tk(className="matplotlib") - window.withdraw() - - # Put a Matplotlib icon on the window rather than the default tk - # icon. Tkinter doesn't allow colour icons on linux systems, but - # tk>=8.5 has a iconphoto command which we call directly. See - # https://mail.python.org/pipermail/tkinter-discuss/2006-November/000954.html - icon_fname = str(cbook._get_data_path( - 'images/matplotlib_128.ppm')) - icon_img = tk.PhotoImage(file=icon_fname, master=window) - try: - window.iconphoto(False, icon_img) - except Exception as exc: - # log the failure (due e.g. to Tk version), but carry on - _log.info('Could not load matplotlib icon: %s', exc) - - canvas = cls.FigureCanvas(figure, master=window) - manager = cls.FigureManager(canvas, num, window) - if mpl.is_interactive(): - manager.show() - canvas.draw_idle() - return manager - - @staticmethod - def mainloop(): - managers = Gcf.get_all_fig_managers() - if managers: - first_manager = managers[0] - manager_class = type(first_manager) - if manager_class._owns_mainloop: - return - manager_class._owns_mainloop = True - try: - first_manager.window.mainloop() - finally: - manager_class._owns_mainloop = False + mainloop = FigureManagerTk.start_main_loop diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 59156e7bd81a..0fe0fc40c00b 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -21,23 +21,17 @@ .. _Anti-Grain Geometry: http://agg.sourceforge.net/antigrain.com """ -try: - import threading -except ImportError: - import dummy_threading as threading from contextlib import nullcontext from math import radians, cos, sin +import threading import numpy as np -from PIL import Image import matplotlib as mpl from matplotlib import _api, cbook -from matplotlib import colors as mcolors from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - RendererBase) -from matplotlib.font_manager import findfont, get_font + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) +from matplotlib.font_manager import fontManager as _fontManager, get_font from matplotlib.ft2font import (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING, LOAD_DEFAULT, LOAD_NO_AUTOHINT) from matplotlib.mathtext import MathTextParser @@ -46,9 +40,6 @@ from matplotlib.backends._backend_agg import RendererAgg as _RendererAgg -backend_version = 'v2.2' - - def get_hinting_flag(): mapping = { 'default': LOAD_DEFAULT, @@ -115,21 +106,6 @@ def _update_methods(self): self.draw_quad_mesh = self._renderer.draw_quad_mesh self.copy_from_bbox = self._renderer.copy_from_bbox - @_api.deprecated("3.4") - def get_content_extents(self): - orig_img = np.asarray(self.buffer_rgba()) - slice_y, slice_x = cbook._get_nonzero_slices(orig_img[..., 3]) - return (slice_x.start, slice_y.start, - slice_x.stop - slice_x.start, slice_y.stop - slice_y.start) - - @_api.deprecated("3.4") - def tostring_rgba_minimized(self): - extents = self.get_content_extents() - bbox = [[extents[0], self.height - (extents[1] + extents[3])], - [extents[0] + extents[2], self.height - extents[1]]] - region = self.copy_from_bbox(bbox) - return np.array(region), extents - def draw_path(self, gc, path, transform, rgbFace=None): # docstring inherited nmax = mpl.rcParams['agg.path.chunksize'] # here at least for testing @@ -153,7 +129,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): p.simplify_threshold = path.simplify_threshold try: self._renderer.draw_path(gc, p, transform, rgbFace) - except OverflowError as err: + except OverflowError: msg = ( "Exceeded cell block limit in Agg.\n\n" "Please reduce the value of " @@ -168,7 +144,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): else: try: self._renderer.draw_path(gc, path, transform, rgbFace) - except OverflowError as err: + except OverflowError: cant_chunk = '' if rgbFace is not None: cant_chunk += "- can not split filled path\n" @@ -212,7 +188,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): def draw_mathtext(self, gc, x, y, s, prop, angle): """Draw mathtext using :mod:`matplotlib.mathtext`.""" - ox, oy, width, height, descent, font_image, used_characters = \ + ox, oy, width, height, descent, font_image = \ self.mathtext_parser.parse(s, self.dpi, prop) xd = descent * sin(radians(angle)) @@ -223,18 +199,12 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited - if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) - - flags = get_hinting_flag() - font = self._get_agg_font(prop) - - if font is None: - return None + font = self._prepare_font(prop) # We pass '0' for angle here, since it will be rotated (in raster # space) in the following call to draw_text_image). - font.set_text(s, 0, flags=flags) + font.set_text(s, 0, flags=get_hinting_flag()) font.draw_glyphs_to_bitmap( antialiased=mpl.rcParams['text.antialiased']) d = font.get_descent() / 64.0 @@ -253,21 +223,15 @@ def get_text_width_height_descent(self, s, prop, ismath): _api.check_in_list(["TeX", True, False], ismath=ismath) if ismath == "TeX": - # todo: handle props - texmanager = self.get_texmanager() - fontsize = prop.get_size_in_points() - w, h, d = texmanager.get_text_width_height_descent( - s, fontsize, renderer=self) - return w, h, d + return super().get_text_width_height_descent(s, prop, ismath) if ismath: - ox, oy, width, height, descent, fonts, used_characters = \ + ox, oy, width, height, descent, font_image = \ self.mathtext_parser.parse(s, self.dpi, prop) return width, height, descent - flags = get_hinting_flag() - font = self._get_agg_font(prop) - font.set_text(s, 0.0, flags=flags) + font = self._prepare_font(prop) + font.set_text(s, 0.0, flags=get_hinting_flag()) w, h = font.get_width_height() # width and height of unrotated string d = font.get_descent() w /= 64.0 # convert from subpixels @@ -296,17 +260,14 @@ def get_canvas_width_height(self): # docstring inherited return self.width, self.height - def _get_agg_font(self, prop): + def _prepare_font(self, font_prop): """ - Get the font for text instance t, caching for efficiency + Get the `.FT2Font` for *font_prop*, clear its buffer, and set its size. """ - fname = findfont(prop) - font = get_font(fname) - + font = get_font(_fontManager._find_fonts_by_props(font_prop)) font.clear() - size = prop.get_size_in_points() + size = font_prop.get_size_in_points() font.set_size(size, self.dpi) - return font def points_to_pixels(self, points): @@ -374,7 +335,7 @@ def restore_region(self, region, bbox=None, xy=None): def start_filter(self): """ - Start filtering. It simply create a new canvas (the old one is saved). + Start filtering. It simply creates a new canvas (the old one is saved). """ self._filter_renderers.append(self._renderer) self._renderer = _RendererAgg(int(self.width), int(self.height), @@ -383,7 +344,7 @@ def start_filter(self): def stop_filter(self, post_processing): """ - Save the plot in the current canvas as a image and apply + Save the plot in the current canvas as an image and apply the *post_processing* function. def post_processing(image, dpi): @@ -418,6 +379,8 @@ def post_processing(image, dpi): class FigureCanvasAgg(FigureCanvasBase): # docstring inherited + _lastKey = None # Overwritten per-instance on the first draw. + def copy_from_bbox(self, bbox): renderer = self.get_renderer() return renderer.copy_from_bbox(bbox) @@ -428,7 +391,8 @@ def restore_region(self, region, bbox=None, xy=None): def draw(self): # docstring inherited - self.renderer = self.get_renderer(cleared=True) + self.renderer = self.get_renderer() + self.renderer.clear() # Acquire a lock on the shared font cache. with RendererAgg.lock, \ (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar @@ -438,11 +402,11 @@ def draw(self): # don't forget to call the superclass. super().draw() + @_api.delete_parameter("3.6", "cleared", alternative="renderer.clear()") def get_renderer(self, cleared=False): w, h = self.figure.bbox.size key = w, h, self.figure.dpi - reuse_renderer = (hasattr(self, "renderer") - and getattr(self, "_lastKey", None) == key) + reuse_renderer = (self._lastKey == key) if not reuse_renderer: self.renderer = RendererAgg(w, h, self.figure.dpi) self._lastKey = key @@ -477,9 +441,7 @@ def buffer_rgba(self): """ return self.renderer.buffer_rgba() - @_check_savefig_extra_args - @_api.delete_parameter("3.5", "args") - def print_raw(self, filename_or_obj, *args): + def print_raw(self, filename_or_obj): FigureCanvasAgg.draw(self) renderer = self.get_renderer() with cbook.open_file_cm(filename_or_obj, "wb") as fh: @@ -487,10 +449,17 @@ def print_raw(self, filename_or_obj, *args): print_rgba = print_raw - @_check_savefig_extra_args - @_api.delete_parameter("3.5", "args") - def print_png(self, filename_or_obj, *args, - metadata=None, pil_kwargs=None): + def _print_pil(self, filename_or_obj, fmt, pil_kwargs, metadata=None): + """ + Draw the canvas, then save it using `.image.imsave` (to which + *pil_kwargs* and *metadata* are forwarded). + """ + FigureCanvasAgg.draw(self) + mpl.image.imsave( + filename_or_obj, self.buffer_rgba(), format=fmt, origin="upper", + dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs) + + def print_png(self, filename_or_obj, *, metadata=None, pil_kwargs=None): """ Write the figure to a PNG file. @@ -537,10 +506,7 @@ def print_png(self, filename_or_obj, *args, If the 'pnginfo' key is present, it completely overrides *metadata*, including the default 'Software' key. """ - FigureCanvasAgg.draw(self) - mpl.image.imsave( - filename_or_obj, self.buffer_rgba(), format="png", origin="upper", - dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs) + self._print_pil(filename_or_obj, "png", pil_kwargs, metadata) def print_to_buffer(self): FigureCanvasAgg.draw(self) @@ -552,52 +518,39 @@ def print_to_buffer(self): # print_figure(), and the latter ensures that `self.figure.dpi` already # matches the dpi kwarg (if any). - @_check_savefig_extra_args() - @_api.delete_parameter("3.5", "args") - def print_jpg(self, filename_or_obj, *args, pil_kwargs=None, **kwargs): + def print_jpg(self, filename_or_obj, *, pil_kwargs=None): + # savefig() has already applied savefig.facecolor; we now set it to + # white to make imsave() blend semi-transparent figures against an + # assumed white background. + with mpl.rc_context({"savefig.facecolor": "white"}): + self._print_pil(filename_or_obj, "jpeg", pil_kwargs) + + print_jpeg = print_jpg + + def print_tif(self, filename_or_obj, *, pil_kwargs=None): + self._print_pil(filename_or_obj, "tiff", pil_kwargs) + + print_tiff = print_tif + + def print_webp(self, filename_or_obj, *, pil_kwargs=None): + self._print_pil(filename_or_obj, "webp", pil_kwargs) + + print_jpg.__doc__, print_tif.__doc__, print_webp.__doc__ = map( """ - Write the figure to a JPEG file. + Write the figure to a {} file. Parameters ---------- filename_or_obj : str or path-like or file-like The file to write to. - - Other Parameters - ---------------- pil_kwargs : dict, optional Additional keyword arguments that are passed to `PIL.Image.Image.save` when saving the figure. - """ - # Remove transparency by alpha-blending on an assumed white background. - r, g, b, a = mcolors.to_rgba(self.figure.get_facecolor()) - try: - self.figure.set_facecolor(a * np.array([r, g, b]) + 1 - a) - FigureCanvasAgg.draw(self) - finally: - self.figure.set_facecolor((r, g, b, a)) - if pil_kwargs is None: - pil_kwargs = {} - pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) - # Drop alpha channel now. - return (Image.fromarray(np.asarray(self.buffer_rgba())[..., :3]) - .save(filename_or_obj, format='jpeg', **pil_kwargs)) - - print_jpeg = print_jpg - - @_check_savefig_extra_args - def print_tif(self, filename_or_obj, *, pil_kwargs=None): - FigureCanvasAgg.draw(self) - if pil_kwargs is None: - pil_kwargs = {} - pil_kwargs.setdefault("dpi", (self.figure.dpi, self.figure.dpi)) - return (Image.fromarray(np.asarray(self.buffer_rgba())) - .save(filename_or_obj, format='tiff', **pil_kwargs)) - - print_tiff = print_tif + """.format, ["JPEG", "TIFF", "WebP"]) @_Backend.export class _BackendAgg(_Backend): + backend_version = 'v2.2' FigureCanvas = FigureCanvasAgg FigureManager = FigureManagerBase diff --git a/lib/matplotlib/backends/backend_cairo.py b/lib/matplotlib/backends/backend_cairo.py index 05a760542f4f..547a2ae9271f 100644 --- a/lib/matplotlib/backends/backend_cairo.py +++ b/lib/matplotlib/backends/backend_cairo.py @@ -14,47 +14,26 @@ try: import cairo - if cairo.version_info < (1, 11, 0): - # Introduced create_for_data for Py3. + if cairo.version_info < (1, 14, 0): # Introduced set_device_scale. raise ImportError except ImportError: try: import cairocffi as cairo except ImportError as err: raise ImportError( - "cairo backend requires that pycairo>=1.11.0 or cairocffi " + "cairo backend requires that pycairo>=1.14.0 or cairocffi " "is installed") from err import matplotlib as mpl from .. import _api, cbook, font_manager from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) from matplotlib.font_manager import ttfFontProperty -from matplotlib.mathtext import MathTextParser from matplotlib.path import Path from matplotlib.transforms import Affine2D -backend_version = cairo.version - - -if cairo.__name__ == "cairocffi": - # Convert a pycairo context to a cairocffi one. - def _to_context(ctx): - if not isinstance(ctx, cairo.Context): - ctx = cairo.Context._from_pointer( - cairo.ffi.cast( - 'cairo_t **', - id(ctx) + object.__basicsize__)[0], - incref=True) - return ctx -else: - # Pass-through a pycairo context. - def _to_context(ctx): - return ctx - - def _append_path(ctx, path, transform, clip=None): for points, code in path.iter_segments( transform, remove_nans=True, clip=clip): @@ -93,53 +72,39 @@ def attr(field): return name, slant, weight -# Mappings used for deprecated properties in RendererCairo, see below. -_f_weights = { - 100: cairo.FONT_WEIGHT_NORMAL, - 200: cairo.FONT_WEIGHT_NORMAL, - 300: cairo.FONT_WEIGHT_NORMAL, - 400: cairo.FONT_WEIGHT_NORMAL, - 500: cairo.FONT_WEIGHT_NORMAL, - 600: cairo.FONT_WEIGHT_BOLD, - 700: cairo.FONT_WEIGHT_BOLD, - 800: cairo.FONT_WEIGHT_BOLD, - 900: cairo.FONT_WEIGHT_BOLD, - 'ultralight': cairo.FONT_WEIGHT_NORMAL, - 'light': cairo.FONT_WEIGHT_NORMAL, - 'normal': cairo.FONT_WEIGHT_NORMAL, - 'medium': cairo.FONT_WEIGHT_NORMAL, - 'regular': cairo.FONT_WEIGHT_NORMAL, - 'semibold': cairo.FONT_WEIGHT_BOLD, - 'bold': cairo.FONT_WEIGHT_BOLD, - 'heavy': cairo.FONT_WEIGHT_BOLD, - 'ultrabold': cairo.FONT_WEIGHT_BOLD, - 'black': cairo.FONT_WEIGHT_BOLD, -} -_f_angles = { - 'italic': cairo.FONT_SLANT_ITALIC, - 'normal': cairo.FONT_SLANT_NORMAL, - 'oblique': cairo.FONT_SLANT_OBLIQUE, -} - - class RendererCairo(RendererBase): - mathtext_parser = _api.deprecated("3.4")( - property(lambda self: MathTextParser('Cairo'))) - def __init__(self, dpi): self.dpi = dpi self.gc = GraphicsContextCairo(renderer=self) + self.width = None + self.height = None self.text_ctx = cairo.Context( cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)) super().__init__() + def set_context(self, ctx): + surface = ctx.get_target() + if hasattr(surface, "get_width") and hasattr(surface, "get_height"): + size = surface.get_width(), surface.get_height() + elif hasattr(surface, "get_extents"): # GTK4 RecordingSurface. + ext = surface.get_extents() + size = ext.width, ext.height + else: # vector surfaces. + ctx.save() + ctx.reset_clip() + rect, *rest = ctx.copy_clip_rectangle_list() + if rest: + raise TypeError("Cannot infer surface size") + size = rect.width, rect.height + ctx.restore() + self.gc.ctx = ctx + self.width, self.height = size + + @_api.deprecated("3.6", alternative="set_context") def set_ctx_from_surface(self, surface): self.gc.ctx = cairo.Context(surface) - # Although it may appear natural to automatically call - # `self.set_width_height(surface.get_width(), surface.get_height())` - # here (instead of having the caller do so separately), this would fail - # for PDF/PS/SVG surfaces, which have no way to report their extents. + @_api.deprecated("3.6") def set_width_height(self, width, height): self.width = width self.height = height @@ -414,6 +379,18 @@ def __init__(self, slices, data): class FigureCanvasCairo(FigureCanvasBase): + @property + def _renderer(self): + # In theory, _renderer should be set in __init__, but GUI canvas + # subclasses (FigureCanvasFooCairo) don't always interact well with + # multiple inheritance (FigureCanvasFoo inits but doesn't super-init + # FigureCanvasCairo), so initialize it in the getter instead. + if not hasattr(self, "_cached_renderer"): + self._cached_renderer = RendererCairo(self.figure.dpi) + return self._cached_renderer + + def get_renderer(self): + return self._renderer def copy_from_bbox(self, bbox): surface = self._renderer.gc.ctx.get_target() @@ -448,11 +425,9 @@ def restore_region(self, region): surface.mark_dirty_rectangle( slx.start, sly.start, slx.stop - slx.start, sly.stop - sly.start) - @_check_savefig_extra_args def print_png(self, fobj): self._get_printed_image_surface().write_to_png(fobj) - @_check_savefig_extra_args def print_rgba(self, fobj): width, height = self.get_width_height() buf = self._get_printed_image_surface().get_data() @@ -462,15 +437,13 @@ def print_rgba(self, fobj): print_raw = print_rgba def _get_printed_image_surface(self): + self._renderer.dpi = self.figure.dpi width, height = self.get_width_height() - renderer = RendererCairo(self.figure.dpi) - renderer.set_width_height(width, height) surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - renderer.set_ctx_from_surface(surface) - self.figure.draw(renderer) + self._renderer.set_context(cairo.Context(surface)) + self.figure.draw(self._renderer) return surface - @_check_savefig_extra_args def _save(self, fmt, fobj, *, orientation='portrait'): # save PDF/PS/SVG @@ -506,18 +479,16 @@ def _save(self, fmt, fobj, *, orientation='portrait'): else: raise ValueError("Unknown format: {!r}".format(fmt)) - # surface.set_dpi() can be used - renderer = RendererCairo(self.figure.dpi) - renderer.set_width_height(width_in_points, height_in_points) - renderer.set_ctx_from_surface(surface) - ctx = renderer.gc.ctx + self._renderer.dpi = self.figure.dpi + self._renderer.set_context(cairo.Context(surface)) + ctx = self._renderer.gc.ctx if orientation == 'landscape': ctx.rotate(np.pi / 2) ctx.translate(0, -height_in_points) # Perhaps add an '%%Orientation: Landscape' comment? - self.figure.draw(renderer) + self.figure.draw(self._renderer) ctx.show_page() surface.finish() @@ -530,7 +501,21 @@ def _save(self, fmt, fobj, *, orientation='portrait'): print_svgz = functools.partialmethod(_save, "svgz") +@_api.deprecated("3.6") +class _RendererGTKCairo(RendererCairo): + def set_context(self, ctx): + if (cairo.__name__ == "cairocffi" + and not isinstance(ctx, cairo.Context)): + ctx = cairo.Context._from_pointer( + cairo.ffi.cast( + 'cairo_t **', + id(ctx) + object.__basicsize__)[0], + incref=True) + self.gc.ctx = ctx + + @_Backend.export class _BackendCairo(_Backend): + backend_version = cairo.version FigureCanvas = FigureCanvasCairo FigureManager = FigureManagerBase diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index e1d006436263..dbb0982ee752 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -6,13 +6,9 @@ import matplotlib as mpl from matplotlib import _api, backend_tools, cbook -from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( - _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase, ToolContainerBase) -from matplotlib.backend_tools import Cursors -from matplotlib.figure import Figure -from matplotlib.widgets import SubplotTool + ToolContainerBase, CloseEvent, KeyEvent, LocationEvent, MouseEvent, + ResizeEvent) try: import gi @@ -29,12 +25,10 @@ raise ImportError from e from gi.repository import Gio, GLib, GObject, Gtk, Gdk -from ._backend_gtk import ( - _create_application, _shutdown_application, - backend_version, _BackendGTK, _NavigationToolbar2GTK, +from . import _backend_gtk +from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 + _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, TimerGTK as TimerGTK3, - ConfigureSubplotsGTK as ConfigureSubplotsGTK3, - RubberbandGTK as RubberbandGTK3, ) @@ -43,40 +37,24 @@ @_api.caching_module_getattr # module-level deprecations class __getattr__: - @_api.deprecated("3.5", obj_type="") - @property - def cursord(self): - try: - new_cursor = functools.partial( - Gdk.Cursor.new_from_name, Gdk.Display.get_default()) - return { - Cursors.MOVE: new_cursor("move"), - Cursors.HAND: new_cursor("pointer"), - Cursors.POINTER: new_cursor("default"), - Cursors.SELECT_REGION: new_cursor("crosshair"), - Cursors.WAIT: new_cursor("wait"), - } - except TypeError as exc: - return {} + icon_filename = _api.deprecated("3.6", obj_type="")(property( + lambda self: + "matplotlib.png" if sys.platform == "win32" else "matplotlib.svg")) + window_icon = _api.deprecated("3.6", obj_type="")(property( + lambda self: + str(cbook._get_data_path("images", __getattr__("icon_filename"))))) @functools.lru_cache() def _mpl_to_gtk_cursor(mpl_cursor): - name = _api.check_getitem({ - Cursors.MOVE: "move", - Cursors.HAND: "pointer", - Cursors.POINTER: "default", - Cursors.SELECT_REGION: "crosshair", - Cursors.WAIT: "wait", - Cursors.RESIZE_HORIZONTAL: "ew-resize", - Cursors.RESIZE_VERTICAL: "ns-resize", - }, cursor=mpl_cursor) - return Gdk.Cursor.new_from_name(Gdk.Display.get_default(), name) - - -class FigureCanvasGTK3(Gtk.DrawingArea, FigureCanvasBase): + return Gdk.Cursor.new_from_name( + Gdk.Display.get_default(), + _backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor)) + + +class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk3" - _timer_cls = TimerGTK3 + manager_class = _api.classproperty(lambda cls: FigureManagerGTK3) # Setting this as a static constant prevents # this resulting expression from leaking event_mask = (Gdk.EventMask.BUTTON_PRESS_MASK @@ -90,11 +68,9 @@ class FigureCanvasGTK3(Gtk.DrawingArea, FigureCanvasBase): | Gdk.EventMask.SCROLL_MASK) def __init__(self, figure=None): - FigureCanvasBase.__init__(self, figure) - GObject.GObject.__init__(self) + super().__init__(figure=figure) self._idle_draw_id = 0 - self._lastCursor = None self._rubberband_rect = None self.connect('scroll_event', self.scroll_event) @@ -108,8 +84,8 @@ def __init__(self, figure=None): self.connect('key_press_event', self.key_press_event) self.connect('key_release_event', self.key_release_event) self.connect('motion_notify_event', self.motion_notify_event) - self.connect('leave_notify_event', self.leave_notify_event) self.connect('enter_notify_event', self.enter_notify_event) + self.connect('leave_notify_event', self.leave_notify_event) self.connect('size_allocate', self.size_allocate) self.set_events(self.__class__.event_mask) @@ -123,7 +99,7 @@ def __init__(self, figure=None): style_ctx.add_class("matplotlib-canvas") def destroy(self): - self.close_event() + CloseEvent("close_event", self)._process() def set_cursor(self, cursor): # docstring inherited @@ -133,9 +109,10 @@ def set_cursor(self, cursor): context = GLib.MainContext.default() context.iteration(True) - def _mouse_event_coords(self, event): + def _mpl_coords(self, event=None): """ - Calculate mouse coordinates in physical pixels. + Convert the position of a GTK event, or of the current cursor position + if *event* is None, to Matplotlib coordinates. GTK use logical pixels, but the figure is scaled to physical pixels for rendering. Transform to physical pixels so that all of the down-stream @@ -143,75 +120,98 @@ def _mouse_event_coords(self, event): Also, the origin is different and needs to be corrected. """ - x = event.x * self.device_pixel_ratio + if event is None: + window = self.get_window() + t, x, y, state = window.get_device_position( + window.get_display().get_device_manager().get_client_pointer()) + else: + x, y = event.x, event.y + x = x * self.device_pixel_ratio # flip y so y=0 is bottom of canvas - y = self.figure.bbox.height - event.y * self.device_pixel_ratio + y = self.figure.bbox.height - y * self.device_pixel_ratio return x, y def scroll_event(self, widget, event): - x, y = self._mouse_event_coords(event) step = 1 if event.direction == Gdk.ScrollDirection.UP else -1 - FigureCanvasBase.scroll_event(self, x, y, step, guiEvent=event) + MouseEvent("scroll_event", self, + *self._mpl_coords(event), step=step, + modifiers=self._mpl_modifiers(event.state), + guiEvent=event)._process() return False # finish event propagation? def button_press_event(self, widget, event): - x, y = self._mouse_event_coords(event) - FigureCanvasBase.button_press_event( - self, x, y, event.button, guiEvent=event) + MouseEvent("button_press_event", self, + *self._mpl_coords(event), event.button, + modifiers=self._mpl_modifiers(event.state), + guiEvent=event)._process() return False # finish event propagation? def button_release_event(self, widget, event): - x, y = self._mouse_event_coords(event) - FigureCanvasBase.button_release_event( - self, x, y, event.button, guiEvent=event) + MouseEvent("button_release_event", self, + *self._mpl_coords(event), event.button, + modifiers=self._mpl_modifiers(event.state), + guiEvent=event)._process() return False # finish event propagation? def key_press_event(self, widget, event): - key = self._get_key(event) - FigureCanvasBase.key_press_event(self, key, guiEvent=event) + KeyEvent("key_press_event", self, + self._get_key(event), *self._mpl_coords(), + guiEvent=event)._process() return True # stop event propagation def key_release_event(self, widget, event): - key = self._get_key(event) - FigureCanvasBase.key_release_event(self, key, guiEvent=event) + KeyEvent("key_release_event", self, + self._get_key(event), *self._mpl_coords(), + guiEvent=event)._process() return True # stop event propagation def motion_notify_event(self, widget, event): - x, y = self._mouse_event_coords(event) - FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) + MouseEvent("motion_notify_event", self, *self._mpl_coords(event), + modifiers=self._mpl_modifiers(event.state), + guiEvent=event)._process() return False # finish event propagation? - def leave_notify_event(self, widget, event): - FigureCanvasBase.leave_notify_event(self, event) - def enter_notify_event(self, widget, event): - x, y = self._mouse_event_coords(event) - FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) + gtk_mods = Gdk.Keymap.get_for_display( + self.get_display()).get_modifier_state() + LocationEvent("figure_enter_event", self, *self._mpl_coords(event), + modifiers=self._mpl_modifiers(gtk_mods), + guiEvent=event)._process() + + def leave_notify_event(self, widget, event): + gtk_mods = Gdk.Keymap.get_for_display( + self.get_display()).get_modifier_state() + LocationEvent("figure_leave_event", self, *self._mpl_coords(event), + modifiers=self._mpl_modifiers(gtk_mods), + guiEvent=event)._process() def size_allocate(self, widget, allocation): dpival = self.figure.dpi winch = allocation.width * self.device_pixel_ratio / dpival hinch = allocation.height * self.device_pixel_ratio / dpival self.figure.set_size_inches(winch, hinch, forward=False) - FigureCanvasBase.resize_event(self) + ResizeEvent("resize_event", self)._process() self.draw_idle() + @staticmethod + def _mpl_modifiers(event_state, *, exclude=None): + modifiers = [ + ("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"), + ("alt", Gdk.ModifierType.MOD1_MASK, "alt"), + ("shift", Gdk.ModifierType.SHIFT_MASK, "shift"), + ("super", Gdk.ModifierType.MOD4_MASK, "super"), + ] + return [name for name, mask, key in modifiers + if exclude != key and event_state & mask] + def _get_key(self, event): unikey = chr(Gdk.keyval_to_unicode(event.keyval)) key = cbook._unikey_or_keysym_to_mplkey( - unikey, - Gdk.keyval_name(event.keyval)) - modifiers = [ - (Gdk.ModifierType.CONTROL_MASK, 'ctrl'), - (Gdk.ModifierType.MOD1_MASK, 'alt'), - (Gdk.ModifierType.SHIFT_MASK, 'shift'), - (Gdk.ModifierType.MOD4_MASK, 'super'), - ] - for key_mask, prefix in modifiers: - if event.state & key_mask: - if not (prefix == 'shift' and unikey.isprintable()): - key = f'{prefix}+{key}' - return key + unikey, Gdk.keyval_name(event.keyval)) + mods = self._mpl_modifiers(event.state, exclude=key) + if "shift" in mods and unikey.isprintable(): + mods.remove("shift") + return "+".join([*mods, key]) def _update_device_pixel_ratio(self, *args, **kwargs): # We need to be careful in cases with mixed resolution displays if @@ -298,140 +298,10 @@ def flush_events(self): context.iteration(True) -class FigureManagerGTK3(FigureManagerBase): - """ - Attributes - ---------- - canvas : `FigureCanvas` - The FigureCanvas instance - num : int or str - The Figure number - toolbar : Gtk.Toolbar - The toolbar - vbox : Gtk.VBox - The Gtk.VBox containing the canvas and toolbar - window : Gtk.Window - The Gtk.Window - - """ - def __init__(self, canvas, num): - app = _create_application() - self.window = Gtk.Window() - app.add_window(self.window) - super().__init__(canvas, num) - - self.window.set_wmclass("matplotlib", "Matplotlib") - self.window.set_icon_from_file(window_icon) - - self.vbox = Gtk.Box() - self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL) - self.window.add(self.vbox) - self.vbox.show() - - self.canvas.show() - - self.vbox.pack_start(self.canvas, True, True, 0) - # calculate size for window - w, h = self.canvas.get_width_height() - - self.toolbar = self._get_toolbar() - - if self.toolmanager: - backend_tools.add_tools_to_manager(self.toolmanager) - if self.toolbar: - backend_tools.add_tools_to_container(self.toolbar) - - if self.toolbar is not None: - self.toolbar.show() - self.vbox.pack_end(self.toolbar, False, False, 0) - min_size, nat_size = self.toolbar.get_preferred_size() - h += nat_size.height - - self.window.set_default_size(w, h) - - self._destroying = False - self.window.connect("destroy", lambda *args: Gcf.destroy(self)) - self.window.connect("delete_event", lambda *args: Gcf.destroy(self)) - if mpl.is_interactive(): - self.window.show() - self.canvas.draw_idle() - - self.canvas.grab_focus() - - def destroy(self, *args): - if self._destroying: - # Otherwise, this can be called twice when the user presses 'q', - # which calls Gcf.destroy(self), then this destroy(), then triggers - # Gcf.destroy(self) once again via - # `connect("destroy", lambda *args: Gcf.destroy(self))`. - return - self._destroying = True - self.vbox.destroy() - self.window.destroy() - self.canvas.destroy() - if self.toolbar: - self.toolbar.destroy() - - def show(self): - # show the figure window - self.window.show() - self.canvas.draw() - if mpl.rcParams['figure.raise_window']: - if self.window.get_window(): - self.window.present() - else: - # If this is called by a callback early during init, - # self.window (a GtkWindow) may not have an associated - # low-level GdkWindow (self.window.get_window()) yet, and - # present() would crash. - _api.warn_external("Cannot raise window yet to be setup") - - def full_screen_toggle(self): - self._full_screen_flag = not self._full_screen_flag - if self._full_screen_flag: - self.window.fullscreen() - else: - self.window.unfullscreen() - _full_screen_flag = False - - def _get_toolbar(self): - # must be inited after the window, drawingArea and figure - # attrs are set - if mpl.rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2GTK3(self.canvas, self.window) - elif mpl.rcParams['toolbar'] == 'toolmanager': - toolbar = ToolbarGTK3(self.toolmanager) - else: - toolbar = None - return toolbar - - def get_window_title(self): - return self.window.get_title() - - def set_window_title(self, title): - self.window.set_title(title) - - def resize(self, width, height): - """Set the canvas size in pixels.""" - width = int(width / self.canvas.device_pixel_ratio) - height = int(height / self.canvas.device_pixel_ratio) - if self.toolbar: - toolbar_size = self.toolbar.size_request() - height += toolbar_size.height - canvas_size = self.canvas.get_allocation() - if canvas_size.width == canvas_size.height == 1: - # A canvas size of (1, 1) cannot exist in most cases, because - # window decorations would prevent such a small window. This call - # must be before the window has been mapped and widgets have been - # sized, so just change the window's starting size. - self.window.set_default_size(width, height) - else: - self.window.resize(width, height) - - class NavigationToolbar2GTK3(_NavigationToolbar2GTK, Gtk.Toolbar): - def __init__(self, canvas, window): - self.win = window + @_api.delete_parameter("3.6", "window") + def __init__(self, canvas, window=None): + self._win = window GObject.GObject.__init__(self) self.set_style(Gtk.ToolbarStyle.ICONS) @@ -472,11 +342,14 @@ def __init__(self, canvas, window): toolitem = Gtk.ToolItem() self.insert(toolitem, -1) self.message = Gtk.Label() + self.message.set_justify(Gtk.Justification.RIGHT) toolitem.add(self.message) self.show_all() - NavigationToolbar2.__init__(self, canvas) + _NavigationToolbar2GTK.__init__(self, canvas) + + win = _api.deprecated("3.6")(property(lambda self: self._win)) def save_figure(self, *args): dialog = Gtk.FileChooserDialog( @@ -520,7 +393,11 @@ def on_notify_filter(*args): try: self.canvas.figure.savefig(fname, format=fmt) except Exception as e: - error_msg_gtk(str(e), parent=self) + dialog = Gtk.MessageDialog( + parent=self.canvas.get_toplevel(), message_format=str(e), + type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK) + dialog.run() + dialog.destroy() class ToolbarGTK3(ToolContainerBase, Gtk.Box): @@ -531,6 +408,7 @@ def __init__(self, toolmanager): Gtk.Box.__init__(self) self.set_property('orientation', Gtk.Orientation.HORIZONTAL) self._message = Gtk.Label() + self._message.set_justify(Gtk.Justification.RIGHT) self.pack_end(self._message, False, False, 0) self.show_all() self._groups = {} @@ -603,22 +481,14 @@ def set_message(self, s): self._message.set_label(s) +@backend_tools._register_tool_class(FigureCanvasGTK3) class SaveFigureGTK3(backend_tools.SaveFigureBase): def trigger(self, *args, **kwargs): - - class PseudoToolbar: - canvas = self.figure.canvas - - return NavigationToolbar2GTK3.save_figure(PseudoToolbar()) - - -@_api.deprecated("3.5", alternative="ToolSetCursor") -class SetCursorGTK3(backend_tools.SetCursorBase): - def set_cursor(self, cursor): - NavigationToolbar2GTK3.set_cursor( - self._make_classic_style_pseudo_toolbar(), cursor) + NavigationToolbar2GTK3.save_figure( + self._make_classic_style_pseudo_toolbar()) +@backend_tools._register_tool_class(FigureCanvasGTK3) class HelpGTK3(backend_tools.ToolHelpBase): def _normalize_shortcut(self, key): """ @@ -704,6 +574,7 @@ def trigger(self, *args): self._show_shortcuts_dialog() +@backend_tools._register_tool_class(FigureCanvasGTK3) class ToolCopyToClipboardGTK3(backend_tools.ToolCopyToClipboardBase): def trigger(self, *args, **kwargs): clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) @@ -713,14 +584,7 @@ def trigger(self, *args, **kwargs): clipboard.set_image(pb) -# Define the file to use as the GTk icon -if sys.platform == 'win32': - icon_filename = 'matplotlib.png' -else: - icon_filename = 'matplotlib.svg' -window_icon = str(cbook._get_data_path('images', icon_filename)) - - +@_api.deprecated("3.6") def error_msg_gtk(msg, parent=None): if parent is not None: # find the toplevel Gtk.Window parent = parent.get_toplevel() @@ -735,16 +599,19 @@ def error_msg_gtk(msg, parent=None): dialog.destroy() -backend_tools.ToolSaveFigure = SaveFigureGTK3 -backend_tools.ToolConfigureSubplots = ConfigureSubplotsGTK3 -backend_tools.ToolRubberband = RubberbandGTK3 -backend_tools.ToolHelp = HelpGTK3 -backend_tools.ToolCopyToClipboard = ToolCopyToClipboardGTK3 - Toolbar = ToolbarGTK3 +backend_tools._register_tool_class( + FigureCanvasGTK3, _backend_gtk.ConfigureSubplotsGTK) +backend_tools._register_tool_class( + FigureCanvasGTK3, _backend_gtk.RubberbandGTK) + + +class FigureManagerGTK3(_FigureManagerGTK): + _toolbar2_class = NavigationToolbar2GTK3 + _toolmanager_toolbar_class = ToolbarGTK3 -@_Backend.export +@_BackendGTK.export class _BackendGTK3(_BackendGTK): FigureCanvas = FigureCanvasGTK3 FigureManager = FigureManagerGTK3 diff --git a/lib/matplotlib/backends/backend_gtk3agg.py b/lib/matplotlib/backends/backend_gtk3agg.py index ef1eed5cccdd..10aa15ac2a6b 100644 --- a/lib/matplotlib/backends/backend_gtk3agg.py +++ b/lib/matplotlib/backends/backend_gtk3agg.py @@ -1,20 +1,16 @@ import numpy as np -from .. import cbook -try: - from . import backend_cairo -except ImportError as e: - raise ImportError('backend Gtk3Agg requires cairo') from e +from .. import _api, cbook, transforms from . import backend_agg, backend_gtk3 -from .backend_cairo import cairo from .backend_gtk3 import Gtk, _BackendGTK3 -from matplotlib import transforms +import cairo # Presence of cairo is already checked by _backend_gtk. -class FigureCanvasGTK3Agg(backend_gtk3.FigureCanvasGTK3, - backend_agg.FigureCanvasAgg): + +class FigureCanvasGTK3Agg(backend_agg.FigureCanvasAgg, + backend_gtk3.FigureCanvasGTK3): def __init__(self, figure): - backend_gtk3.FigureCanvasGTK3.__init__(self, figure) + super().__init__(figure=figure) self._bbox_queue = [] def on_draw_event(self, widget, ctx): @@ -32,8 +28,6 @@ def on_draw_event(self, widget, ctx): else: bbox_queue = self._bbox_queue - ctx = backend_cairo._to_context(ctx) - for bbox in bbox_queue: x = int(bbox.x0) y = h - int(bbox.y1) @@ -69,13 +63,8 @@ def blit(self, bbox=None): self._bbox_queue.append(bbox) self.queue_draw_area(x, y, width, height) - def draw(self): - # Call these explicitly because GTK's draw is a GObject method which - # isn't cooperative with Python class methods. - backend_agg.FigureCanvasAgg.draw(self) - backend_gtk3.FigureCanvasGTK3.draw(self) - +@_api.deprecated("3.6", alternative="backend_gtk3.FigureManagerGTK3") class FigureManagerGTK3Agg(backend_gtk3.FigureManagerGTK3): pass @@ -83,4 +72,3 @@ class FigureManagerGTK3Agg(backend_gtk3.FigureManagerGTK3): @_BackendGTK3.export class _BackendGTK3Cairo(_BackendGTK3): FigureCanvas = FigureCanvasGTK3Agg - FigureManager = FigureManagerGTK3Agg diff --git a/lib/matplotlib/backends/backend_gtk3cairo.py b/lib/matplotlib/backends/backend_gtk3cairo.py index 2e8558a66b2c..49215510c178 100644 --- a/lib/matplotlib/backends/backend_gtk3cairo.py +++ b/lib/matplotlib/backends/backend_gtk3cairo.py @@ -1,21 +1,11 @@ from contextlib import nullcontext -from . import backend_cairo, backend_gtk3 -from .backend_gtk3 import Gtk, _BackendGTK3 +from .backend_cairo import ( # noqa + FigureCanvasCairo, _RendererGTKCairo as RendererGTK3Cairo) +from .backend_gtk3 import Gtk, FigureCanvasGTK3, _BackendGTK3 -class RendererGTK3Cairo(backend_cairo.RendererCairo): - def set_context(self, ctx): - self.gc.ctx = backend_cairo._to_context(ctx) - - -class FigureCanvasGTK3Cairo(backend_gtk3.FigureCanvasGTK3, - backend_cairo.FigureCanvasCairo): - - def __init__(self, figure): - super().__init__(figure) - self._renderer = RendererGTK3Cairo(self.figure.dpi) - +class FigureCanvasGTK3Cairo(FigureCanvasCairo, FigureCanvasGTK3): def on_draw_event(self, widget, ctx): with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar else nullcontext()): @@ -28,8 +18,6 @@ def on_draw_event(self, widget, ctx): self.get_style_context(), ctx, allocation.x, allocation.y, allocation.width, allocation.height) - self._renderer.set_width_height( - allocation.width * scale, allocation.height * scale) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index dcc97a1dec5c..8628e14de096 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -1,18 +1,12 @@ import functools import io import os -from pathlib import Path -import sys import matplotlib as mpl from matplotlib import _api, backend_tools, cbook -from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( - _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase, ToolContainerBase) -from matplotlib.backend_tools import Cursors -from matplotlib.figure import Figure -from matplotlib.widgets import SubplotTool + ToolContainerBase, KeyEvent, LocationEvent, MouseEvent, ResizeEvent, + CloseEvent) try: import gi @@ -28,42 +22,27 @@ # auto-backend selection logic correctly skips. raise ImportError from e -from gi.repository import Gio, GLib, GObject, Gtk, Gdk, GdkPixbuf -from ._backend_gtk import ( - _create_application, _shutdown_application, - backend_version, _BackendGTK, _NavigationToolbar2GTK, +from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf +from . import _backend_gtk +from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 + _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, TimerGTK as TimerGTK4, - ConfigureSubplotsGTK as ConfigureSubplotsGTK4, - RubberbandGTK as RubberbandGTK4, ) -def _mpl_to_gtk_cursor(mpl_cursor): - return _api.check_getitem({ - Cursors.MOVE: "move", - Cursors.HAND: "pointer", - Cursors.POINTER: "default", - Cursors.SELECT_REGION: "crosshair", - Cursors.WAIT: "wait", - Cursors.RESIZE_HORIZONTAL: "ew-resize", - Cursors.RESIZE_VERTICAL: "ns-resize", - }, cursor=mpl_cursor) - - -class FigureCanvasGTK4(Gtk.DrawingArea, FigureCanvasBase): +class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk4" supports_blit = False - _timer_cls = TimerGTK4 + manager_class = _api.classproperty(lambda cls: FigureManagerGTK4) _context_is_scaled = False def __init__(self, figure=None): - FigureCanvasBase.__init__(self, figure) - GObject.GObject.__init__(self) + super().__init__(figure=figure) + self.set_hexpand(True) self.set_vexpand(True) self._idle_draw_id = 0 - self._lastCursor = None self._rubberband_rect = None self.set_draw_func(self._draw_func) @@ -100,21 +79,17 @@ def __init__(self, figure=None): style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) style_ctx.add_class("matplotlib-canvas") - def pick(self, mouseevent): - # GtkWidget defines pick in GTK4, so we need to override here to work - # with the base implementation we want. - FigureCanvasBase.pick(self, mouseevent) - def destroy(self): - self.close_event() + CloseEvent("close_event", self)._process() def set_cursor(self, cursor): # docstring inherited - self.set_cursor_from_name(_mpl_to_gtk_cursor(cursor)) + self.set_cursor_from_name(_backend_gtk.mpl_to_gtk_cursor_name(cursor)) - def _mouse_event_coords(self, x, y): + def _mpl_coords(self, xy=None): """ - Calculate mouse coordinates in physical pixels. + Convert the *xy* position of a GTK event, or of the current cursor + position if *xy* is None, to Matplotlib coordinates. GTK use logical pixels, but the figure is scaled to physical pixels for rendering. Transform to physical pixels so that all of the down-stream @@ -122,46 +97,70 @@ def _mouse_event_coords(self, x, y): Also, the origin is different and needs to be corrected. """ + if xy is None: + surface = self.get_native().get_surface() + is_over, x, y, mask = surface.get_device_position( + self.get_display().get_default_seat().get_pointer()) + else: + x, y = xy x = x * self.device_pixel_ratio # flip y so y=0 is bottom of canvas y = self.figure.bbox.height - y * self.device_pixel_ratio return x, y def scroll_event(self, controller, dx, dy): - FigureCanvasBase.scroll_event(self, 0, 0, dy) + MouseEvent( + "scroll_event", self, *self._mpl_coords(), step=dy, + modifiers=self._mpl_modifiers(controller), + )._process() return True def button_press_event(self, controller, n_press, x, y): - x, y = self._mouse_event_coords(x, y) - FigureCanvasBase.button_press_event(self, x, y, - controller.get_current_button()) + MouseEvent( + "button_press_event", self, *self._mpl_coords((x, y)), + controller.get_current_button(), + modifiers=self._mpl_modifiers(controller), + )._process() self.grab_focus() def button_release_event(self, controller, n_press, x, y): - x, y = self._mouse_event_coords(x, y) - FigureCanvasBase.button_release_event(self, x, y, - controller.get_current_button()) + MouseEvent( + "button_release_event", self, *self._mpl_coords((x, y)), + controller.get_current_button(), + modifiers=self._mpl_modifiers(controller), + )._process() def key_press_event(self, controller, keyval, keycode, state): - key = self._get_key(keyval, keycode, state) - FigureCanvasBase.key_press_event(self, key) + KeyEvent( + "key_press_event", self, self._get_key(keyval, keycode, state), + *self._mpl_coords(), + )._process() return True def key_release_event(self, controller, keyval, keycode, state): - key = self._get_key(keyval, keycode, state) - FigureCanvasBase.key_release_event(self, key) + KeyEvent( + "key_release_event", self, self._get_key(keyval, keycode, state), + *self._mpl_coords(), + )._process() return True def motion_notify_event(self, controller, x, y): - x, y = self._mouse_event_coords(x, y) - FigureCanvasBase.motion_notify_event(self, x, y) - - def leave_notify_event(self, controller): - FigureCanvasBase.leave_notify_event(self) + MouseEvent( + "motion_notify_event", self, *self._mpl_coords((x, y)), + modifiers=self._mpl_modifiers(controller), + )._process() def enter_notify_event(self, controller, x, y): - x, y = self._mouse_event_coords(x, y) - FigureCanvasBase.enter_notify_event(self, xy=(x, y)) + LocationEvent( + "figure_enter_event", self, *self._mpl_coords((x, y)), + modifiers=self._mpl_modifiers(), + )._process() + + def leave_notify_event(self, controller): + LocationEvent( + "figure_leave_event", self, *self._mpl_coords(), + modifiers=self._mpl_modifiers(), + )._process() def resize_event(self, area, width, height): self._update_device_pixel_ratio() @@ -169,25 +168,40 @@ def resize_event(self, area, width, height): winch = width * self.device_pixel_ratio / dpi hinch = height * self.device_pixel_ratio / dpi self.figure.set_size_inches(winch, hinch, forward=False) - FigureCanvasBase.resize_event(self) + ResizeEvent("resize_event", self)._process() self.draw_idle() + def _mpl_modifiers(self, controller=None): + if controller is None: + surface = self.get_native().get_surface() + is_over, x, y, event_state = surface.get_device_position( + self.get_display().get_default_seat().get_pointer()) + else: + event_state = controller.get_current_event_state() + mod_table = [ + ("ctrl", Gdk.ModifierType.CONTROL_MASK), + ("alt", Gdk.ModifierType.ALT_MASK), + ("shift", Gdk.ModifierType.SHIFT_MASK), + ("super", Gdk.ModifierType.SUPER_MASK), + ] + return [name for name, mask in mod_table if event_state & mask] + def _get_key(self, keyval, keycode, state): unikey = chr(Gdk.keyval_to_unicode(keyval)) key = cbook._unikey_or_keysym_to_mplkey( unikey, Gdk.keyval_name(keyval)) modifiers = [ - (Gdk.ModifierType.CONTROL_MASK, 'ctrl'), - (Gdk.ModifierType.ALT_MASK, 'alt'), - (Gdk.ModifierType.SHIFT_MASK, 'shift'), - (Gdk.ModifierType.SUPER_MASK, 'super'), + ("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"), + ("alt", Gdk.ModifierType.ALT_MASK, "alt"), + ("shift", Gdk.ModifierType.SHIFT_MASK, "shift"), + ("super", Gdk.ModifierType.SUPER_MASK, "super"), ] - for key_mask, prefix in modifiers: - if state & key_mask: - if not (prefix == 'shift' and unikey.isprintable()): - key = f'{prefix}+{key}' - return key + mods = [ + mod for mod, mask, mod_key in modifiers + if (mod_key != key and state & mask + and not (mod == "shift" and unikey.isprintable()))] + return "+".join([*mods, key]) def _update_device_pixel_ratio(self, *args, **kwargs): # We need to be careful in cases with mixed resolution displays if @@ -269,123 +283,10 @@ def flush_events(self): context.iteration(True) -class FigureManagerGTK4(FigureManagerBase): - """ - Attributes - ---------- - canvas : `FigureCanvas` - The FigureCanvas instance - num : int or str - The Figure number - toolbar : Gtk.Box - The toolbar - vbox : Gtk.VBox - The Gtk.VBox containing the canvas and toolbar - window : Gtk.Window - The Gtk.Window - - """ - def __init__(self, canvas, num): - app = _create_application() - self.window = Gtk.Window() - app.add_window(self.window) - super().__init__(canvas, num) - - self.vbox = Gtk.Box() - self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL) - self.window.set_child(self.vbox) - - self.vbox.prepend(self.canvas) - # calculate size for window - w, h = self.canvas.get_width_height() - - self.toolbar = self._get_toolbar() - - if self.toolmanager: - backend_tools.add_tools_to_manager(self.toolmanager) - if self.toolbar: - backend_tools.add_tools_to_container(self.toolbar) - - if self.toolbar is not None: - sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) - sw.set_child(self.toolbar) - self.vbox.append(sw) - min_size, nat_size = self.toolbar.get_preferred_size() - h += nat_size.height - - self.window.set_default_size(w, h) - - self._destroying = False - self.window.connect("destroy", lambda *args: Gcf.destroy(self)) - self.window.connect("close-request", lambda *args: Gcf.destroy(self)) - if mpl.is_interactive(): - self.window.show() - self.canvas.draw_idle() - - self.canvas.grab_focus() - - def destroy(self, *args): - if self._destroying: - # Otherwise, this can be called twice when the user presses 'q', - # which calls Gcf.destroy(self), then this destroy(), then triggers - # Gcf.destroy(self) once again via - # `connect("destroy", lambda *args: Gcf.destroy(self))`. - return - self._destroying = True - self.window.destroy() - self.canvas.destroy() - - def show(self): - # show the figure window - self.window.show() - self.canvas.draw() - if mpl.rcParams['figure.raise_window']: - if self.window.get_surface(): - self.window.present() - else: - # If this is called by a callback early during init, - # self.window (a GtkWindow) may not have an associated - # low-level GdkSurface (self.window.get_surface()) yet, and - # present() would crash. - _api.warn_external("Cannot raise window yet to be setup") - - def full_screen_toggle(self): - if not self.window.is_fullscreen(): - self.window.fullscreen() - else: - self.window.unfullscreen() - - def _get_toolbar(self): - # must be inited after the window, drawingArea and figure - # attrs are set - if mpl.rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2GTK4(self.canvas, self.window) - elif mpl.rcParams['toolbar'] == 'toolmanager': - toolbar = ToolbarGTK4(self.toolmanager) - else: - toolbar = None - return toolbar - - def get_window_title(self): - return self.window.get_title() - - def set_window_title(self, title): - self.window.set_title(title) - - def resize(self, width, height): - """Set the canvas size in pixels.""" - width = int(width / self.canvas.device_pixel_ratio) - height = int(height / self.canvas.device_pixel_ratio) - if self.toolbar: - min_size, nat_size = self.toolbar.get_preferred_size() - height += nat_size.height - canvas_size = self.canvas.get_allocation() - self.window.set_default_size(width, height) - - class NavigationToolbar2GTK4(_NavigationToolbar2GTK, Gtk.Box): - def __init__(self, canvas, window): - self.win = window + @_api.delete_parameter("3.6", "window") + def __init__(self, canvas, window=None): + self._win = window Gtk.Box.__init__(self) self.add_css_class('toolbar') @@ -422,9 +323,12 @@ def __init__(self, canvas, window): self.append(label) self.message = Gtk.Label() + self.message.set_justify(Gtk.Justification.RIGHT) self.append(self.message) - NavigationToolbar2.__init__(self, canvas) + _NavigationToolbar2GTK.__init__(self, canvas) + + win = _api.deprecated("3.6")(property(lambda self: self._win)) def save_figure(self, *args): dialog = Gtk.FileChooserNative( @@ -514,6 +418,7 @@ def __init__(self, toolmanager): self.append(label) self._message = Gtk.Label() + self._message.set_justify(Gtk.Justification.RIGHT) self.append(self._message) def add_toolitem(self, name, group, position, image_file, description, @@ -589,15 +494,14 @@ def set_message(self, s): self._message.set_label(s) +@backend_tools._register_tool_class(FigureCanvasGTK4) class SaveFigureGTK4(backend_tools.SaveFigureBase): def trigger(self, *args, **kwargs): - - class PseudoToolbar: - canvas = self.figure.canvas - - return NavigationToolbar2GTK4.save_figure(PseudoToolbar()) + NavigationToolbar2GTK4.save_figure( + self._make_classic_style_pseudo_toolbar()) +@backend_tools._register_tool_class(FigureCanvasGTK4) class HelpGTK4(backend_tools.ToolHelpBase): def _normalize_shortcut(self, key): """ @@ -671,6 +575,7 @@ def trigger(self, *args): window.show() +@backend_tools._register_tool_class(FigureCanvasGTK4) class ToolCopyToClipboardGTK4(backend_tools.ToolCopyToClipboardBase): def trigger(self, *args, **kwargs): with io.BytesIO() as f: @@ -683,16 +588,19 @@ def trigger(self, *args, **kwargs): clipboard.set(pb) -backend_tools.ToolSaveFigure = SaveFigureGTK4 -backend_tools.ToolConfigureSubplots = ConfigureSubplotsGTK4 -backend_tools.ToolRubberband = RubberbandGTK4 -backend_tools.ToolHelp = HelpGTK4 -backend_tools.ToolCopyToClipboard = ToolCopyToClipboardGTK4 - +backend_tools._register_tool_class( + FigureCanvasGTK4, _backend_gtk.ConfigureSubplotsGTK) +backend_tools._register_tool_class( + FigureCanvasGTK4, _backend_gtk.RubberbandGTK) Toolbar = ToolbarGTK4 -@_Backend.export +class FigureManagerGTK4(_FigureManagerGTK): + _toolbar2_class = NavigationToolbar2GTK4 + _toolmanager_toolbar_class = ToolbarGTK4 + + +@_BackendGTK.export class _BackendGTK4(_BackendGTK): FigureCanvas = FigureCanvasGTK4 FigureManager = FigureManagerGTK4 diff --git a/lib/matplotlib/backends/backend_gtk4agg.py b/lib/matplotlib/backends/backend_gtk4agg.py index 386f1ad0d2f8..d7ee04616e72 100644 --- a/lib/matplotlib/backends/backend_gtk4agg.py +++ b/lib/matplotlib/backends/backend_gtk4agg.py @@ -1,19 +1,14 @@ import numpy as np -from .. import cbook -try: - from . import backend_cairo -except ImportError as e: - raise ImportError('backend Gtk4Agg requires cairo') from e +from .. import _api, cbook from . import backend_agg, backend_gtk4 -from .backend_cairo import cairo from .backend_gtk4 import Gtk, _BackendGTK4 +import cairo # Presence of cairo is already checked by _backend_gtk. -class FigureCanvasGTK4Agg(backend_gtk4.FigureCanvasGTK4, - backend_agg.FigureCanvasAgg): - def __init__(self, figure): - backend_gtk4.FigureCanvasGTK4.__init__(self, figure) + +class FigureCanvasGTK4Agg(backend_agg.FigureCanvasAgg, + backend_gtk4.FigureCanvasGTK4): def on_draw_event(self, widget, ctx): scale = self.device_pixel_ratio @@ -24,8 +19,6 @@ def on_draw_event(self, widget, ctx): allocation.x, allocation.y, allocation.width, allocation.height) - ctx = backend_cairo._to_context(ctx) - buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( np.asarray(self.get_renderer().buffer_rgba())) height, width, _ = buf.shape @@ -37,13 +30,8 @@ def on_draw_event(self, widget, ctx): return False - def draw(self): - # Call these explicitly because GTK's draw is a GObject method which - # isn't cooperative with Python class methods. - backend_agg.FigureCanvasAgg.draw(self) - backend_gtk4.FigureCanvasGTK4.draw(self) - +@_api.deprecated("3.6", alternative="backend_gtk4.FigureManagerGTK4") class FigureManagerGTK4Agg(backend_gtk4.FigureManagerGTK4): pass @@ -51,4 +39,3 @@ class FigureManagerGTK4Agg(backend_gtk4.FigureManagerGTK4): @_BackendGTK4.export class _BackendGTK4Agg(_BackendGTK4): FigureCanvas = FigureCanvasGTK4Agg - FigureManager = FigureManagerGTK4Agg diff --git a/lib/matplotlib/backends/backend_gtk4cairo.py b/lib/matplotlib/backends/backend_gtk4cairo.py index 05ddef53bd92..83cbd081c26d 100644 --- a/lib/matplotlib/backends/backend_gtk4cairo.py +++ b/lib/matplotlib/backends/backend_gtk4cairo.py @@ -1,22 +1,13 @@ from contextlib import nullcontext -from . import backend_cairo, backend_gtk4 -from .backend_gtk4 import Gtk, _BackendGTK4 +from .backend_cairo import ( # noqa + FigureCanvasCairo, _RendererGTKCairo as RendererGTK4Cairo) +from .backend_gtk4 import Gtk, FigureCanvasGTK4, _BackendGTK4 -class RendererGTK4Cairo(backend_cairo.RendererCairo): - def set_context(self, ctx): - self.gc.ctx = backend_cairo._to_context(ctx) - - -class FigureCanvasGTK4Cairo(backend_gtk4.FigureCanvasGTK4, - backend_cairo.FigureCanvasCairo): +class FigureCanvasGTK4Cairo(FigureCanvasCairo, FigureCanvasGTK4): _context_is_scaled = True - def __init__(self, figure): - super().__init__(figure) - self._renderer = RendererGTK4Cairo(self.figure.dpi) - def on_draw_event(self, widget, ctx): with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar else nullcontext()): @@ -29,8 +20,6 @@ def on_draw_event(self, widget, ctx): self.get_style_context(), ctx, allocation.x, allocation.y, allocation.width, allocation.height) - self._renderer.set_width_height( - allocation.width * scale, allocation.height * scale) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 4450d44b02c4..2867c13f430b 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -1,11 +1,13 @@ +import os + import matplotlib as mpl -from matplotlib import cbook +from matplotlib import _api, cbook from matplotlib._pylab_helpers import Gcf -from matplotlib.backends import _macosx -from matplotlib.backends.backend_agg import FigureCanvasAgg +from . import _macosx +from .backend_agg import FigureCanvasAgg from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase) + ResizeEvent, TimerBase) from matplotlib.figure import Figure from matplotlib.widgets import SubplotTool @@ -15,41 +17,80 @@ class TimerMac(_macosx.Timer, TimerBase): # completely implemented at the C-level (in _macosx.Timer) -class FigureCanvasMac(_macosx.FigureCanvas, FigureCanvasAgg): +class FigureCanvasMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase): # docstring inherited - # Events such as button presses, mouse movements, and key presses - # are handled in the C code and the base class methods - # button_press_event, button_release_event, motion_notify_event, - # key_press_event, and key_release_event are called from there. + # Ideally this class would be `class FCMacAgg(FCAgg, FCMac)` + # (FC=FigureCanvas) where FCMac would be an ObjC-implemented mac-specific + # class also inheriting from FCBase (this is the approach with other GUI + # toolkits). However, writing an extension type inheriting from a Python + # base class is slightly tricky (the extension type must be a heap type), + # and we can just as well lift the FCBase base up one level, keeping it *at + # the end* to have the right method resolution order. + + # Events such as button presses, mouse movements, and key presses are + # handled in C and events (MouseEvent, etc.) are triggered from there. required_interactive_framework = "macosx" _timer_cls = TimerMac + manager_class = _api.classproperty(lambda cls: FigureManagerMac) def __init__(self, figure): - FigureCanvasBase.__init__(self, figure) - width, height = self.get_width_height() - _macosx.FigureCanvas.__init__(self, width, height) - - def set_cursor(self, cursor): - # docstring inherited - _macosx.set_cursor(cursor) - - def _draw(self): - renderer = self.get_renderer(cleared=self.figure.stale) - if self.figure.stale: - self.figure.draw(renderer) - return renderer + super().__init__(figure=figure) + self._draw_pending = False + self._is_drawing = False def draw(self): - # docstring inherited - self._draw() - self.flush_events() + """Render the figure and update the macosx canvas.""" + # The renderer draw is done here; delaying causes problems with code + # that uses the result of the draw() to update plot elements. + if self._is_drawing: + return + with cbook._setattr_cm(self, _is_drawing=True): + super().draw() + self.update() - # draw_idle is provided by _macosx.FigureCanvas + def draw_idle(self): + # docstring inherited + if not (getattr(self, '_draw_pending', False) or + getattr(self, '_is_drawing', False)): + self._draw_pending = True + # Add a singleshot timer to the eventloop that will call back + # into the Python method _draw_idle to take care of the draw + self._single_shot_timer(self._draw_idle) + + def _single_shot_timer(self, callback): + """Add a single shot timer with the given callback""" + # We need to explicitly stop (called from delete) the timer after + # firing, otherwise segfaults will occur when trying to deallocate + # the singleshot timers. + def callback_func(callback, timer): + callback() + del timer + timer = self.new_timer(interval=0) + timer.add_callback(callback_func, callback, timer) + timer.start() + + def _draw_idle(self): + """ + Draw method for singleshot timer + + This draw method can be added to a singleshot timer, which can + accumulate draws while the eventloop is spinning. This method will + then only draw the first time and short-circuit the others. + """ + with self._idle_draw_cntx(): + if not self._draw_pending: + # Short-circuit because our draw request has already been + # taken care of + return + self._draw_pending = False + self.draw() def blit(self, bbox=None): - self.draw_idle() + # docstring inherited + super().blit(bbox) + self.update() def resize(self, width, height): # Size from macOS is logical pixels, dpi is physical. @@ -57,40 +98,17 @@ def resize(self, width, height): width /= scale height /= scale self.figure.set_size_inches(width, height, forward=False) - FigureCanvasBase.resize_event(self) + ResizeEvent("resize_event", self)._process() self.draw_idle() -class FigureManagerMac(_macosx.FigureManager, FigureManagerBase): - """ - Wrap everything up into a window for the pylab interface - """ - def __init__(self, canvas, num): - _macosx.FigureManager.__init__(self, canvas) - FigureManagerBase.__init__(self, canvas, num) - if mpl.rcParams['toolbar'] == 'toolbar2': - self.toolbar = NavigationToolbar2Mac(canvas) - else: - self.toolbar = None - if self.toolbar is not None: - self.toolbar.update() - - if mpl.is_interactive(): - self.show() - self.canvas.draw_idle() - - def close(self): - Gcf.destroy(self) - - class NavigationToolbar2Mac(_macosx.NavigationToolbar2, NavigationToolbar2): def __init__(self, canvas): - self.canvas = canvas # Needed by the _macosx __init__. data_path = cbook._get_data_path('images') _, tooltips, image_names, _ = zip(*NavigationToolbar2.toolitems) _macosx.NavigationToolbar2.__init__( - self, + self, canvas, tuple(str(data_path / image_name) + ".pdf" for image_name in image_names if image_name is not None), tuple(tooltip for tooltip in tooltips if tooltip is not None)) @@ -103,12 +121,18 @@ def remove_rubberband(self): self.canvas.remove_rubberband() def save_figure(self, *args): + directory = os.path.expanduser(mpl.rcParams['savefig.directory']) filename = _macosx.choose_save_file('Save the figure', + directory, self.canvas.get_default_filename()) if filename is None: # Cancel return + # Save dir for next time, unless empty str (which means use cwd). + if mpl.rcParams['savefig.directory']: + mpl.rcParams['savefig.directory'] = os.path.dirname(filename) self.canvas.figure.savefig(filename) + @_api.deprecated("3.6", alternative='configure_subplots()') def prepare_configure_subplots(self): toolfig = Figure(figsize=(6, 3)) canvas = FigureCanvasMac(toolfig) @@ -117,15 +141,44 @@ def prepare_configure_subplots(self): _tool = SubplotTool(self.canvas.figure, toolfig) return canvas - def set_message(self, message): - _macosx.NavigationToolbar2.set_message(self, message.encode('utf-8')) + +class FigureManagerMac(_macosx.FigureManager, FigureManagerBase): + _toolbar2_class = NavigationToolbar2Mac + + def __init__(self, canvas, num): + self._shown = False + _macosx.FigureManager.__init__(self, canvas) + icon_path = str(cbook._get_data_path('images/matplotlib.pdf')) + _macosx.FigureManager.set_icon(icon_path) + FigureManagerBase.__init__(self, canvas, num) + if self.toolbar is not None: + self.toolbar.update() + if mpl.is_interactive(): + self.show() + self.canvas.draw_idle() + + def _close_button_pressed(self): + Gcf.destroy(self) + self.canvas.flush_events() + + @_api.deprecated("3.6") + def close(self): + return self._close_button_pressed() + + @classmethod + def start_main_loop(cls): + _macosx.show() + + def show(self): + if not self._shown: + self._show() + self._shown = True + if mpl.rcParams["figure.raise_window"]: + self._raise() @_Backend.export class _BackendMac(_Backend): FigureCanvas = FigureCanvasMac FigureManager = FigureManagerMac - - @staticmethod - def mainloop(): - _macosx.show() + mainloop = FigureManagerMac.start_main_loop diff --git a/lib/matplotlib/backends/backend_mixed.py b/lib/matplotlib/backends/backend_mixed.py index 54ca8f81ba02..5fadb96a0f73 100644 --- a/lib/matplotlib/backends/backend_mixed.py +++ b/lib/matplotlib/backends/backend_mixed.py @@ -1,8 +1,8 @@ import numpy as np from matplotlib import cbook -from matplotlib.backends.backend_agg import RendererAgg -from matplotlib.tight_bbox import process_figure_for_rasterizing +from .backend_agg import RendererAgg +from matplotlib._tight_bbox import process_figure_for_rasterizing class MixedModeRenderer: @@ -53,7 +53,7 @@ def __init__(self, figure, width, height, dpi, vector_renderer, # the figure dpi before and after the rasterization. Although # this looks ugly, I couldn't find a better solution. -JJL self.figure = figure - self._figdpi = figure.get_dpi() + self._figdpi = figure.dpi self._bbox_inches_restore = bbox_inches_restore @@ -74,7 +74,7 @@ def start_rasterizing(self): `stop_rasterizing` is called) will be drawn with the raster backend. """ # change the dpi of the figure temporarily. - self.figure.set_dpi(self.dpi) + self.figure.dpi = self.dpi if self._bbox_inches_restore: # when tight bbox is used r = process_figure_for_rasterizing(self.figure, self._bbox_inches_restore) @@ -110,7 +110,7 @@ def stop_rasterizing(self): self._raster_renderer = None # restore the figure dpi. - self.figure.set_dpi(self._figdpi) + self.figure.dpi = self._figdpi if self._bbox_inches_restore: # when tight bbox is used r = process_figure_for_rasterizing(self.figure, diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index c9df2e8dbad7..f4ee4b1179f3 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -9,21 +9,16 @@ import pathlib import uuid +from ipykernel.comm import Comm from IPython.display import display, Javascript, HTML -try: - # Jupyter/IPython 4.x or later - from ipykernel.comm import Comm -except ImportError: - # Jupyter/IPython 3.x or earlier - from IPython.kernel.comm import Comm from matplotlib import is_interactive from matplotlib._pylab_helpers import Gcf -from matplotlib.backend_bases import _Backend, NavigationToolbar2 -from matplotlib.backends.backend_webagg_core import ( - FigureCanvasWebAggCore, FigureManagerWebAgg, NavigationToolbar2WebAgg, - TimerTornado, TimerAsyncio -) +from matplotlib.backend_bases import _Backend, CloseEvent, NavigationToolbar2 +from .backend_webagg_core import ( + FigureCanvasWebAggCore, FigureManagerWebAgg, NavigationToolbar2WebAgg) +from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611 + TimerTornado, TimerAsyncio) def connection_info(): @@ -44,18 +39,13 @@ def connection_info(): return '\n'.join(result) -# Note: Version 3.2 and 4.x icons -# https://fontawesome.com/v3.2/icons/ -# https://fontawesome.com/v4.7/icons/ -# the `fa fa-xxx` part targets font-awesome 4, (IPython 3.x) -# the icon-xxx targets font awesome 3.21 (IPython 2.x) -_FONT_AWESOME_CLASSES = { - 'home': 'fa fa-home icon-home', - 'back': 'fa fa-arrow-left icon-arrow-left', - 'forward': 'fa fa-arrow-right icon-arrow-right', - 'zoom_to_rect': 'fa fa-square-o icon-check-empty', - 'move': 'fa fa-arrows icon-move', - 'download': 'fa fa-floppy-o icon-save', +_FONT_AWESOME_CLASSES = { # font-awesome 4 names + 'home': 'fa fa-home', + 'back': 'fa fa-arrow-left', + 'forward': 'fa fa-arrow-right', + 'zoom_to_rect': 'fa fa-square-o', + 'move': 'fa fa-arrows', + 'download': 'fa fa-floppy-o', None: None } @@ -72,12 +62,27 @@ class NavigationIPy(NavigationToolbar2WebAgg): class FigureManagerNbAgg(FigureManagerWebAgg): - ToolbarCls = NavigationIPy + _toolbar2_class = ToolbarCls = NavigationIPy def __init__(self, canvas, num): self._shown = False super().__init__(canvas, num) + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + canvas = canvas_class(figure) + manager = cls(canvas, num) + if is_interactive(): + manager.show() + canvas.draw_idle() + + def destroy(event): + canvas.mpl_disconnect(cid) + Gcf.destroy(manager) + + cid = canvas.mpl_connect('close_event', destroy) + return manager + def display_js(self): # XXX How to do this just once? It has to deal with multiple # browser instances using the same kernel (require.js - but the @@ -91,6 +96,15 @@ def show(self): else: self.canvas.draw_idle() self._shown = True + # plt.figure adds an event which makes the figure in focus the active + # one. Disable this behaviour, as it results in figures being put as + # the active figure after they have been shown, even in non-interactive + # mode. + if hasattr(self, '_cidgcf'): + self.canvas.mpl_disconnect(self._cidgcf) + if not is_interactive(): + from matplotlib._pylab_helpers import Gcf + Gcf.figs.pop(self.num, None) def reshow(self): """ @@ -135,7 +149,7 @@ def clearup_closed(self): if socket.is_open()} if len(self.web_sockets) == 0: - self.canvas.close_event() + CloseEvent("close_event", self.canvas)._process() def remove_comm(self, comm_id): self.web_sockets = {socket for socket in self.web_sockets @@ -143,7 +157,7 @@ def remove_comm(self, comm_id): class FigureCanvasNbAgg(FigureCanvasWebAggCore): - pass + manager_class = FigureManagerNbAgg class CommSocket: @@ -227,42 +241,3 @@ def on_message(self, message): class _BackendNbAgg(_Backend): FigureCanvas = FigureCanvasNbAgg FigureManager = FigureManagerNbAgg - - @staticmethod - def new_figure_manager_given_figure(num, figure): - canvas = FigureCanvasNbAgg(figure) - manager = FigureManagerNbAgg(canvas, num) - if is_interactive(): - manager.show() - figure.canvas.draw_idle() - - def destroy(event): - canvas.mpl_disconnect(cid) - Gcf.destroy(manager) - - cid = canvas.mpl_connect('close_event', destroy) - return manager - - @staticmethod - def show(block=None): - ## TODO: something to do when keyword block==False ? - from matplotlib._pylab_helpers import Gcf - - managers = Gcf.get_all_fig_managers() - if not managers: - return - - interactive = is_interactive() - - for manager in managers: - manager.show() - - # plt.figure adds an event which makes the figure in focus the - # active one. Disable this behaviour, as it results in - # figures being put as the active figure after they have been - # shown, even in non-interactive mode. - if hasattr(manager, '_cidgcf'): - manager.canvas.mpl_disconnect(manager._cidgcf) - - if not interactive: - Gcf.figs.pop(manager.num, None) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index d035d1680da1..7bd0afc4567b 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -13,7 +13,6 @@ import logging import math import os -import re import string import struct import sys @@ -26,20 +25,17 @@ from PIL import Image import matplotlib as mpl -from matplotlib import _api, _text_helpers, cbook +from matplotlib import _api, _text_helpers, _type1font, cbook, dviread from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, + RendererBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.figure import Figure -from matplotlib.font_manager import findfont, get_font -from matplotlib.afm import AFM -import matplotlib.type1font as type1font -import matplotlib.dviread as dviread +from matplotlib.font_manager import get_font, fontManager as _fontManager +from matplotlib._afm import AFM from matplotlib.ft2font import (FIXED_WIDTH, ITALIC, LOAD_NO_SCALE, LOAD_NO_HINTING, KERNING_UNFITTED, FT2Font) -from matplotlib.mathtext import MathTextParser from matplotlib.transforms import Affine2D, BboxBase from matplotlib.path import Path from matplotlib.dates import UTC @@ -90,13 +86,18 @@ # TODOs: # -# * encoding of fonts, including mathtext fonts and unicode support +# * encoding of fonts, including mathtext fonts and Unicode support # * TTF support has lots of small TODOs, e.g., how do you know if a font # is serif/sans-serif, or symbolic/non-symbolic? # * draw_quad_mesh +@_api.deprecated("3.6", alternative="a vendored copy of _fill") def fill(strings, linelen=75): + return _fill(strings, linelen=linelen) + + +def _fill(strings, linelen=75): """ Make one string from sequence of strings, with whitespace in between. @@ -117,25 +118,6 @@ def fill(strings, linelen=75): result.append(b' '.join(strings[lasti:])) return b'\n'.join(result) -# PDF strings are supposed to be able to include any eight-bit data, -# except that unbalanced parens and backslashes must be escaped by a -# backslash. However, sf bug #2708559 shows that the carriage return -# character may get read as a newline; these characters correspond to -# \gamma and \Omega in TeX's math font encoding. Escaping them fixes -# the bug. -_string_escape_regex = re.compile(br'([\\()\r\n])') - - -def _string_escape(match): - m = match.group(0) - if m in br'\()': - return b'\\' + m - elif m == b'\n': - return br'\n' - elif m == b'\r': - return br'\r' - assert False - def _create_pdf_info_dict(backend, metadata): """ @@ -248,6 +230,77 @@ def _datetime_to_pdf(d): return r +def _calculate_quad_point_coordinates(x, y, width, height, angle=0): + """ + Calculate the coordinates of rectangle when rotated by angle around x, y + """ + + angle = math.radians(-angle) + sin_angle = math.sin(angle) + cos_angle = math.cos(angle) + a = x + height * sin_angle + b = y + height * cos_angle + c = x + width * cos_angle + height * sin_angle + d = y - width * sin_angle + height * cos_angle + e = x + width * cos_angle + f = y - width * sin_angle + return ((x, y), (e, f), (c, d), (a, b)) + + +def _get_coordinates_of_block(x, y, width, height, angle=0): + """ + Get the coordinates of rotated rectangle and rectangle that covers the + rotated rectangle. + """ + + vertices = _calculate_quad_point_coordinates(x, y, width, + height, angle) + + # Find min and max values for rectangle + # adjust so that QuadPoints is inside Rect + # PDF docs says that QuadPoints should be ignored if any point lies + # outside Rect, but for Acrobat it is enough that QuadPoints is on the + # border of Rect. + + pad = 0.00001 if angle % 90 else 0 + min_x = min(v[0] for v in vertices) - pad + min_y = min(v[1] for v in vertices) - pad + max_x = max(v[0] for v in vertices) + pad + max_y = max(v[1] for v in vertices) + pad + return (tuple(itertools.chain.from_iterable(vertices)), + (min_x, min_y, max_x, max_y)) + + +def _get_link_annotation(gc, x, y, width, height, angle=0): + """ + Create a link annotation object for embedding URLs. + """ + quadpoints, rect = _get_coordinates_of_block(x, y, width, height, angle) + link_annotation = { + 'Type': Name('Annot'), + 'Subtype': Name('Link'), + 'Rect': rect, + 'Border': [0, 0, 0], + 'A': { + 'S': Name('URI'), + 'URI': gc.get_url(), + }, + } + if angle % 90: + # Add QuadPoints + link_annotation['QuadPoints'] = quadpoints + return link_annotation + + +# PDF strings are supposed to be able to include any eight-bit data, except +# that unbalanced parens and backslashes must be escaped by a backslash. +# However, sf bug #2708559 shows that the carriage return character may get +# read as a newline; these characters correspond to \gamma and \Omega in TeX's +# math font encoding. Escaping them fixes the bug. +_str_escapes = str.maketrans({ + '\\': '\\\\', '(': '\\(', ')': '\\)', '\n': '\\n', '\r': '\\r'}) + + def pdfRepr(obj): """Map Python objects to PDF syntax.""" @@ -273,38 +326,36 @@ def pdfRepr(obj): elif isinstance(obj, (int, np.integer)): return b"%d" % obj - # Unicode strings are encoded in UTF-16BE with byte-order mark. + # Non-ASCII Unicode strings are encoded in UTF-16BE with byte-order mark. elif isinstance(obj, str): - try: - # But maybe it's really ASCII? - s = obj.encode('ASCII') - return pdfRepr(s) - except UnicodeEncodeError: - s = codecs.BOM_UTF16_BE + obj.encode('UTF-16BE') - return pdfRepr(s) + return pdfRepr(obj.encode('ascii') if obj.isascii() + else codecs.BOM_UTF16_BE + obj.encode('UTF-16BE')) # Strings are written in parentheses, with backslashes and parens # escaped. Actually balanced parens are allowed, but it is # simpler to escape them all. TODO: cut long strings into lines; # I believe there is some maximum line length in PDF. + # Despite the extra decode/encode, translate is faster than regex. elif isinstance(obj, bytes): - return b'(' + _string_escape_regex.sub(_string_escape, obj) + b')' + return ( + b'(' + + obj.decode('latin-1').translate(_str_escapes).encode('latin-1') + + b')') # Dictionaries. The keys must be PDF names, so if we find strings # there, we make Name objects from them. The values may be # anything, so the caller must ensure that PDF names are # represented as Name objects. elif isinstance(obj, dict): - return fill([ + return _fill([ b"<<", - *[Name(key).pdfRepr() + b" " + pdfRepr(obj[key]) - for key in sorted(obj)], + *[Name(k).pdfRepr() + b" " + pdfRepr(v) for k, v in obj.items()], b">>", ]) # Lists. elif isinstance(obj, (list, tuple)): - return fill([b"[", *[pdfRepr(val) for val in obj], b"]"]) + return _fill([b"[", *[pdfRepr(val) for val in obj], b"]"]) # The null keyword. elif obj is None: @@ -316,7 +367,7 @@ def pdfRepr(obj): # A bounding box elif isinstance(obj, BboxBase): - return fill([pdfRepr(val) for val in obj.bounds]) + return _fill([pdfRepr(val) for val in obj.bounds]) else: raise TypeError("Don't know a PDF representation for {} objects" @@ -365,7 +416,8 @@ def write(self, contents, file): class Name: """PDF name object.""" __slots__ = ('name',) - _regex = re.compile(r'[^!-~]') + _hexify = {c: '#%02x' % c + for c in {*range(256)} - {*range(ord('!'), ord('~') + 1)}} def __init__(self, name): if isinstance(name, Name): @@ -373,13 +425,13 @@ def __init__(self, name): else: if isinstance(name, bytes): name = name.decode('ascii') - self.name = self._regex.sub(Name.hexify, name).encode('ascii') + self.name = name.translate(self._hexify).encode('ascii') def __repr__(self): return "" % self.name def __str__(self): - return '/' + str(self.name) + return '/' + self.name.decode('ascii') def __eq__(self, other): return isinstance(other, Name) and self.name == other.name @@ -391,6 +443,7 @@ def __hash__(self): return hash(self.name) @staticmethod + @_api.deprecated("3.6") def hexify(match): return '#%02x' % ord(match.group()) @@ -398,8 +451,8 @@ def pdfRepr(self): return b'/' + self.name +@_api.deprecated("3.6") class Operator: - """PDF operator object.""" __slots__ = ('op',) def __init__(self, op): @@ -421,46 +474,52 @@ def pdfRepr(self): return self._x -# PDF operators (not an exhaustive list) -class Op(Operator, Enum): +class Op(Enum): + """PDF operators (not an exhaustive list).""" + close_fill_stroke = b'b' fill_stroke = b'B' fill = b'f' - closepath = b'h', + closepath = b'h' close_stroke = b's' stroke = b'S' endpath = b'n' - begin_text = b'BT', + begin_text = b'BT' end_text = b'ET' curveto = b'c' rectangle = b're' lineto = b'l' - moveto = b'm', + moveto = b'm' concat_matrix = b'cm' use_xobject = b'Do' - setgray_stroke = b'G', + setgray_stroke = b'G' setgray_nonstroke = b'g' setrgb_stroke = b'RG' - setrgb_nonstroke = b'rg', + setrgb_nonstroke = b'rg' setcolorspace_stroke = b'CS' - setcolorspace_nonstroke = b'cs', + setcolorspace_nonstroke = b'cs' setcolor_stroke = b'SCN' setcolor_nonstroke = b'scn' - setdash = b'd', + setdash = b'd' setlinejoin = b'j' setlinecap = b'J' setgstate = b'gs' - gsave = b'q', + gsave = b'q' grestore = b'Q' textpos = b'Td' selectfont = b'Tf' - textmatrix = b'Tm', + textmatrix = b'Tm' show = b'Tj' showkern = b'TJ' setlinewidth = b'w' clip = b'W' shading = b'sh' + op = _api.deprecated('3.6')(property(lambda self: self.value)) + + def pdfRepr(self): + return self.value + @classmethod def paint_path(cls, fill, stroke): """ @@ -837,7 +896,7 @@ def write(self, data): self.currentstream.write(data) def output(self, *data): - self.write(fill([pdfRepr(x) for x in data])) + self.write(_fill([pdfRepr(x) for x in data])) self.write(b'\n') def beginStream(self, id, len, extra=None, png=None): @@ -849,6 +908,11 @@ def endStream(self): self.currentstream.end() self.currentstream = None + def outputStream(self, ref, data, *, extra=None): + self.beginStream(ref.id, None, extra) + self.currentstream.write(data) + self.endStream() + def _write_annotations(self): for annotsObject, annotations in self._annotations: self.writeObject(annotsObject, annotations) @@ -861,20 +925,28 @@ def fontName(self, fontprop): """ if isinstance(fontprop, str): - filename = fontprop + filenames = [fontprop] elif mpl.rcParams['pdf.use14corefonts']: - filename = findfont( - fontprop, fontext='afm', directory=RendererPdf._afm_font_dir) + filenames = _fontManager._find_fonts_by_props( + fontprop, fontext='afm', directory=RendererPdf._afm_font_dir + ) else: - filename = findfont(fontprop) - - Fx = self.fontNames.get(filename) - if Fx is None: - Fx = next(self._internal_font_seq) - self.fontNames[filename] = Fx - _log.debug('Assigning font %s = %r', Fx, filename) - - return Fx + filenames = _fontManager._find_fonts_by_props(fontprop) + first_Fx = None + for fname in filenames: + Fx = self.fontNames.get(fname) + if not first_Fx: + first_Fx = Fx + if Fx is None: + Fx = next(self._internal_font_seq) + self.fontNames[fname] = Fx + _log.debug('Assigning font %s = %r', Fx, fname) + if not first_Fx: + first_Fx = Fx + + # find_fontsprop's first value always adheres to + # findfont's value, so technically no behaviour change + return first_Fx def dviFontName(self, dvifont): """ @@ -887,7 +959,7 @@ def dviFontName(self, dvifont): if dvi_info is not None: return dvi_info.pdfname - tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) + tex_font_map = dviread.PsfontsMap(dviread._find_tex_file('pdftex.map')) psfont = tex_font_map[dvifont.texname] if psfont.filename is None: raise ValueError( @@ -978,7 +1050,7 @@ def _embedTeXFont(self, fontinfo): return fontdictObject # We have a font file to embed - read it in and apply any effects - t1font = type1font.Type1Font(fontinfo.fontfile) + t1font = _type1font.Type1Font(fontinfo.fontfile) if fontinfo.effects: t1font = t1font.transform(fontinfo.effects) fontdict['BaseFont'] = Name(t1font.prop['FontName']) @@ -1053,22 +1125,19 @@ def createType1Descriptor(self, t1font, fontfile): self.writeObject(fontdescObject, descriptor) - self.beginStream(fontfileObject.id, None, - {'Length1': len(t1font.parts[0]), - 'Length2': len(t1font.parts[1]), - 'Length3': 0}) - self.currentstream.write(t1font.parts[0]) - self.currentstream.write(t1font.parts[1]) - self.endStream() + self.outputStream(fontfileObject, b"".join(t1font.parts[:2]), + extra={'Length1': len(t1font.parts[0]), + 'Length2': len(t1font.parts[1]), + 'Length3': 0}) return fontdescObject - def _get_xobject_symbol_name(self, filename, symbol_name): + def _get_xobject_glyph_name(self, filename, glyph_name): Fx = self.fontName(filename) return "-".join([ Fx.name.decode(), os.path.splitext(os.path.basename(filename))[0], - symbol_name]) + glyph_name]) _identityToUnicodeCMap = b"""/CIDInit /ProcSet findresource begin 12 dict begin @@ -1143,7 +1212,6 @@ def get_char_width(charcode): width = font.load_char( s, flags=LOAD_NO_SCALE | LOAD_NO_HINTING).horiAdvance return cvt(width) - with warnings.catch_warnings(): # Ignore 'Required glyph missing from current font' warning # from ft2font: here we're just building the widths table, but @@ -1182,13 +1250,13 @@ def get_char_width(charcode): charprocs = {} for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] - charprocDict = {'Length': len(stream)} + charprocDict = {} # The 2-byte characters are used as XObjects, so they # need extra info in their dictionary if charname in multi_byte_chars: - charprocDict['Type'] = Name('XObject') - charprocDict['Subtype'] = Name('Form') - charprocDict['BBox'] = bbox + charprocDict = {'Type': Name('XObject'), + 'Subtype': Name('Form'), + 'BBox': bbox} # Each glyph includes bounding box information, # but xpdf and ghostscript can't handle it in a # Form XObject (they segfault!!!), so we remove it @@ -1197,14 +1265,12 @@ def get_char_width(charcode): # value. stream = stream[stream.find(b"d1") + 2:] charprocObject = self.reserveObject('charProc') - self.beginStream(charprocObject.id, None, charprocDict) - self.currentstream.write(stream) - self.endStream() + self.outputStream(charprocObject, stream, extra=charprocDict) # Send the glyphs with ccode > 255 to the XObject dictionary, # and the others to the font itself if charname in multi_byte_chars: - name = self._get_xobject_symbol_name(filename, charname) + name = self._get_xobject_glyph_name(filename, charname) self.multi_byte_charprocs[name] = charprocObject else: charprocs[charname] = charprocObject @@ -1267,12 +1333,9 @@ def embedTTFType42(font, characters, descriptor): # Make fontfile stream descriptor['FontFile2'] = fontfileObject - self.beginStream( - fontfileObject.id, - self.reserveObject('length of font stream'), - {'Length1': fontdata.getbuffer().nbytes}) - self.currentstream.write(fontdata.getvalue()) - self.endStream() + self.outputStream( + fontfileObject, fontdata.getvalue(), + extra={'Length1': fontdata.getbuffer().nbytes}) # Make the 'W' (Widths) array, CidToGidMap and ToUnicode CMap # at the same time @@ -1331,10 +1394,9 @@ def embedTTFType42(font, characters, descriptor): rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] - charprocDict = {'Length': len(stream)} - charprocDict['Type'] = Name('XObject') - charprocDict['Subtype'] = Name('Form') - charprocDict['BBox'] = bbox + charprocDict = {'Type': Name('XObject'), + 'Subtype': Name('Form'), + 'BBox': bbox} # Each glyph includes bounding box information, # but xpdf and ghostscript can't handle it in a # Form XObject (they segfault!!!), so we remove it @@ -1343,27 +1405,17 @@ def embedTTFType42(font, characters, descriptor): # value. stream = stream[stream.find(b"d1") + 2:] charprocObject = self.reserveObject('charProc') - self.beginStream(charprocObject.id, None, charprocDict) - self.currentstream.write(stream) - self.endStream() + self.outputStream(charprocObject, stream, extra=charprocDict) - name = self._get_xobject_symbol_name(filename, charname) + name = self._get_xobject_glyph_name(filename, charname) self.multi_byte_charprocs[name] = charprocObject # CIDToGIDMap stream cid_to_gid_map = "".join(cid_to_gid_map).encode("utf-16be") - self.beginStream(cidToGidMapObject.id, - None, - {'Length': len(cid_to_gid_map)}) - self.currentstream.write(cid_to_gid_map) - self.endStream() + self.outputStream(cidToGidMapObject, cid_to_gid_map) # ToUnicode CMap - self.beginStream(toUnicodeMapObject.id, - None, - {'Length': unicode_cmap}) - self.currentstream.write(unicode_cmap) - self.endStream() + self.outputStream(toUnicodeMapObject, unicode_cmap) descriptor['MaxWidth'] = max_width @@ -1729,10 +1781,9 @@ def _writeImg(self, data, id, smask=None): if bit_depth is None or palette is None: raise RuntimeError("invalid PNG header") palette = palette[:num_colors * 3] # Trim padding - palette = pdfRepr(palette) - obj['ColorSpace'] = Verbatim(b'[/Indexed /DeviceRGB ' - + str(num_colors - 1).encode() - + b' ' + palette + b']') + obj['ColorSpace'] = Verbatim( + b'[/Indexed /DeviceRGB %d %s]' + % (num_colors - 1, pdfRepr(palette))) obj['BitsPerComponent'] = bit_depth color_channels = 1 else: @@ -1847,7 +1898,8 @@ def pathOperations(path, transform, clip=None, simplify=None, sketch=None): return [Verbatim(_path.convert_to_string( path, transform, clip, simplify, sketch, 6, - [Op.moveto.op, Op.lineto.op, b'', Op.curveto.op, Op.closepath.op], + [Op.moveto.value, Op.lineto.value, b'', Op.curveto.value, + Op.closepath.value], True))] def writePath(self, path, transform, clip=False, sketch=None): @@ -1921,11 +1973,6 @@ def __init__(self, file, image_dpi, height, width): self.gc = self.new_gc() self.image_dpi = image_dpi - @_api.deprecated("3.4") - @property - def mathtext_parser(self): - return MathTextParser("Pdf") - def finalize(self): self.file.output(*self.gc.finalize()) @@ -1999,7 +2046,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): self.file.output(self.gc.paint()) def draw_path_collection(self, gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): # We can only reuse the objects if the presence of fill and @@ -2041,7 +2088,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, if (not can_do_optimization) or (not should_do_optimization): return RendererBase.draw_path_collection( self, gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position) @@ -2057,8 +2104,8 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, output(*self.gc.push()) lastx, lasty = 0, 0 for xo, yo, path_id, gc0, rgbFace in self._iter_collection( - gc, master_transform, all_transforms, path_codes, offsets, - offsetTrans, facecolors, edgecolors, linewidths, linestyles, + gc, path_codes, offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): self.check_gc(gc0, rgbFace) @@ -2166,17 +2213,8 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): self._text2path.mathtext_parser.parse(s, 72, prop) if gc.get_url() is not None: - link_annotation = { - 'Type': Name('Annot'), - 'Subtype': Name('Link'), - 'Rect': (x, y, x + width, y + height), - 'Border': [0, 0, 0], - 'A': { - 'S': Name('URI'), - 'URI': gc.get_url(), - }, - } - self.file._annotations[-1][1].append(link_annotation) + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, width, height, angle)) fonttype = mpl.rcParams['pdf.fonttype'] @@ -2232,17 +2270,8 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): page, = dvi if gc.get_url() is not None: - link_annotation = { - 'Type': Name('Annot'), - 'Subtype': Name('Link'), - 'Rect': (x, y, x + page.width, y + page.height), - 'Border': [0, 0, 0], - 'A': { - 'S': Name('URI'), - 'URI': gc.get_url(), - }, - } - self.file._annotations[-1][1].append(link_annotation) + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, page.width, page.height, angle)) # Gather font information and do some setup for combining # characters into strings. The variable seq will contain a @@ -2342,17 +2371,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if gc.get_url() is not None: font.set_text(s) width, height = font.get_width_height() - link_annotation = { - 'Type': Name('Annot'), - 'Subtype': Name('Link'), - 'Rect': (x, y, x + width / 64, y + height / 64), - 'Border': [0, 0, 0], - 'A': { - 'S': Name('URI'), - 'URI': gc.get_url(), - }, - } - self.file._annotations[-1][1].append(link_annotation) + self.file._annotations[-1][1].append(_get_link_annotation( + gc, x, y, width / 64, height / 64, angle)) # If fonttype is neither 3 nor 42, emit the whole string at once # without manual kerning. @@ -2376,22 +2396,27 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # the regular text show command (TJ) with appropriate kerning between # chunks, whereas multibyte characters use the XObject command (Do). else: - # List of (start_x, [prev_kern, char, char, ...]), w/o zero kerns. + # List of (ft_object, start_x, [prev_kern, char, char, ...]), + # w/o zero kerns. singlebyte_chunks = [] - # List of (start_x, glyph_index). + # List of (ft_object, start_x, glyph_index). multibyte_glyphs = [] prev_was_multibyte = True + prev_font = font for item in _text_helpers.layout( s, font, kern_mode=KERNING_UNFITTED): if _font_supports_glyph(fonttype, ord(item.char)): - if prev_was_multibyte: - singlebyte_chunks.append((item.x, [])) + if prev_was_multibyte or item.ft_object != prev_font: + singlebyte_chunks.append((item.ft_object, item.x, [])) + prev_font = item.ft_object if item.prev_kern: - singlebyte_chunks[-1][1].append(item.prev_kern) - singlebyte_chunks[-1][1].append(item.char) + singlebyte_chunks[-1][2].append(item.prev_kern) + singlebyte_chunks[-1][2].append(item.char) prev_was_multibyte = False else: - multibyte_glyphs.append((item.x, item.glyph_idx)) + multibyte_glyphs.append( + (item.ft_object, item.x, item.glyph_idx) + ) prev_was_multibyte = True # Do the rotation and global translation as a single matrix # concatenation up front @@ -2401,10 +2426,12 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): -math.sin(a), math.cos(a), x, y, Op.concat_matrix) # Emit all the 1-byte characters in a BT/ET group. - self.file.output(Op.begin_text, - self.file.fontName(prop), fontsize, Op.selectfont) + + self.file.output(Op.begin_text) prev_start_x = 0 - for start_x, kerns_or_chars in singlebyte_chunks: + for ft_object, start_x, kerns_or_chars in singlebyte_chunks: + ft_name = self.file.fontName(ft_object.fname) + self.file.output(ft_name, fontsize, Op.selectfont) self._setup_textpos(start_x, 0, 0, prev_start_x, 0, 0) self.file.output( # See pdf spec "Text space details" for the 1000/fontsize @@ -2416,14 +2443,16 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): prev_start_x = start_x self.file.output(Op.end_text) # Then emit all the multibyte characters, one at a time. - for start_x, glyph_idx in multibyte_glyphs: - self._draw_xobject_glyph(font, fontsize, glyph_idx, start_x, 0) + for ft_object, start_x, glyph_idx in multibyte_glyphs: + self._draw_xobject_glyph( + ft_object, fontsize, glyph_idx, start_x, 0 + ) self.file.output(Op.grestore) def _draw_xobject_glyph(self, font, fontsize, glyph_idx, x, y): """Draw a multibyte character from a Type 3 font as an XObject.""" - symbol_name = font.get_glyph_name(glyph_idx) - name = self.file._get_xobject_symbol_name(font.fname, symbol_name) + glyph_name = font.get_glyph_name(glyph_idx) + name = self.file._get_xobject_glyph_name(font.fname, glyph_name) self.file.output( Op.gsave, 0.001 * fontsize, 0, 0, 0.001 * fontsize, x, y, Op.concat_matrix, @@ -2585,7 +2614,7 @@ def clip_cmd(self, cliprect, clippath): def delta(self, other): """ Copy properties of other into self and return PDF commands - needed to transform self into other. + needed to transform *self* into *other*. """ cmds = [] fill_performed = False @@ -2767,15 +2796,11 @@ class FigureCanvasPdf(FigureCanvasBase): def get_default_filetype(self): return 'pdf' - @_check_savefig_extra_args - @_api.delete_parameter("3.4", "dpi") def print_pdf(self, filename, *, - dpi=None, # dpi to use for images bbox_inches_restore=None, metadata=None): - if dpi is None: # always use this branch after deprecation elapses. - dpi = self.figure.get_dpi() - self.figure.set_dpi(72) # there are 72 pdf points to an inch + dpi = self.figure.dpi + self.figure.dpi = 72 # there are 72 pdf points to an inch width, height = self.figure.get_size_inches() if isinstance(filename, PdfPages): file = filename._file diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index b5eec48d922f..7312f300a57b 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -1,4 +1,3 @@ -import atexit import codecs import datetime import functools @@ -18,8 +17,7 @@ import matplotlib as mpl from matplotlib import _api, cbook, font_manager as fm from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase ) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.backends.backend_pdf import ( @@ -36,58 +34,70 @@ # which is not recognized by TeX. -def get_fontspec(): - """Build fontspec preamble from rc.""" - latex_fontspec = [] - texcommand = mpl.rcParams["pgf.texsystem"] +@_api.caching_module_getattr +class __getattr__: + NO_ESCAPE = _api.deprecated("3.6", obj_type="")( + property(lambda self: _NO_ESCAPE)) + re_mathsep = _api.deprecated("3.6", obj_type="")( + property(lambda self: _split_math.__self__)) - if texcommand != "pdflatex": - latex_fontspec.append("\\usepackage{fontspec}") - if texcommand != "pdflatex" and mpl.rcParams["pgf.rcfonts"]: - families = ["serif", "sans\\-serif", "monospace"] - commands = ["setmainfont", "setsansfont", "setmonofont"] - for family, command in zip(families, commands): - # 1) Forward slashes also work on Windows, so don't mess with - # backslashes. 2) The dirname needs to include a separator. - path = pathlib.Path(fm.findfont(family)) - latex_fontspec.append(r"\%s{%s}[Path=\detokenize{%s}]" % ( - command, path.name, path.parent.as_posix() + "/")) - - return "\n".join(latex_fontspec) +@_api.deprecated("3.6") +def get_fontspec(): + """Build fontspec preamble from rc.""" + with mpl.rc_context({"pgf.preamble": ""}): + return _get_preamble() +@_api.deprecated("3.6") def get_preamble(): """Get LaTeX preamble from rc.""" return mpl.rcParams["pgf.preamble"] -############################################################################### -# This almost made me cry!!! -# In the end, it's better to use only one unit for all coordinates, since the +def _get_preamble(): + """Prepare a LaTeX preamble based on the rcParams configuration.""" + preamble = [mpl.rcParams["pgf.preamble"]] + if mpl.rcParams["pgf.texsystem"] != "pdflatex": + preamble.append("\\usepackage{fontspec}") + if mpl.rcParams["pgf.rcfonts"]: + families = ["serif", "sans\\-serif", "monospace"] + commands = ["setmainfont", "setsansfont", "setmonofont"] + for family, command in zip(families, commands): + # 1) Forward slashes also work on Windows, so don't mess with + # backslashes. 2) The dirname needs to include a separator. + path = pathlib.Path(fm.findfont(family)) + preamble.append(r"\%s{%s}[Path=\detokenize{%s/}]" % ( + command, path.name, path.parent.as_posix())) + preamble.append(mpl.texmanager._usepackage_if_not_loaded( + "underscore", option="strings")) # Documented as "must come last". + return "\n".join(preamble) + + +# It's better to use only one unit for all coordinates, since the # arithmetic in latex seems to produce inaccurate conversions. latex_pt_to_in = 1. / 72.27 latex_in_to_pt = 1. / latex_pt_to_in mpl_pt_to_in = 1. / 72. mpl_in_to_pt = 1. / mpl_pt_to_in -############################################################################### -# helper functions - -NO_ESCAPE = r"(? 3 else 1.0 if has_fill: - writeln(self.fh, - r"\definecolor{currentfill}{rgb}{%f,%f,%f}" - % tuple(rgbFace[:3])) - writeln(self.fh, r"\pgfsetfillcolor{currentfill}") + _writeln(self.fh, + r"\definecolor{currentfill}{rgb}{%f,%f,%f}" + % tuple(rgbFace[:3])) + _writeln(self.fh, r"\pgfsetfillcolor{currentfill}") if has_fill and fillopacity != 1.0: - writeln(self.fh, r"\pgfsetfillopacity{%f}" % fillopacity) + _writeln(self.fh, r"\pgfsetfillopacity{%f}" % fillopacity) # linewidth and color lw = gc.get_linewidth() * mpl_pt_to_in * latex_in_to_pt stroke_rgba = gc.get_rgb() - writeln(self.fh, r"\pgfsetlinewidth{%fpt}" % lw) - writeln(self.fh, - r"\definecolor{currentstroke}{rgb}{%f,%f,%f}" - % stroke_rgba[:3]) - writeln(self.fh, r"\pgfsetstrokecolor{currentstroke}") + _writeln(self.fh, r"\pgfsetlinewidth{%fpt}" % lw) + _writeln(self.fh, + r"\definecolor{currentstroke}{rgb}{%f,%f,%f}" + % stroke_rgba[:3]) + _writeln(self.fh, r"\pgfsetstrokecolor{currentstroke}") if strokeopacity != 1.0: - writeln(self.fh, r"\pgfsetstrokeopacity{%f}" % strokeopacity) + _writeln(self.fh, r"\pgfsetstrokeopacity{%f}" % strokeopacity) # line style dash_offset, dash_list = gc.get_dashes() if dash_list is None: - writeln(self.fh, r"\pgfsetdash{}{0pt}") + _writeln(self.fh, r"\pgfsetdash{}{0pt}") else: - writeln(self.fh, - r"\pgfsetdash{%s}{%fpt}" - % ("".join(r"{%fpt}" % dash for dash in dash_list), - dash_offset)) + _writeln(self.fh, + r"\pgfsetdash{%s}{%fpt}" + % ("".join(r"{%fpt}" % dash for dash in dash_list), + dash_offset)) def _print_pgf_path(self, gc, path, transform, rgbFace=None): f = 1. / self.dpi @@ -573,32 +584,32 @@ def _print_pgf_path(self, gc, path, transform, rgbFace=None): for points, code in path.iter_segments(transform, clip=clip): if code == Path.MOVETO: x, y = tuple(points) - writeln(self.fh, - r"\pgfpathmoveto{\pgfqpoint{%fin}{%fin}}" % - (f * x, f * y)) + _writeln(self.fh, + r"\pgfpathmoveto{\pgfqpoint{%fin}{%fin}}" % + (f * x, f * y)) elif code == Path.CLOSEPOLY: - writeln(self.fh, r"\pgfpathclose") + _writeln(self.fh, r"\pgfpathclose") elif code == Path.LINETO: x, y = tuple(points) - writeln(self.fh, - r"\pgfpathlineto{\pgfqpoint{%fin}{%fin}}" % - (f * x, f * y)) + _writeln(self.fh, + r"\pgfpathlineto{\pgfqpoint{%fin}{%fin}}" % + (f * x, f * y)) elif code == Path.CURVE3: cx, cy, px, py = tuple(points) coords = cx * f, cy * f, px * f, py * f - writeln(self.fh, - r"\pgfpathquadraticcurveto" - r"{\pgfqpoint{%fin}{%fin}}{\pgfqpoint{%fin}{%fin}}" - % coords) + _writeln(self.fh, + r"\pgfpathquadraticcurveto" + r"{\pgfqpoint{%fin}{%fin}}{\pgfqpoint{%fin}{%fin}}" + % coords) elif code == Path.CURVE4: c1x, c1y, c2x, c2y, px, py = tuple(points) coords = c1x * f, c1y * f, c2x * f, c2y * f, px * f, py * f - writeln(self.fh, - r"\pgfpathcurveto" - r"{\pgfqpoint{%fin}{%fin}}" - r"{\pgfqpoint{%fin}{%fin}}" - r"{\pgfqpoint{%fin}{%fin}}" - % coords) + _writeln(self.fh, + r"\pgfpathcurveto" + r"{\pgfqpoint{%fin}{%fin}}" + r"{\pgfqpoint{%fin}{%fin}}" + r"{\pgfqpoint{%fin}{%fin}}" + % coords) # apply pgf decorators sketch_params = gc.get_sketch_params() if gc else None @@ -616,13 +627,13 @@ def _print_pgf_path(self, gc, path, transform, rgbFace=None): length *= 0.5 scale *= 2 # PGF guarantees that repeated loading is a no-op - writeln(self.fh, r"\usepgfmodule{decorations}") - writeln(self.fh, r"\usepgflibrary{decorations.pathmorphing}") - writeln(self.fh, r"\pgfkeys{/pgf/decoration/.cd, " - f"segment length = {(length * f):f}in, " - f"amplitude = {(scale * f):f}in}}") - writeln(self.fh, f"\\pgfmathsetseed{{{int(randomness)}}}") - writeln(self.fh, r"\pgfdecoratecurrentpath{random steps}") + _writeln(self.fh, r"\usepgfmodule{decorations}") + _writeln(self.fh, r"\usepgflibrary{decorations.pathmorphing}") + _writeln(self.fh, r"\pgfkeys{/pgf/decoration/.cd, " + f"segment length = {(length * f):f}in, " + f"amplitude = {(scale * f):f}in}}") + _writeln(self.fh, f"\\pgfmathsetseed{{{int(randomness)}}}") + _writeln(self.fh, r"\pgfdecoratecurrentpath{random steps}") def _pgf_path_draw(self, stroke=True, fill=False): actions = [] @@ -630,7 +641,7 @@ def _pgf_path_draw(self, stroke=True, fill=False): actions.append("stroke") if fill: actions.append("fill") - writeln(self.fh, r"\pgfusepath{%s}" % ",".join(actions)) + _writeln(self.fh, r"\pgfusepath{%s}" % ",".join(actions)) def option_scale_image(self): # docstring inherited @@ -659,31 +670,31 @@ def draw_image(self, gc, x, y, im, transform=None): self.image_counter += 1 # reference the image in the pgf picture - writeln(self.fh, r"\begin{pgfscope}") + _writeln(self.fh, r"\begin{pgfscope}") self._print_pgf_clip(gc) f = 1. / self.dpi # from display coords to inch if transform is None: - writeln(self.fh, - r"\pgfsys@transformshift{%fin}{%fin}" % (x * f, y * f)) + _writeln(self.fh, + r"\pgfsys@transformshift{%fin}{%fin}" % (x * f, y * f)) w, h = w * f, h * f else: tr1, tr2, tr3, tr4, tr5, tr6 = transform.frozen().to_values() - writeln(self.fh, - r"\pgfsys@transformcm{%f}{%f}{%f}{%f}{%fin}{%fin}" % - (tr1 * f, tr2 * f, tr3 * f, tr4 * f, - (tr5 + x) * f, (tr6 + y) * f)) + _writeln(self.fh, + r"\pgfsys@transformcm{%f}{%f}{%f}{%f}{%fin}{%fin}" % + (tr1 * f, tr2 * f, tr3 * f, tr4 * f, + (tr5 + x) * f, (tr6 + y) * f)) w = h = 1 # scale is already included in the transform interp = str(transform is None).lower() # interpolation in PDF reader - writeln(self.fh, - r"\pgftext[left,bottom]" - r"{%s[interpolate=%s,width=%fin,height=%fin]{%s}}" % - (_get_image_inclusion_command(), - interp, w, h, fname_img)) - writeln(self.fh, r"\end{pgfscope}") - - def draw_tex(self, gc, x, y, s, prop, angle, ismath="TeX", mtext=None): + _writeln(self.fh, + r"\pgftext[left,bottom]" + r"{%s[interpolate=%s,width=%fin,height=%fin]{%s}}" % + (_get_image_inclusion_command(), + interp, w, h, fname_img)) + _writeln(self.fh, r"\end{pgfscope}") + + def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): # docstring inherited - self.draw_text(gc, x, y, s, prop, angle, ismath, mtext) + self.draw_text(gc, x, y, s, prop, angle, ismath="TeX", mtext=mtext) def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited @@ -691,16 +702,16 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # prepare string for tex s = _escape_and_apply_props(s, prop) - writeln(self.fh, r"\begin{pgfscope}") + _writeln(self.fh, r"\begin{pgfscope}") alpha = gc.get_alpha() if alpha != 1.0: - writeln(self.fh, r"\pgfsetfillopacity{%f}" % alpha) - writeln(self.fh, r"\pgfsetstrokeopacity{%f}" % alpha) + _writeln(self.fh, r"\pgfsetfillopacity{%f}" % alpha) + _writeln(self.fh, r"\pgfsetstrokeopacity{%f}" % alpha) rgb = tuple(gc.get_rgb())[:3] - writeln(self.fh, r"\definecolor{textcolor}{rgb}{%f,%f,%f}" % rgb) - writeln(self.fh, r"\pgfsetstrokecolor{textcolor}") - writeln(self.fh, r"\pgfsetfillcolor{textcolor}") + _writeln(self.fh, r"\definecolor{textcolor}{rgb}{%f,%f,%f}" % rgb) + _writeln(self.fh, r"\pgfsetstrokecolor{textcolor}") + _writeln(self.fh, r"\pgfsetfillcolor{textcolor}") s = r"\color{textcolor}" + s dpi = self.figure.dpi @@ -729,8 +740,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if angle != 0: text_args.append("rotate=%f" % angle) - writeln(self.fh, r"\pgftext[%s]{%s}" % (",".join(text_args), s)) - writeln(self.fh, r"\end{pgfscope}") + _writeln(self.fh, r"\pgftext[%s]{%s}" % (",".join(text_args), s)) + _writeln(self.fh, r"\end{pgfscope}") def get_text_width_height_descent(self, s, prop, ismath): # docstring inherited @@ -757,31 +768,6 @@ def points_to_pixels(self, points): return points * mpl_pt_to_in * self.dpi -@_api.deprecated("3.4") -class TmpDirCleaner: - _remaining_tmpdirs = set() - - @_api.classproperty - @_api.deprecated("3.4") - def remaining_tmpdirs(cls): - return cls._remaining_tmpdirs - - @staticmethod - @_api.deprecated("3.4") - def add(tmpdir): - TmpDirCleaner._remaining_tmpdirs.add(tmpdir) - - @staticmethod - @_api.deprecated("3.4") - @atexit.register - def cleanup_remaining_tmpdirs(): - for tmpdir in TmpDirCleaner._remaining_tmpdirs: - error_message = "error deleting tmp directory {}".format(tmpdir) - shutil.rmtree( - tmpdir, - onerror=lambda *args: _log.error(error_message)) - - class FigureCanvasPgf(FigureCanvasBase): filetypes = {"pgf": "LaTeX PGF picture", "pdf": "LaTeX compiled PGF picture", @@ -790,7 +776,6 @@ class FigureCanvasPgf(FigureCanvasBase): def get_default_filetype(self): return 'pdf' - @_check_savefig_extra_args def _print_pgf_to_fh(self, fh, *, bbox_inches_restore=None): header_text = """%% Creator: Matplotlib, PGF backend @@ -817,37 +802,35 @@ def _print_pgf_to_fh(self, fh, *, bbox_inches_restore=None): # append the preamble used by the backend as a comment for debugging header_info_preamble = ["%% Matplotlib used the following preamble"] - for line in get_preamble().splitlines(): - header_info_preamble.append("%% " + line) - for line in get_fontspec().splitlines(): + for line in _get_preamble().splitlines(): header_info_preamble.append("%% " + line) header_info_preamble.append("%%") header_info_preamble = "\n".join(header_info_preamble) # get figure size in inch w, h = self.figure.get_figwidth(), self.figure.get_figheight() - dpi = self.figure.get_dpi() + dpi = self.figure.dpi # create pgfpicture environment and write the pgf code fh.write(header_text) fh.write(header_info_preamble) fh.write("\n") - writeln(fh, r"\begingroup") - writeln(fh, r"\makeatletter") - writeln(fh, r"\begin{pgfpicture}") - writeln(fh, - r"\pgfpathrectangle{\pgfpointorigin}{\pgfqpoint{%fin}{%fin}}" - % (w, h)) - writeln(fh, r"\pgfusepath{use as bounding box, clip}") + _writeln(fh, r"\begingroup") + _writeln(fh, r"\makeatletter") + _writeln(fh, r"\begin{pgfpicture}") + _writeln(fh, + r"\pgfpathrectangle{\pgfpointorigin}{\pgfqpoint{%fin}{%fin}}" + % (w, h)) + _writeln(fh, r"\pgfusepath{use as bounding box, clip}") renderer = MixedModeRenderer(self.figure, w, h, dpi, RendererPgf(self.figure, fh), bbox_inches_restore=bbox_inches_restore) self.figure.draw(renderer) # end the pgfpicture environment - writeln(fh, r"\end{pgfpicture}") - writeln(fh, r"\makeatother") - writeln(fh, r"\endgroup") + _writeln(fh, r"\end{pgfpicture}") + _writeln(fh, r"\makeatother") + _writeln(fh, r"\endgroup") def print_pgf(self, fname_or_fh, **kwargs): """ @@ -873,14 +856,12 @@ def print_pdf(self, fname_or_fh, *, metadata=None, **kwargs): self.print_pgf(tmppath / "figure.pgf", **kwargs) (tmppath / "figure.tex").write_text( "\n".join([ - r"\PassOptionsToPackage{pdfinfo={%s}}{hyperref}" % pdfinfo, - r"\RequirePackage{hyperref}", - r"\documentclass[12pt]{minimal}", + r"\documentclass[12pt]{article}", + r"\usepackage[pdfinfo={%s}]{hyperref}" % pdfinfo, r"\usepackage[papersize={%fin,%fin}, margin=0in]{geometry}" % (w, h), - get_preamble(), - get_fontspec(), r"\usepackage{pgf}", + _get_preamble(), r"\begin{document}", r"\centering", r"\input{figure.pgf}", @@ -985,14 +966,12 @@ def _write_header(self, width_inches, height_inches): pdfinfo = ','.join( _metadata_to_str(k, v) for k, v in self._info_dict.items()) latex_header = "\n".join([ - r"\PassOptionsToPackage{pdfinfo={%s}}{hyperref}" % pdfinfo, - r"\RequirePackage{hyperref}", - r"\documentclass[12pt]{minimal}", + r"\documentclass[12pt]{article}", + r"\usepackage[pdfinfo={%s}]{hyperref}" % pdfinfo, r"\usepackage[papersize={%fin,%fin}, margin=0in]{geometry}" % (width_inches, height_inches), - get_preamble(), - get_fontspec(), r"\usepackage{pgf}", + _get_preamble(), r"\setlength{\parindent}{0pt}", r"\begin{document}%", ]) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 293a27fb90a9..68dd61e6f126 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -6,13 +6,11 @@ import datetime from enum import Enum import functools -import glob -from io import StringIO, TextIOWrapper +from io import StringIO +import itertools import logging -import math import os import pathlib -import tempfile import re import shutil from tempfile import TemporaryDirectory @@ -22,15 +20,13 @@ import matplotlib as mpl from matplotlib import _api, cbook, _path, _text_helpers -from matplotlib.afm import AFM +from matplotlib._afm import AFM from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - GraphicsContextBase, RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.cbook import is_writable_file_like, file_requires_unicode from matplotlib.font_manager import get_font -from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_NO_SCALE, FT2Font +from matplotlib.ft2font import LOAD_NO_SCALE, FT2Font from matplotlib._ttconv import convert_ttf_to_ps -from matplotlib.mathtext import MathTextParser from matplotlib._mathtext_data import uni2type1 from matplotlib.path import Path from matplotlib.texmanager import TexManager @@ -38,18 +34,22 @@ from matplotlib.backends.backend_mixed import MixedModeRenderer from . import _backend_pdf_ps -_log = logging.getLogger(__name__) -backend_version = 'Level II' +_log = logging.getLogger(__name__) debugPS = False +@_api.deprecated("3.7") class PsBackendHelper: def __init__(self): self._cached = {} -ps_backend_helper = PsBackendHelper() +@_api.caching_module_getattr +class __getattr__: + # module-level deprecations + ps_backend_helper = _api.deprecated("3.7", obj_type="")( + property(lambda self: PsBackendHelper())) papersize = {'letter': (8.5, 11), @@ -63,9 +63,9 @@ def __init__(self): 'a5': (5.83, 8.27), 'a6': (4.13, 5.83), 'a7': (2.91, 4.13), - 'a8': (2.07, 2.91), - 'a9': (1.457, 2.05), - 'a10': (1.02, 1.457), + 'a8': (2.05, 2.91), + 'a9': (1.46, 2.05), + 'a10': (1.02, 1.46), 'b0': (40.55, 57.32), 'b1': (28.66, 40.55), 'b2': (20.27, 28.66), @@ -92,6 +92,7 @@ def _nums_to_str(*args): return " ".join(f"{arg:1.3f}".rstrip("0").rstrip(".") for arg in args) +@_api.deprecated("3.6", alternative="a vendored copy of this function") def quote_ps_string(s): """ Quote dangerous characters of S for use in a PostScript string constant. @@ -263,9 +264,6 @@ class RendererPS(_backend_pdf_ps.RendererPDFPSBase): _afm_font_dir = cbook._get_data_path("fonts/afm") _use_afm_rc_name = "ps.useafm" - mathtext_parser = _api.deprecated("3.4")(property( - lambda self: MathTextParser("PS"))) - def __init__(self, width, height, pswriter, imagedpi=72): # Although postscript itself is dpi independent, we need to inform the # image code about a requested dpi to generate high resolution images @@ -385,7 +383,7 @@ def create_hatch(self, hatch): /PaintProc {{ pop - {linewidth:f} setlinewidth + {linewidth:g} setlinewidth {self._convert_path( Path.hatch(hatch), Affine2D().scale(sidelen), simplify=False)} gsave @@ -395,7 +393,7 @@ def create_hatch(self, hatch): }} bind >> matrix - 0.0 {pageheight:f} translate + 0 {pageheight:g} translate makepattern /{name} exch def """) @@ -448,17 +446,7 @@ def draw_image(self, gc, x, y, im, transform=None): h, w = im.shape[:2] imagecmd = "false 3 colorimage" data = im[::-1, :, :3] # Vertically flipped rgb values. - # data.tobytes().hex() has no spaces, so can be linewrapped by simply - # splitting data every nchars. It's equivalent to textwrap.fill only - # much faster. - nchars = 128 - data = data.tobytes().hex() - hexlines = "\n".join( - [ - data[n * nchars:(n + 1) * nchars] - for n in range(math.ceil(len(data) / nchars)) - ] - ) + hexdata = data.tobytes().hex("\n", -64) # Linewrap to 128 chars. if transform is None: matrix = "1 0 0 1 0 0" @@ -472,15 +460,15 @@ def draw_image(self, gc, x, y, im, transform=None): self._pswriter.write(f"""\ gsave {self._get_clip_cmd(gc)} -{x:f} {y:f} translate +{x:g} {y:g} translate [{matrix}] concat -{xscale:f} {yscale:f} scale +{xscale:g} {yscale:g} scale /DataString {w:d} string def {w:d} {h:d} 8 [ {w:d} 0 0 -{h:d} 0 {h:d} ] {{ currentfile DataString readhexstring pop }} bind {imagecmd} -{hexlines} +{hexdata} grestore """) @@ -547,7 +535,7 @@ def draw_markers( @_log_if_debug_on def draw_path_collection(self, gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): # Is the optimization worth it? Rough calculation: @@ -563,7 +551,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, if not should_do_optimization: return RendererBase.draw_path_collection( self, gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position) @@ -582,8 +570,8 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, path_codes.append(name) for xo, yo, path_id, gc0, rgbFace in self._iter_collection( - gc, master_transform, all_transforms, path_codes, offsets, - offsetTrans, facecolors, edgecolors, linewidths, linestyles, + gc, path_codes, offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): ps = "%g %g %s" % (xo, yo, path_id) self._draw_ps(ps, gc0, rgbFace) @@ -643,13 +631,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if ismath: return self.draw_mathtext(gc, x, y, s, prop, angle) + stream = [] # list of (ps_name, x, char_name) + if mpl.rcParams['ps.useafm']: font = self._get_font_afm(prop) + ps_name = (font.postscript_name.encode("ascii", "replace") + .decode("ascii")) scale = 0.001 * prop.get_size_in_points() - thisx = 0 last_name = None # kerns returns 0 for None. - xs_names = [] for c in s: name = uni2type1.get(ord(c), f"uni{ord(c):04X}") try: @@ -660,27 +650,29 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): kern = font.get_kern_dist_from_name(last_name, name) last_name = name thisx += kern * scale - xs_names.append((thisx, name)) + stream.append((ps_name, thisx, name)) thisx += width * scale else: font = self._get_font_ttf(prop) - font.set_text(s, 0, flags=LOAD_NO_HINTING) self._character_tracker.track(font, s) - xs_names = [(item.x, font.get_glyph_name(item.glyph_idx)) - for item in _text_helpers.layout(s, font)] - + for item in _text_helpers.layout(s, font): + ps_name = (item.ft_object.postscript_name + .encode("ascii", "replace").decode("ascii")) + glyph_name = item.ft_object.get_glyph_name(item.glyph_idx) + stream.append((ps_name, item.x, glyph_name)) self.set_color(*gc.get_rgb()) - ps_name = (font.postscript_name - .encode("ascii", "replace").decode("ascii")) - self.set_font(ps_name, prop.get_size_in_points()) - thetext = "\n".join(f"{x:f} 0 m /{name:s} glyphshow" - for x, name in xs_names) - self._pswriter.write(f"""\ + + for ps_name, group in itertools. \ + groupby(stream, lambda entry: entry[0]): + self.set_font(ps_name, prop.get_size_in_points(), False) + thetext = "\n".join(f"{x:g} 0 m /{name:s} glyphshow" + for _, x, name in group) + self._pswriter.write(f"""\ gsave {self._get_clip_cmd(gc)} -{x:f} {y:f} translate -{angle:f} rotate +{x:g} {y:g} translate +{angle:g} rotate {thetext} grestore """) @@ -689,14 +681,12 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): def draw_mathtext(self, gc, x, y, s, prop, angle): """Draw the math text using matplotlib.mathtext.""" width, height, descent, glyphs, rects = \ - self._text2path.mathtext_parser.parse( - s, 72, prop, - _force_standard_ps_fonts=mpl.rcParams["ps.useafm"]) + self._text2path.mathtext_parser.parse(s, 72, prop) self.set_color(*gc.get_rgb()) self._pswriter.write( f"gsave\n" - f"{x:f} {y:f} translate\n" - f"{angle:f} rotate\n") + f"{x:g} {y:g} translate\n" + f"{angle:g} rotate\n") lastfont = None for font, fontsize, num, ox, oy in glyphs: self._character_tracker.track_glyph(font, num) @@ -704,12 +694,12 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): lastfont = font.postscript_name, fontsize self._pswriter.write( f"/{font.postscript_name} {fontsize} selectfont\n") - symbol_name = ( + glyph_name = ( font.get_name_char(chr(num)) if isinstance(font, AFM) else font.get_glyph_name(font.get_char_index(num))) self._pswriter.write( - f"{ox:f} {oy:f} moveto\n" - f"/{symbol_name} glyphshow\n") + f"{ox:g} {oy:g} moveto\n" + f"/{glyph_name} glyphshow\n") for ox, oy, w, h in rects: self._pswriter.write(f"{ox} {oy} {w} {h} rectfill\n") self._pswriter.write("grestore\n") @@ -740,13 +730,13 @@ def draw_gouraud_triangles(self, gc, points, colors, trans): xmin, ymin = points_min xmax, ymax = points_max - streamarr = np.empty( + data = np.empty( shape[0] * shape[1], dtype=[('flags', 'u1'), ('points', '2>u4'), ('colors', '3u1')]) - streamarr['flags'] = 0 - streamarr['points'] = (flat_points - points_min) * factor - streamarr['colors'] = flat_colors[:, :3] * 255.0 - stream = quote_ps_string(streamarr.tobytes()) + data['flags'] = 0 + data['points'] = (flat_points - points_min) * factor + data['colors'] = flat_colors[:, :3] * 255.0 + hexdata = data.tobytes().hex("\n", -64) # Linewrap to 128 chars. self._pswriter.write(f"""\ gsave @@ -756,8 +746,10 @@ def draw_gouraud_triangles(self, gc, points, colors, trans): /BitsPerComponent 8 /BitsPerFlag 8 /AntiAlias true - /Decode [ {xmin:f} {xmax:f} {ymin:f} {ymax:f} 0 1 0 1 0 1 ] - /DataSource ({stream}) + /Decode [ {xmin:g} {xmax:g} {ymin:g} {ymax:g} 0 1 0 1 0 1 ] + /DataSource < +{hexdata} +> >> shfill grestore @@ -786,6 +778,7 @@ def _draw_ps(self, ps, gc, rgbFace, *, fill=True, stroke=True): self.set_linejoin(gc.get_joinstyle()) self.set_linecap(gc.get_capstyle()) self.set_linedash(*gc.get_dashes()) + if mightstroke or hatch: self.set_color(*gc.get_rgb()[:3]) write('gsave\n') @@ -814,15 +807,6 @@ def _draw_ps(self, ps, gc, rgbFace, *, fill=True, stroke=True): write("grestore\n") -@_api.deprecated("3.4", alternative="GraphicsContextBase") -class GraphicsContextPS(GraphicsContextBase): - def get_capstyle(self): - return {'butt': 0, 'round': 1, 'projecting': 2}[super().get_capstyle()] - - def get_joinstyle(self): - return {'miter': 0, 'round': 1, 'bevel': 2}[super().get_joinstyle()] - - class _Orientation(Enum): portrait, landscape = range(2) @@ -838,23 +822,13 @@ class FigureCanvasPS(FigureCanvasBase): def get_default_filetype(self): return 'ps' - @_api.delete_parameter("3.5", "args") - def print_ps(self, outfile, *args, **kwargs): - return self._print_ps(outfile, 'ps', **kwargs) - - @_api.delete_parameter("3.5", "args") - def print_eps(self, outfile, *args, **kwargs): - return self._print_ps(outfile, 'eps', **kwargs) - - @_api.delete_parameter("3.4", "dpi") def _print_ps( - self, outfile, format, *, - dpi=None, metadata=None, papertype=None, orientation='portrait', - **kwargs): + self, fmt, outfile, *, + metadata=None, papertype=None, orientation='portrait', + bbox_inches_restore=None, **kwargs): - if dpi is None: # always use this branch after deprecation elapses. - dpi = self.figure.get_dpi() - self.figure.set_dpi(72) # Override the dpi kwarg + dpi = self.figure.dpi + self.figure.dpi = 72 # Override the dpi kwarg dsc_comments = {} if isinstance(outfile, (str, os.PathLike)): @@ -885,22 +859,22 @@ def _print_ps( printer = (self._print_figure_tex if mpl.rcParams['text.usetex'] else self._print_figure) - printer(outfile, format, dpi=dpi, dsc_comments=dsc_comments, - orientation=orientation, papertype=papertype, **kwargs) + printer(fmt, outfile, dpi=dpi, dsc_comments=dsc_comments, + orientation=orientation, papertype=papertype, + bbox_inches_restore=bbox_inches_restore, **kwargs) - @_check_savefig_extra_args def _print_figure( - self, outfile, format, *, + self, fmt, outfile, *, dpi, dsc_comments, orientation, papertype, bbox_inches_restore=None): """ Render the figure to a filesystem path or a file-like object. Parameters are as for `.print_figure`, except that *dsc_comments* is a - all string containing Document Structuring Convention comments, + string containing Document Structuring Convention comments, generated from the *metadata* parameter to `.print_figure`. """ - is_eps = format == 'eps' + is_eps = fmt == 'eps' if not (isinstance(outfile, (str, os.PathLike)) or is_writable_file_like(outfile)): raise ValueError("outfile must be a path or a file-like object") @@ -1026,9 +1000,8 @@ def print_figure_impl(fh): file = codecs.getwriter("latin-1")(file) print_figure_impl(file) - @_check_savefig_extra_args def _print_figure_tex( - self, outfile, format, *, + self, fmt, outfile, *, dpi, dsc_comments, orientation, papertype, bbox_inches_restore=None): """ @@ -1038,7 +1011,7 @@ def _print_figure_tex( The rest of the behavior is as for `._print_figure`. """ - is_eps = format == 'eps' + is_eps = fmt == 'eps' width, height = self.figure.get_size_inches() xo = 0 @@ -1062,8 +1035,8 @@ def _print_figure_tex( # write to a temp file, we'll move it to outfile when done with TemporaryDirectory() as tmpdir: - tmpfile = os.path.join(tmpdir, "tmp.ps") - pathlib.Path(tmpfile).write_text( + tmppath = pathlib.Path(tmpdir, "tmp.ps") + tmppath.write_text( f"""\ %!PS-Adobe-3.0 EPSF-3.0 {dsc_comments} @@ -1099,35 +1072,38 @@ def _print_figure_tex( papertype = _get_papertype(width, height) paper_width, paper_height = papersize[papertype] - texmanager = ps_renderer.get_texmanager() - font_preamble = texmanager.get_font_preamble() - custom_preamble = texmanager.get_custom_preamble() - - psfrag_rotated = convert_psfrags(tmpfile, ps_renderer.psfrag, - font_preamble, - custom_preamble, paper_width, - paper_height, - orientation.name) + psfrag_rotated = _convert_psfrags( + tmppath, ps_renderer.psfrag, paper_width, paper_height, + orientation.name) if (mpl.rcParams['ps.usedistiller'] == 'ghostscript' or mpl.rcParams['text.usetex']): _try_distill(gs_distill, - tmpfile, is_eps, ptype=papertype, bbox=bbox, + tmppath, is_eps, ptype=papertype, bbox=bbox, rotated=psfrag_rotated) elif mpl.rcParams['ps.usedistiller'] == 'xpdf': _try_distill(xpdf_distill, - tmpfile, is_eps, ptype=papertype, bbox=bbox, + tmppath, is_eps, ptype=papertype, bbox=bbox, rotated=psfrag_rotated) - _move_path_to_path_or_stream(tmpfile, outfile) + _move_path_to_path_or_stream(tmppath, outfile) + + print_ps = functools.partialmethod(_print_ps, "ps") + print_eps = functools.partialmethod(_print_ps, "eps") def draw(self): self.figure.draw_without_rendering() return super().draw() +@_api.deprecated("3.6") def convert_psfrags(tmpfile, psfrags, font_preamble, custom_preamble, paper_width, paper_height, orientation): + return _convert_psfrags( + pathlib.Path(tmpfile), psfrags, paper_width, paper_height, orientation) + + +def _convert_psfrags(tmppath, psfrags, paper_width, paper_height, orientation): """ When we want to use the LaTeX backend with postscript, we write PSFrag tags to a temporary postscript file, each one marking a position for LaTeX to @@ -1154,7 +1130,7 @@ def convert_psfrags(tmpfile, psfrags, font_preamble, custom_preamble, % { "psfrags": "\n".join(psfrags), "angle": 90 if orientation == 'landscape' else 0, - "epsfile": pathlib.Path(tmpfile).resolve().as_posix(), + "epsfile": tmppath.resolve().as_posix(), }, fontsize=10) # tex's default fontsize. @@ -1162,7 +1138,7 @@ def convert_psfrags(tmpfile, psfrags, font_preamble, custom_preamble, psfile = os.path.join(tmpdir, "tmp.ps") cbook._check_and_log_subprocess( ['dvips', '-q', '-R0', '-o', psfile, dvifile], _log) - shutil.move(psfile, tmpfile) + shutil.move(psfile, tmppath) # check if the dvips created a ps in landscape paper. Somehow, # above latex+dvips results in a ps file in a landscape mode for a @@ -1171,14 +1147,14 @@ def convert_psfrags(tmpfile, psfrags, font_preamble, custom_preamble, # the generated ps file is in landscape and return this # information. The return value is used in pstoeps step to recover # the correct bounding box. 2010-06-05 JJL - with open(tmpfile) as fh: + with open(tmppath) as fh: psfrag_rotated = "Landscape" in fh.read(1000) return psfrag_rotated -def _try_distill(func, *args, **kwargs): +def _try_distill(func, tmppath, *args, **kwargs): try: - func(*args, **kwargs) + func(str(tmppath), *args, **kwargs) except mpl.ExecutableNotFoundError as exc: _log.warning("%s. Distillation step skipped.", exc) @@ -1212,7 +1188,7 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): # the original bbox can be restored during the pstoeps step. if eps: - # For some versions of gs, above steps result in an ps file where the + # For some versions of gs, above steps result in a ps file where the # original bbox is no more correct. Do not adjust bbox for now. pstoeps(tmpfile, bbox, rotated=rotated) @@ -1227,32 +1203,26 @@ def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): mpl._get_executable_info("gs") # Effectively checks for ps2pdf. mpl._get_executable_info("pdftops") - pdffile = tmpfile + '.pdf' - psfile = tmpfile + '.ps' - - # Pass options as `-foo#bar` instead of `-foo=bar` to keep Windows happy - # (https://www.ghostscript.com/doc/9.22/Use.htm#MS_Windows). - cbook._check_and_log_subprocess( - ["ps2pdf", - "-dAutoFilterColorImages#false", - "-dAutoFilterGrayImages#false", - "-sAutoRotatePages#None", - "-sGrayImageFilter#FlateEncode", - "-sColorImageFilter#FlateEncode", - "-dEPSCrop" if eps else "-sPAPERSIZE#%s" % ptype, - tmpfile, pdffile], _log) - cbook._check_and_log_subprocess( - ["pdftops", "-paper", "match", "-level2", pdffile, psfile], _log) - - os.remove(tmpfile) - shutil.move(psfile, tmpfile) - + with TemporaryDirectory() as tmpdir: + tmppdf = pathlib.Path(tmpdir, "tmp.pdf") + tmpps = pathlib.Path(tmpdir, "tmp.ps") + # Pass options as `-foo#bar` instead of `-foo=bar` to keep Windows + # happy (https://ghostscript.com/doc/9.56.1/Use.htm#MS_Windows). + cbook._check_and_log_subprocess( + ["ps2pdf", + "-dAutoFilterColorImages#false", + "-dAutoFilterGrayImages#false", + "-sAutoRotatePages#None", + "-sGrayImageFilter#FlateEncode", + "-sColorImageFilter#FlateEncode", + "-dEPSCrop" if eps else "-sPAPERSIZE#%s" % ptype, + tmpfile, tmppdf], _log) + cbook._check_and_log_subprocess( + ["pdftops", "-paper", "match", "-level2", tmppdf, tmpps], _log) + shutil.move(tmpps, tmpfile) if eps: pstoeps(tmpfile) - for fname in glob.glob(tmpfile+'.*'): - os.remove(fname) - def get_bbox_header(lbrt, rotated=False): """ @@ -1387,4 +1357,5 @@ def pstoeps(tmpfile, bbox=None, rotated=False): @_Backend.export class _BackendPS(_Backend): + backend_version = 'Level II' FigureCanvas = FigureCanvasPS diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 4190dbda4e54..eb08c734af20 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -1,7 +1,5 @@ import functools -import operator import os -import signal import sys import traceback @@ -10,20 +8,17 @@ from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase, cursors, ToolContainerBase, MouseButton) + TimerBase, cursors, ToolContainerBase, MouseButton, + CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) import matplotlib.backends.qt_editor.figureoptions as figureoptions from . import qt_compat from .qt_compat import ( QtCore, QtGui, QtWidgets, __version__, QT_API, - _enum, _to_int, - _devicePixelRatioF, _isdeleted, _setDevicePixelRatio, - _maybe_allow_interrupt + _enum, _to_int, _isdeleted, _maybe_allow_interrupt ) -backend_version = __version__ - -# SPECIAL_KEYS are Qt::Key that do *not* return their unicode name +# SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name # instead they have manually specified names. SPECIAL_KEYS = { _to_int(getattr(_enum("QtCore.Qt.Key"), k)): v for k, v in [ @@ -93,105 +88,72 @@ } -# make place holder -qApp = None +@_api.caching_module_getattr +class __getattr__: + qApp = _api.deprecated( + "3.6", alternative="QtWidgets.QApplication.instance()")( + property(lambda self: QtWidgets.QApplication.instance())) +# lru_cache keeps a reference to the QApplication instance, keeping it from +# being GC'd. +@functools.lru_cache(1) def _create_qApp(): - """ - Only one qApp can exist at a time, so check before creating one. - """ - global qApp - - if qApp is None: - app = QtWidgets.QApplication.instance() - if app is None: - # display_is_valid returns False only if on Linux and neither X11 - # nor Wayland display can be opened. - if not mpl._c_internal_utils.display_is_valid(): - raise RuntimeError('Invalid DISPLAY variable') - try: - QtWidgets.QApplication.setAttribute( - QtCore.Qt.AA_EnableHighDpiScaling) - except AttributeError: # Only for Qt>=5.6, <6. - pass - - # Check to make sure a QApplication from a different major version - # of Qt is not instantiated in the process - if QT_API in {'PyQt6', 'PySide6'}: - other_bindings = ('PyQt5', 'PySide2') - elif QT_API in {'PyQt5', 'PySide2'}: - other_bindings = ('PyQt6', 'PySide6') - else: - raise RuntimeError("Should never be here") - - for binding in other_bindings: - mod = sys.modules.get(f'{binding}.QtWidgets') - if mod is not None and mod.QApplication.instance() is not None: - other_core = sys.modules.get(f'{binding}.QtCore') - _api.warn_external( - f'Matplotlib is using {QT_API} which wraps ' - f'{QtCore.qVersion()} however an instantiated ' - f'QApplication from {binding} which wraps ' - f'{other_core.qVersion()} exists. Mixing Qt major ' - 'versions may not work as expected.' - ) - break - try: - QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) - except AttributeError: # Only for Qt>=5.14. - pass - qApp = QtWidgets.QApplication(["matplotlib"]) - qApp.lastWindowClosed.connect(qApp.quit) - cbook._setup_new_guiapp() + app = QtWidgets.QApplication.instance() + + # Create a new QApplication and configure it if none exists yet, as only + # one QApplication can exist at a time. + if app is None: + # display_is_valid returns False only if on Linux and neither X11 + # nor Wayland display can be opened. + if not mpl._c_internal_utils.display_is_valid(): + raise RuntimeError('Invalid DISPLAY variable') + + # Check to make sure a QApplication from a different major version + # of Qt is not instantiated in the process + if QT_API in {'PyQt6', 'PySide6'}: + other_bindings = ('PyQt5', 'PySide2') + elif QT_API in {'PyQt5', 'PySide2'}: + other_bindings = ('PyQt6', 'PySide6') else: - qApp = app + raise RuntimeError("Should never be here") + + for binding in other_bindings: + mod = sys.modules.get(f'{binding}.QtWidgets') + if mod is not None and mod.QApplication.instance() is not None: + other_core = sys.modules.get(f'{binding}.QtCore') + _api.warn_external( + f'Matplotlib is using {QT_API} which wraps ' + f'{QtCore.qVersion()} however an instantiated ' + f'QApplication from {binding} which wraps ' + f'{other_core.qVersion()} exists. Mixing Qt major ' + 'versions may not work as expected.' + ) + break + try: + QtWidgets.QApplication.setAttribute( + QtCore.Qt.AA_EnableHighDpiScaling) + except AttributeError: # Only for Qt>=5.6, <6. + pass + try: + QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( + QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) + except AttributeError: # Only for Qt>=5.14. + pass + app = QtWidgets.QApplication(["matplotlib"]) + if sys.platform == "darwin": + image = str(cbook._get_data_path('images/matplotlib.svg')) + icon = QtGui.QIcon(image) + app.setWindowIcon(icon) + app.lastWindowClosed.connect(app.quit) + cbook._setup_new_guiapp() try: - qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) # Only for Qt<6. + app.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) # Only for Qt<6. except AttributeError: pass - -def _allow_super_init(__init__): - """ - Decorator for ``__init__`` to allow ``super().__init__`` on PySide2. - """ - - if QT_API in ["PyQt5", "PyQt6"]: - - return __init__ - - else: - # To work around lack of cooperative inheritance in PySide2 and - # PySide6, when calling FigureCanvasQT.__init__, we temporarily patch - # QWidget.__init__ by a cooperative version, that first calls - # QWidget.__init__ with no additional arguments, and then finds the - # next class in the MRO with an __init__ that does support cooperative - # inheritance (i.e., not defined by the PyQt4 or sip, or PySide{,2,6} - # or Shiboken packages), and manually call its `__init__`, once again - # passing the additional arguments. - - qwidget_init = QtWidgets.QWidget.__init__ - - def cooperative_qwidget_init(self, *args, **kwargs): - qwidget_init(self) - mro = type(self).__mro__ - next_coop_init = next( - cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:] - if cls.__module__.split(".")[0] not in [ - "PySide2", "PySide6", "Shiboken", - ]) - next_coop_init.__init__(self, *args, **kwargs) - - @functools.wraps(__init__) - def wrapper(self, *args, **kwargs): - with cbook._setattr_cm(QtWidgets.QWidget, - __init__=cooperative_qwidget_init): - __init__(self, *args, **kwargs) - - return wrapper + return app class TimerQT(TimerBase): @@ -223,9 +185,10 @@ def _timer_stop(self): self._timer.stop() -class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): +class FigureCanvasQT(FigureCanvasBase, QtWidgets.QWidget): required_interactive_framework = "qt" _timer_cls = TimerQT + manager_class = _api.classproperty(lambda cls: FigureManagerQT) buttond = { getattr(_enum("QtCore.Qt.MouseButton"), k): v for k, v in [ @@ -237,7 +200,6 @@ class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): ] } - @_allow_super_init def __init__(self, figure=None): _create_qApp() super().__init__(figure=figure) @@ -245,6 +207,7 @@ def __init__(self, figure=None): self._draw_pending = False self._is_drawing = False self._draw_rect_callback = lambda painter: None + self._in_resize_event = False self.setAttribute( _enum("QtCore.Qt.WidgetAttribute").WA_OpaquePaintEvent) @@ -255,7 +218,8 @@ def __init__(self, figure=None): self.setPalette(palette) def _update_pixel_ratio(self): - if self._set_device_pixel_ratio(_devicePixelRatioF(self)): + if self._set_device_pixel_ratio( + self.devicePixelRatioF() or 1): # rarely, devicePixelRatioF=0 # The easiest way to resize the canvas is to emit a resizeEvent # since we implement all the logic for resizing the canvas for # that event. @@ -280,18 +244,7 @@ def set_cursor(self, cursor): # docstring inherited self.setCursor(_api.check_getitem(cursord, cursor=cursor)) - def enterEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) - FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) - - def leaveEvent(self, event): - QtWidgets.QApplication.restoreOverrideCursor() - FigureCanvasBase.leave_notify_event(self, guiEvent=event) - - _get_position = operator.methodcaller( - "position" if QT_API in ["PyQt6", "PySide6"] else "pos") - - def mouseEventCoords(self, pos): + def mouseEventCoords(self, pos=None): """ Calculate mouse coordinates in physical pixels. @@ -301,39 +254,65 @@ def mouseEventCoords(self, pos): Also, the origin is different and needs to be corrected. """ + if pos is None: + pos = self.mapFromGlobal(QtGui.QCursor.pos()) + elif hasattr(pos, "position"): # qt6 QtGui.QEvent + pos = pos.position() + elif hasattr(pos, "pos"): # qt5 QtCore.QEvent + pos = pos.pos() + # (otherwise, it's already a QPoint) x = pos.x() # flip y so y=0 is bottom of canvas y = self.figure.bbox.height / self.device_pixel_ratio - pos.y() return x * self.device_pixel_ratio, y * self.device_pixel_ratio + def enterEvent(self, event): + # Force querying of the modifiers, as the cached modifier state can + # have been invalidated while the window was out of focus. + mods = QtWidgets.QApplication.instance().queryKeyboardModifiers() + LocationEvent("figure_enter_event", self, + *self.mouseEventCoords(event), + modifiers=self._mpl_modifiers(mods), + guiEvent=event)._process() + + def leaveEvent(self, event): + QtWidgets.QApplication.restoreOverrideCursor() + LocationEvent("figure_leave_event", self, + *self.mouseEventCoords(), + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() + def mousePressEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) button = self.buttond.get(event.button()) if button is not None: - FigureCanvasBase.button_press_event(self, x, y, button, - guiEvent=event) + MouseEvent("button_press_event", self, + *self.mouseEventCoords(event), button, + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() def mouseDoubleClickEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) button = self.buttond.get(event.button()) if button is not None: - FigureCanvasBase.button_press_event(self, x, y, - button, dblclick=True, - guiEvent=event) + MouseEvent("button_press_event", self, + *self.mouseEventCoords(event), button, dblclick=True, + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() def mouseMoveEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) - FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) + MouseEvent("motion_notify_event", self, + *self.mouseEventCoords(event), + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() def mouseReleaseEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) button = self.buttond.get(event.button()) if button is not None: - FigureCanvasBase.button_release_event(self, x, y, button, - guiEvent=event) + MouseEvent("button_release_event", self, + *self.mouseEventCoords(event), button, + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() def wheelEvent(self, event): - x, y = self.mouseEventCoords(self._get_position(event)) # from QWheelEvent::pixelDelta doc: pixelDelta is sometimes not # provided (`isNull()`) and is unreliable on X11 ("xcb"). if (event.pixelDelta().isNull() @@ -342,35 +321,43 @@ def wheelEvent(self, event): else: steps = event.pixelDelta().y() if steps: - FigureCanvasBase.scroll_event( - self, x, y, steps, guiEvent=event) + MouseEvent("scroll_event", self, + *self.mouseEventCoords(event), step=steps, + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() def keyPressEvent(self, event): key = self._get_key(event) if key is not None: - FigureCanvasBase.key_press_event(self, key, guiEvent=event) + KeyEvent("key_press_event", self, + key, *self.mouseEventCoords(), + guiEvent=event)._process() def keyReleaseEvent(self, event): key = self._get_key(event) if key is not None: - FigureCanvasBase.key_release_event(self, key, guiEvent=event) + KeyEvent("key_release_event", self, + key, *self.mouseEventCoords(), + guiEvent=event)._process() def resizeEvent(self, event): - frame = sys._getframe() - # Prevent PyQt6 recursion, but sometimes frame.f_back is None - if frame.f_code is getattr(frame.f_back, 'f_code', None): + if self._in_resize_event: # Prevent PyQt6 recursion return - w = event.size().width() * self.device_pixel_ratio - h = event.size().height() * self.device_pixel_ratio - - dpival = self.figure.dpi - winch = w / dpival - hinch = h / dpival - self.figure.set_size_inches(winch, hinch, forward=False) - # pass back into Qt to let it finish - QtWidgets.QWidget.resizeEvent(self, event) - # emit our resize events - FigureCanvasBase.resize_event(self) + self._in_resize_event = True + try: + w = event.size().width() * self.device_pixel_ratio + h = event.size().height() * self.device_pixel_ratio + dpival = self.figure.dpi + winch = w / dpival + hinch = h / dpival + self.figure.set_size_inches(winch, hinch, forward=False) + # pass back into Qt to let it finish + QtWidgets.QWidget.resizeEvent(self, event) + # emit our resize events + ResizeEvent("resize_event", self)._process() + self.draw_idle() + finally: + self._in_resize_event = False def sizeHint(self): w, h = self.get_width_height() @@ -379,26 +366,31 @@ def sizeHint(self): def minumumSizeHint(self): return QtCore.QSize(10, 10) - def _get_key(self, event): - event_key = event.key() - event_mods = _to_int(event.modifiers()) # actually a bitmask - + @staticmethod + def _mpl_modifiers(modifiers=None, *, exclude=None): + if modifiers is None: + modifiers = QtWidgets.QApplication.instance().keyboardModifiers() + modifiers = _to_int(modifiers) # get names of the pressed modifier keys # 'control' is named 'control' when a standalone key, but 'ctrl' when a # modifier - # bit twiddling to pick out modifier keys from event_mods bitmask, - # if event_key is a MODIFIER, it should not be duplicated in mods - mods = [SPECIAL_KEYS[key].replace('control', 'ctrl') - for mod, key in _MODIFIER_KEYS - if event_key != key and event_mods & mod] + # bit twiddling to pick out modifier keys from modifiers bitmask, + # if exclude is a MODIFIER, it should not be duplicated in mods + return [SPECIAL_KEYS[key].replace('control', 'ctrl') + for mask, key in _MODIFIER_KEYS + if exclude != key and modifiers & mask] + + def _get_key(self, event): + event_key = event.key() + mods = self._mpl_modifiers(exclude=event_key) try: # for certain keys (enter, left, backspace, etc) use a word for the - # key, rather than unicode + # key, rather than Unicode key = SPECIAL_KEYS[event_key] except KeyError: - # unicode defines code points up to 0x10ffff (sys.maxunicode) + # Unicode defines code points up to 0x10ffff (sys.maxunicode) # QT will use Key_Codes larger than that for keyboard keys that are - # are not unicode characters (like multimedia keys) + # not Unicode characters (like multimedia keys) # skip these # if you really want them, you should add them to SPECIAL_KEYS if event_key > sys.maxunicode: @@ -416,7 +408,7 @@ def _get_key(self, event): def flush_events(self): # docstring inherited - qApp.processEvents() + QtWidgets.QApplication.instance().processEvents() def start_event_loop(self, timeout=0): # docstring inherited @@ -424,8 +416,7 @@ def start_event_loop(self, timeout=0): raise RuntimeError("Event loop already running") self._event_loop = event_loop = QtCore.QEventLoop() if timeout > 0: - timer = QtCore.QTimer.singleShot(int(timeout * 1000), - event_loop.quit) + _ = QtCore.QTimer.singleShot(int(timeout * 1000), event_loop.quit) with _maybe_allow_interrupt(event_loop): qt_compat._exec(event_loop) @@ -538,21 +529,18 @@ class FigureManagerQT(FigureManagerBase): def __init__(self, canvas, num): self.window = MainWindow() super().__init__(canvas, num) - self.window.closing.connect(canvas.close_event) + self.window.closing.connect( + # The lambda prevents the event from being immediately gc'd. + lambda: CloseEvent("close_event", self.canvas)._process()) self.window.closing.connect(self._widgetclosed) - image = str(cbook._get_data_path('images/matplotlib.svg')) - self.window.setWindowIcon(QtGui.QIcon(image)) + if sys.platform != "darwin": + image = str(cbook._get_data_path('images/matplotlib.svg')) + icon = QtGui.QIcon(image) + self.window.setWindowIcon(icon) self.window._destroying = False - self.toolbar = self._get_toolbar(self.canvas, self.window) - - if self.toolmanager: - backend_tools.add_tools_to_manager(self.toolmanager) - if self.toolbar: - backend_tools.add_tools_to_container(self.toolbar) - if self.toolbar: self.window.addToolBar(self.toolbar) tbs_height = self.toolbar.sizeHint().height() @@ -599,17 +587,6 @@ def _widgetclosed(self): # Gcf can get destroyed before the Gcf.destroy # line is run, leading to a useless AttributeError. - def _get_toolbar(self, canvas, parent): - # must be inited after the window, drawingArea and figure - # attrs are set - if mpl.rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2QT(canvas, parent, True) - elif mpl.rcParams['toolbar'] == 'toolmanager': - toolbar = ToolbarQt(self.toolmanager, self.window) - else: - toolbar = None - return toolbar - def resize(self, width, height): # The Qt methods return sizes in 'virtual' pixels so we do need to # rescale from physical to logical pixels. @@ -620,6 +597,13 @@ def resize(self, width, height): self.canvas.resize(width, height) self.window.resize(width + extra_width, height + extra_height) + @classmethod + def start_main_loop(cls): + qapp = QtWidgets.QApplication.instance() + if qapp: + with _maybe_allow_interrupt(qapp): + qt_compat._exec(qapp) + def show(self): self.window.show() if mpl.rcParams['figure.raise_window']: @@ -654,7 +638,7 @@ class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar): ("Customize", "Edit axis, curve and image parameters", "qt4_editor_options", "edit_parameters")) - def __init__(self, canvas, parent, coordinates=True): + def __init__(self, canvas, parent=None, coordinates=True): """coordinates: should we show the coordinates on the right?""" QtWidgets.QToolBar.__init__(self, parent) self.setAllowedAreas(QtCore.Qt.ToolBarArea( @@ -707,7 +691,8 @@ def _icon(self, name): filename = str(path_large if path_large.exists() else path_regular) pm = QtGui.QPixmap(filename) - _setDevicePixelRatio(pm, _devicePixelRatioF(self)) + pm.setDevicePixelRatio( + self.devicePixelRatioF() or 1) # rarely, devicePixelRatioF=0 if self.palette().color(self.backgroundRole()).value() < 128: icon_color = self.palette().color(self.foregroundRole()) mask = pm.createMaskFromColor( @@ -927,7 +912,7 @@ def _reset(self): class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar): - def __init__(self, toolmanager, parent): + def __init__(self, toolmanager, parent=None): ToolContainerBase.__init__(self, toolmanager) QtWidgets.QToolBar.__init__(self, parent) self.setAllowedAreas(QtCore.Qt.ToolBarArea( @@ -994,6 +979,7 @@ def set_message(self, s): self.widgetForAction(self._message_action).setText(s) +@backend_tools._register_tool_class(FigureCanvasQT) class ConfigureSubplotsQt(backend_tools.ConfigureSubplotsBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1003,19 +989,14 @@ def trigger(self, *args): NavigationToolbar2QT.configure_subplots(self) +@backend_tools._register_tool_class(FigureCanvasQT) class SaveFigureQt(backend_tools.SaveFigureBase): def trigger(self, *args): NavigationToolbar2QT.save_figure( self._make_classic_style_pseudo_toolbar()) -@_api.deprecated("3.5", alternative="ToolSetCursor") -class SetCursorQt(backend_tools.SetCursorBase): - def set_cursor(self, cursor): - NavigationToolbar2QT.set_cursor( - self._make_classic_style_pseudo_toolbar(), cursor) - - +@backend_tools._register_tool_class(FigureCanvasQT) class RubberbandQt(backend_tools.RubberbandBase): def draw_rubberband(self, x0, y0, x1, y1): NavigationToolbar2QT.draw_rubberband( @@ -1026,30 +1007,26 @@ def remove_rubberband(self): self._make_classic_style_pseudo_toolbar()) +@backend_tools._register_tool_class(FigureCanvasQT) class HelpQt(backend_tools.ToolHelpBase): def trigger(self, *args): QtWidgets.QMessageBox.information(None, "Help", self._get_help_html()) +@backend_tools._register_tool_class(FigureCanvasQT) class ToolCopyToClipboardQT(backend_tools.ToolCopyToClipboardBase): def trigger(self, *args, **kwargs): pixmap = self.canvas.grab() - qApp.clipboard().setPixmap(pixmap) + QtWidgets.QApplication.instance().clipboard().setPixmap(pixmap) -backend_tools.ToolSaveFigure = SaveFigureQt -backend_tools.ToolConfigureSubplots = ConfigureSubplotsQt -backend_tools.ToolRubberband = RubberbandQt -backend_tools.ToolHelp = HelpQt -backend_tools.ToolCopyToClipboard = ToolCopyToClipboardQT +FigureManagerQT._toolbar2_class = NavigationToolbar2QT +FigureManagerQT._toolmanager_toolbar_class = ToolbarQt @_Backend.export class _BackendQT(_Backend): + backend_version = __version__ FigureCanvas = FigureCanvasQT FigureManager = FigureManagerQT - - @staticmethod - def mainloop(): - with _maybe_allow_interrupt(qApp): - qt_compat._exec(qApp) + mainloop = FigureManagerQT.start_main_loop diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 3c6b2c66a845..d94062b723f4 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -4,11 +4,11 @@ from .backend_qt import ( # noqa - backend_version, SPECIAL_KEYS, + SPECIAL_KEYS, # Public API cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT, FigureManagerQT, ToolbarQt, NavigationToolbar2QT, SubplotToolQt, - SaveFigureQt, ConfigureSubplotsQt, SetCursorQt, RubberbandQt, + SaveFigureQt, ConfigureSubplotsQt, RubberbandQt, HelpQt, ToolCopyToClipboardQT, # internal re-exports FigureCanvasBase, FigureManagerBase, MouseButton, NavigationToolbar2, diff --git a/lib/matplotlib/backends/backend_qt5agg.py b/lib/matplotlib/backends/backend_qt5agg.py index c81fa6f6ccb3..8a92fd5135d5 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -6,8 +6,7 @@ backends._QT_FORCE_QT5_BINDING = True from .backend_qtagg import ( # noqa: F401, E402 # pylint: disable=W0611 _BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT, - backend_version, FigureCanvasAgg, FigureCanvasQT -) + FigureCanvasAgg, FigureCanvasQT) @_BackendQTAgg.export diff --git a/lib/matplotlib/backends/backend_qt5cairo.py b/lib/matplotlib/backends/backend_qt5cairo.py index f7a38fabf07a..a4263f597119 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -2,8 +2,7 @@ backends._QT_FORCE_QT5_BINDING = True from .backend_qtcairo import ( # noqa: F401, E402 # pylint: disable=W0611 - _BackendQTCairo, FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT, - RendererCairo + _BackendQTCairo, FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT ) diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py index 0ccf32fccd1e..f64264d712f7 100644 --- a/lib/matplotlib/backends/backend_qtagg.py +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -6,20 +6,15 @@ from matplotlib.transforms import Bbox -from .qt_compat import QT_API, _enum, _setDevicePixelRatio -from .. import cbook +from .qt_compat import QT_API, _enum from .backend_agg import FigureCanvasAgg -from .backend_qt import ( - QtCore, QtGui, QtWidgets, _BackendQT, FigureCanvasQT, FigureManagerQT, - NavigationToolbar2QT, backend_version) +from .backend_qt import QtCore, QtGui, _BackendQT, FigureCanvasQT +from .backend_qt import ( # noqa: F401 # pylint: disable=W0611 + FigureManagerQT, NavigationToolbar2QT) class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): - def __init__(self, figure=None): - # Must pass 'figure' as kwarg to Qt base class. - super().__init__(figure=figure) - def paintEvent(self, event): """ Copy the image from the Agg canvas to the qt.drawable. @@ -52,30 +47,25 @@ def paintEvent(self, event): right = left + width # create a buffer using the image bounding box bbox = Bbox([[left, bottom], [right, top]]) - reg = self.copy_from_bbox(bbox) - buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( - memoryview(reg)) - - # clear the widget canvas - painter.eraseRect(rect) + buf = memoryview(self.copy_from_bbox(bbox)) if QT_API == "PyQt6": from PyQt6 import sip ptr = int(sip.voidptr(buf)) else: ptr = buf - qimage = QtGui.QImage( - ptr, buf.shape[1], buf.shape[0], - _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) - _setDevicePixelRatio(qimage, self.device_pixel_ratio) + + painter.eraseRect(rect) # clear the widget canvas + qimage = QtGui.QImage(ptr, buf.shape[1], buf.shape[0], + _enum("QtGui.QImage.Format").Format_RGBA8888) + qimage.setDevicePixelRatio(self.device_pixel_ratio) # set origin using original QT coordinates origin = QtCore.QPoint(rect.left(), rect.top()) painter.drawImage(origin, qimage) # Adjust the buf reference count to work around a memory # leak bug in QImage under PySide. - if QT_API in ('PySide', 'PySide2'): - if QtCore.__version_info__ < (5, 12): - ctypes.c_long.from_address(id(buf)).value = 1 + if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12): + ctypes.c_long.from_address(id(buf)).value = 1 self._draw_rect_callback(painter) finally: diff --git a/lib/matplotlib/backends/backend_qtcairo.py b/lib/matplotlib/backends/backend_qtcairo.py index 8d6def2d79e5..cca1be012f9e 100644 --- a/lib/matplotlib/backends/backend_qtcairo.py +++ b/lib/matplotlib/backends/backend_qtcairo.py @@ -1,16 +1,11 @@ import ctypes -from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo +from .backend_cairo import cairo, FigureCanvasCairo from .backend_qt import QtCore, QtGui, _BackendQT, FigureCanvasQT -from .qt_compat import QT_API, _enum, _setDevicePixelRatio +from .qt_compat import QT_API, _enum -class FigureCanvasQTCairo(FigureCanvasQT, FigureCanvasCairo): - def __init__(self, figure=None): - super().__init__(figure=figure) - self._renderer = RendererCairo(self.figure.dpi) - self._renderer.set_width_height(-1, -1) # Invalid values. - +class FigureCanvasQTCairo(FigureCanvasCairo, FigureCanvasQT): def draw(self): if hasattr(self._renderer.gc, "ctx"): self._renderer.dpi = self.figure.dpi @@ -22,8 +17,7 @@ def paintEvent(self, event): height = int(self.device_pixel_ratio * self.height()) if (width, height) != self._renderer.get_canvas_width_height(): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - self._renderer.set_ctx_from_surface(surface) - self._renderer.set_width_height(width, height) + self._renderer.set_context(cairo.Context(surface)) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) buf = self._renderer.gc.ctx.get_target().get_data() @@ -37,10 +31,9 @@ def paintEvent(self, event): _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied) # Adjust the buf reference count to work around a memory leak bug in # QImage under PySide. - if QT_API in ('PySide', 'PySide2'): - if QtCore.__version_info__ < (5, 12): - ctypes.c_long.from_address(id(buf)).value = 1 - _setDevicePixelRatio(qimage, self.device_pixel_ratio) + if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12): + ctypes.c_long.from_address(id(buf)).value = 1 + qimage.setDevicePixelRatio(self.device_pixel_ratio) painter = QtGui.QPainter(self) painter.eraseRect(event.rect()) painter.drawImage(0, 0, qimage) diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index e4de85905ca7..df39e620f888 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -3,7 +3,7 @@ import datetime import gzip import hashlib -from io import BytesIO, StringIO +from io import BytesIO import itertools import logging import os @@ -16,19 +16,17 @@ import matplotlib as mpl from matplotlib import _api, cbook, font_manager as fm from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, - RendererBase) + _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.colors import rgb2hex from matplotlib.dates import UTC -from matplotlib.mathtext import MathTextParser from matplotlib.path import Path from matplotlib import _path from matplotlib.transforms import Affine2D, Affine2DBase + _log = logging.getLogger(__name__) -backend_version = mpl.__version__ # ---------------------------------------------------------------------- # SimpleXMLWriter class @@ -68,7 +66,12 @@ # -------------------------------------------------------------------- +@_api.deprecated("3.6", alternative="a vendored copy of _escape_cdata") def escape_cdata(s): + return _escape_cdata(s) + + +def _escape_cdata(s): s = s.replace("&", "&") s = s.replace("<", "<") s = s.replace(">", ">") @@ -78,12 +81,22 @@ def escape_cdata(s): _escape_xml_comment = re.compile(r'-(?=-)') +@_api.deprecated("3.6", alternative="a vendored copy of _escape_comment") def escape_comment(s): - s = escape_cdata(s) + return _escape_comment.sub(s) + + +def _escape_comment(s): + s = _escape_cdata(s) return _escape_xml_comment.sub('- ', s) +@_api.deprecated("3.6", alternative="a vendored copy of _escape_attrib") def escape_attrib(s): + return _escape_attrib(s) + + +def _escape_attrib(s): s = s.replace("&", "&") s = s.replace("'", "'") s = s.replace('"', """) @@ -93,12 +106,17 @@ def escape_attrib(s): def _quote_escape_attrib(s): - return ('"' + escape_cdata(s) + '"' if '"' not in s else - "'" + escape_cdata(s) + "'" if "'" not in s else - '"' + escape_attrib(s) + '"') + return ('"' + _escape_cdata(s) + '"' if '"' not in s else + "'" + _escape_cdata(s) + "'" if "'" not in s else + '"' + _escape_attrib(s) + '"') +@_api.deprecated("3.6", alternative="a vendored copy of _short_float_fmt") def short_float_fmt(x): + return _short_float_fmt(x) + + +def _short_float_fmt(x): """ Create a short string representation of a float, which is %f formatting with trailing zeros and the decimal point removed. @@ -132,7 +150,7 @@ def __flush(self, indent=True): self.__open = 0 if self.__data: data = ''.join(self.__data) - self.__write(escape_cdata(data)) + self.__write(_escape_cdata(data)) self.__data = [] def start(self, tag, attrib={}, **extra): @@ -155,14 +173,14 @@ def start(self, tag, attrib={}, **extra): An element identifier. """ self.__flush() - tag = escape_cdata(tag) + tag = _escape_cdata(tag) self.__data = [] self.__tags.append(tag) self.__write(self.__indentation[:len(self.__tags) - 1]) self.__write("<%s" % tag) for k, v in {**attrib, **extra}.items(): if v: - k = escape_cdata(k) + k = _escape_cdata(k) v = _quote_escape_attrib(v) self.__write(' %s=%s' % (k, v)) self.__open = 1 @@ -179,7 +197,7 @@ def comment(self, comment): """ self.__flush() self.__write(self.__indentation[:len(self.__tags)]) - self.__write("\n" % escape_comment(comment)) + self.__write("\n" % _escape_comment(comment)) def data(self, text): """ @@ -205,7 +223,7 @@ def end(self, tag=None, indent=True): """ if tag: assert self.__tags, "unbalanced end(%s)" % tag - assert escape_cdata(tag) == self.__tags[-1], \ + assert _escape_cdata(tag) == self.__tags[-1], \ "expected end(%s), got %s" % (self.__tags[-1], tag) else: assert self.__tags, "unbalanced end()" @@ -249,29 +267,54 @@ def flush(self): pass # replaced by the constructor -def generate_transform(transform_list=[]): - if len(transform_list): - output = StringIO() - for type, value in transform_list: - if (type == 'scale' and (value == (1,) or value == (1, 1)) - or type == 'translate' and value == (0, 0) - or type == 'rotate' and value == (0,)): - continue - if type == 'matrix' and isinstance(value, Affine2DBase): - value = value.to_values() - output.write('%s(%s)' % ( - type, ' '.join(short_float_fmt(x) for x in value))) - return output.getvalue() - return '' +def _generate_transform(transform_list): + parts = [] + for type, value in transform_list: + if (type == 'scale' and (value == (1,) or value == (1, 1)) + or type == 'translate' and value == (0, 0) + or type == 'rotate' and value == (0,)): + continue + if type == 'matrix' and isinstance(value, Affine2DBase): + value = value.to_values() + parts.append('%s(%s)' % ( + type, ' '.join(_short_float_fmt(x) for x in value))) + return ' '.join(parts) + + +@_api.deprecated("3.6") +def generate_transform(transform_list=None): + return _generate_transform(transform_list or []) -def generate_css(attrib={}): +def _generate_css(attrib): return "; ".join(f"{k}: {v}" for k, v in attrib.items()) +@_api.deprecated("3.6") +def generate_css(attrib=None): + return _generate_css(attrib or {}) + + _capstyle_d = {'projecting': 'square', 'butt': 'butt', 'round': 'round'} +def _check_is_str(info, key): + if not isinstance(info, str): + raise TypeError(f'Invalid type for {key} metadata. Expected str, not ' + f'{type(info)}.') + + +def _check_is_iterable_of_str(infos, key): + if np.iterable(infos): + for info in infos: + if not isinstance(info, str): + raise TypeError(f'Invalid type for {key} metadata. Expected ' + f'iterable of str, not {type(info)}.') + else: + raise TypeError(f'Invalid type for {key} metadata. Expected str or ' + f'iterable of str, not {type(infos)}.') + + class RendererSVG(RendererBase): def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, *, metadata=None): @@ -297,8 +340,8 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, super().__init__() self._glyph_map = dict() - str_height = short_float_fmt(height) - str_width = short_float_fmt(width) + str_height = _short_float_fmt(height) + str_width = _short_float_fmt(width) svgwriter.write(svgProlog) self._start_id = self.writer.start( 'svg', @@ -311,11 +354,6 @@ def __init__(self, width, height, svgwriter, basename=None, image_dpi=72, self._write_metadata(metadata) self._write_default_style() - @_api.deprecated("3.4") - @property - def mathtext_parser(self): - return MathTextParser('SVG') - def finalize(self): self._write_clips() self._write_hatches() @@ -338,7 +376,9 @@ def _write_metadata(self, metadata): writer = self.writer if 'Title' in metadata: - writer.element('title', text=metadata['Title']) + title = metadata['Title'] + _check_is_str(title, 'Title') + writer.element('title', text=title) # Special handling. date = metadata.get('Date', None) @@ -355,14 +395,14 @@ def _write_metadata(self, metadata): elif isinstance(d, (datetime.datetime, datetime.date)): dates.append(d.isoformat()) else: - raise ValueError( - 'Invalid type for Date metadata. ' - 'Expected iterable of str, date, or datetime, ' - 'not {!r}.'.format(type(d))) + raise TypeError( + f'Invalid type for Date metadata. ' + f'Expected iterable of str, date, or datetime, ' + f'not {type(d)}.') else: - raise ValueError('Invalid type for Date metadata. ' - 'Expected str, date, datetime, or iterable ' - 'of the same, not {!r}.'.format(type(date))) + raise TypeError(f'Invalid type for Date metadata. ' + f'Expected str, date, datetime, or iterable ' + f'of the same, not {type(date)}.') metadata['Date'] = '/'.join(dates) elif 'Date' not in metadata: # Do not add `Date` if the user explicitly set `Date` to `None` @@ -394,36 +434,40 @@ def ensure_metadata(mid): writer.element('dc:type', attrib={'rdf:resource': uri}) # Single value only. - for key in ['title', 'coverage', 'date', 'description', 'format', - 'identifier', 'language', 'relation', 'source']: - info = metadata.pop(key.title(), None) + for key in ['Title', 'Coverage', 'Date', 'Description', 'Format', + 'Identifier', 'Language', 'Relation', 'Source']: + info = metadata.pop(key, None) if info is not None: mid = ensure_metadata(mid) - writer.element(f'dc:{key}', text=info) + _check_is_str(info, key) + writer.element(f'dc:{key.lower()}', text=info) # Multiple Agent values. - for key in ['creator', 'contributor', 'publisher', 'rights']: - agents = metadata.pop(key.title(), None) + for key in ['Creator', 'Contributor', 'Publisher', 'Rights']: + agents = metadata.pop(key, None) if agents is None: continue if isinstance(agents, str): agents = [agents] + _check_is_iterable_of_str(agents, key) + # Now we know that we have an iterable of str mid = ensure_metadata(mid) - writer.start(f'dc:{key}') + writer.start(f'dc:{key.lower()}') for agent in agents: writer.start('cc:Agent') writer.element('dc:title', text=agent) writer.end('cc:Agent') - writer.end(f'dc:{key}') + writer.end(f'dc:{key.lower()}') # Multiple values. keywords = metadata.pop('Keywords', None) if keywords is not None: if isinstance(keywords, str): keywords = [keywords] - + _check_is_iterable_of_str(keywords, 'Keywords') + # Now we know that we have an iterable of str mid = ensure_metadata(mid) writer.start('dc:subject') writer.start('rdf:Bag') @@ -441,7 +485,7 @@ def ensure_metadata(mid): def _write_default_style(self): writer = self.writer - default_style = generate_css({ + default_style = _generate_css({ 'stroke-linejoin': 'round', 'stroke-linecap': 'butt'}) writer.start('defs') @@ -458,18 +502,7 @@ def _make_id(self, type, content): return '%s%s' % (type, m.hexdigest()[:10]) def _make_flip_transform(self, transform): - return (transform + - Affine2D() - .scale(1.0, -1.0) - .translate(0.0, self.height)) - - def _get_font(self, prop): - fname = fm.findfont(prop) - font = fm.get_font(fname) - font.clear() - size = prop.get_size_in_points() - font.set_size(size, 72.0) - return font + return transform + Affine2D().scale(1, -1).translate(0, self.height) def _get_hatch(self, gc, rgbFace): """ @@ -528,7 +561,7 @@ def _write_hatches(self): writer.element( 'path', d=path_data, - style=generate_css(hatch_style) + style=_generate_css(hatch_style) ) writer.end('pattern') writer.end('defs') @@ -543,7 +576,7 @@ def _get_style_dict(self, gc, rgbFace): attrib['fill'] = "url(#%s)" % self._get_hatch(gc, rgbFace) if (rgbFace is not None and len(rgbFace) == 4 and rgbFace[3] != 1.0 and not forced_alpha): - attrib['fill-opacity'] = short_float_fmt(rgbFace[3]) + attrib['fill-opacity'] = _short_float_fmt(rgbFace[3]) else: if rgbFace is None: attrib['fill'] = 'none' @@ -552,25 +585,25 @@ def _get_style_dict(self, gc, rgbFace): attrib['fill'] = rgb2hex(rgbFace) if (len(rgbFace) == 4 and rgbFace[3] != 1.0 and not forced_alpha): - attrib['fill-opacity'] = short_float_fmt(rgbFace[3]) + attrib['fill-opacity'] = _short_float_fmt(rgbFace[3]) if forced_alpha and gc.get_alpha() != 1.0: - attrib['opacity'] = short_float_fmt(gc.get_alpha()) + attrib['opacity'] = _short_float_fmt(gc.get_alpha()) offset, seq = gc.get_dashes() if seq is not None: attrib['stroke-dasharray'] = ','.join( - short_float_fmt(val) for val in seq) - attrib['stroke-dashoffset'] = short_float_fmt(float(offset)) + _short_float_fmt(val) for val in seq) + attrib['stroke-dashoffset'] = _short_float_fmt(float(offset)) linewidth = gc.get_linewidth() if linewidth: rgb = gc.get_rgb() attrib['stroke'] = rgb2hex(rgb) if not forced_alpha and rgb[3] != 1.0: - attrib['stroke-opacity'] = short_float_fmt(rgb[3]) + attrib['stroke-opacity'] = _short_float_fmt(rgb[3]) if linewidth != 1.0: - attrib['stroke-width'] = short_float_fmt(linewidth) + attrib['stroke-width'] = _short_float_fmt(linewidth) if gc.get_joinstyle() != 'round': attrib['stroke-linejoin'] = gc.get_joinstyle() if gc.get_capstyle() != 'butt': @@ -579,7 +612,7 @@ def _get_style_dict(self, gc, rgbFace): return attrib def _get_style(self, gc, rgbFace): - return generate_css(self._get_style_dict(gc, rgbFace)) + return _generate_css(self._get_style_dict(gc, rgbFace)) def _get_clip_attrs(self, gc): cliprect = gc.get_clip_rectangle() @@ -620,10 +653,10 @@ def _write_clips(self): x, y, w, h = clip writer.element( 'rect', - x=short_float_fmt(x), - y=short_float_fmt(y), - width=short_float_fmt(w), - height=short_float_fmt(h)) + x=_short_float_fmt(x), + y=_short_float_fmt(y), + width=_short_float_fmt(w), + height=_short_float_fmt(h)) writer.end('clipPath') writer.end('defs') @@ -682,9 +715,9 @@ def draw_markers( marker_trans + Affine2D().scale(1.0, -1.0), simplify=False) style = self._get_style_dict(gc, rgbFace) - dictkey = (path_data, generate_css(style)) + dictkey = (path_data, _generate_css(style)) oid = self._markers.get(dictkey) - style = generate_css({k: v for k, v in style.items() + style = _generate_css({k: v for k, v in style.items() if k.startswith('stroke')}) if oid is None: @@ -702,14 +735,14 @@ def draw_markers( trans_and_flip, clip=clip, simplify=False): if len(vertices): x, y = vertices[-2:] - attrib['x'] = short_float_fmt(x) - attrib['y'] = short_float_fmt(y) + attrib['x'] = _short_float_fmt(x) + attrib['y'] = _short_float_fmt(y) attrib['style'] = self._get_style(gc, rgbFace) writer.element('use', attrib=attrib) writer.end('g') def draw_path_collection(self, gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): # Is the optimization worth it? Rough calculation: @@ -725,7 +758,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, if not should_do_optimization: return super().draw_path_collection( gc, master_transform, paths, all_transforms, - offsets, offsetTrans, facecolors, edgecolors, + offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position) @@ -743,8 +776,8 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, writer.end('defs') for xo, yo, path_id, gc0, rgbFace in self._iter_collection( - gc, master_transform, all_transforms, path_codes, offsets, - offsetTrans, facecolors, edgecolors, linewidths, linestyles, + gc, path_codes, offsets, offset_trans, + facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position): url = gc0.get_url() if url is not None: @@ -754,8 +787,8 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, writer.start('g', **clip_attrs) attrib = { 'xlink:href': '#%s' % path_id, - 'x': short_float_fmt(xo), - 'y': short_float_fmt(self.height - yo), + 'x': _short_float_fmt(xo), + 'y': _short_float_fmt(self.height - yo), 'style': self._get_style(gc0, rgbFace) } writer.element('use', attrib=attrib) @@ -768,7 +801,9 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, def draw_gouraud_triangle(self, gc, points, colors, trans): # docstring inherited + self._draw_gouraud_triangle(gc, points, colors, trans) + def _draw_gouraud_triangle(self, gc, points, colors, trans): # This uses a method described here: # # http://www.svgopen.org/2005/papers/Converting3DFaceToSVG/index.html @@ -837,18 +872,18 @@ def draw_gouraud_triangle(self, gc, points, colors, trans): 'linearGradient', id="GR%x_%d" % (self._n_gradients, i), gradientUnits="userSpaceOnUse", - x1=short_float_fmt(x1), y1=short_float_fmt(y1), - x2=short_float_fmt(xb), y2=short_float_fmt(yb)) + x1=_short_float_fmt(x1), y1=_short_float_fmt(y1), + x2=_short_float_fmt(xb), y2=_short_float_fmt(yb)) writer.element( 'stop', offset='1', - style=generate_css({ + style=_generate_css({ 'stop-color': rgb2hex(avg_color), - 'stop-opacity': short_float_fmt(rgba_color[-1])})) + 'stop-opacity': _short_float_fmt(rgba_color[-1])})) writer.element( 'stop', offset='0', - style=generate_css({'stop-color': rgb2hex(rgba_color), + style=_generate_css({'stop-color': rgb2hex(rgba_color), 'stop-opacity': "0"})) writer.end('linearGradient') @@ -856,9 +891,9 @@ def draw_gouraud_triangle(self, gc, points, colors, trans): writer.end('defs') # triangle formation using "path" - dpath = "M " + short_float_fmt(x1)+',' + short_float_fmt(y1) - dpath += " L " + short_float_fmt(x2) + ',' + short_float_fmt(y2) - dpath += " " + short_float_fmt(x3) + ',' + short_float_fmt(y3) + " Z" + dpath = "M " + _short_float_fmt(x1)+',' + _short_float_fmt(y1) + dpath += " L " + _short_float_fmt(x2) + ',' + _short_float_fmt(y2) + dpath += " " + _short_float_fmt(x3) + ',' + _short_float_fmt(y3) + " Z" writer.element( 'path', @@ -903,7 +938,7 @@ def draw_gouraud_triangles(self, gc, triangles_array, colors_array, self.writer.start('g', **self._get_clip_attrs(gc)) transform = transform.frozen() for tri, col in zip(triangles_array, colors_array): - self.draw_gouraud_triangle(gc, tri, col, transform) + self._draw_gouraud_triangle(gc, tri, col, transform) self.writer.end('g') def option_scale_image(self): @@ -958,16 +993,16 @@ def draw_image(self, gc, x, y, im, transform=None): self.writer.element( 'image', - transform=generate_transform([ + transform=_generate_transform([ ('scale', (1, -1)), ('translate', (0, -h))]), - x=short_float_fmt(x), - y=short_float_fmt(-(self.height - y - h)), - width=short_float_fmt(w), height=short_float_fmt(h), + x=_short_float_fmt(x), + y=_short_float_fmt(-(self.height - y - h)), + width=_short_float_fmt(w), height=_short_float_fmt(h), attrib=attrib) else: alpha = gc.get_alpha() if alpha != 1.0: - attrib['opacity'] = short_float_fmt(alpha) + attrib['opacity'] = _short_float_fmt(alpha) flipped = ( Affine2D().scale(1.0 / w, 1.0 / h) + @@ -977,14 +1012,14 @@ def draw_image(self, gc, x, y, im, transform=None): .scale(1.0, -1.0) .translate(0.0, self.height)) - attrib['transform'] = generate_transform( + attrib['transform'] = _generate_transform( [('matrix', flipped.frozen())]) attrib['style'] = ( 'image-rendering:crisp-edges;' 'image-rendering:pixelated') self.writer.element( 'image', - width=short_float_fmt(w), height=short_float_fmt(h), + width=_short_float_fmt(w), height=_short_float_fmt(h), attrib=attrib) if url is not None: @@ -1007,7 +1042,7 @@ def _update_glyph_map_defs(self, glyph_map_new): Path(vertices * 64, codes), simplify=False) writer.element( 'path', id=char_id, d=path_data, - transform=generate_transform([('scale', (1 / 64,))])) + transform=_generate_transform([('scale', (1 / 64,))])) writer.end('defs') self._glyph_map.update(glyph_map_new) @@ -1015,18 +1050,7 @@ def _adjust_char_id(self, char_id): return char_id.replace("%20", "_") def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): - """ - Draw the text by converting them to paths using the textpath module. - - Parameters - ---------- - s : str - text to be converted - prop : `matplotlib.font_manager.FontProperties` - font property - ismath : bool - If True, use mathtext parser. If "TeX", use *usetex* mode. - """ + # docstring inherited writer = self.writer writer.comment(s) @@ -1042,11 +1066,11 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): style['fill'] = color alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3] if alpha != 1: - style['opacity'] = short_float_fmt(alpha) + style['opacity'] = _short_float_fmt(alpha) font_scale = fontsize / text2path.FONT_SCALE attrib = { - 'style': generate_css(style), - 'transform': generate_transform([ + 'style': _generate_css(style), + 'transform': _generate_transform([ ('translate', (x, y)), ('rotate', (-angle,)), ('scale', (font_scale, -font_scale))]), @@ -1063,9 +1087,9 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): for glyph_id, xposition, yposition, scale in glyph_info: attrib = {'xlink:href': '#%s' % glyph_id} if xposition != 0.0: - attrib['x'] = short_float_fmt(xposition) + attrib['x'] = _short_float_fmt(xposition) if yposition != 0.0: - attrib['y'] = short_float_fmt(yposition) + attrib['y'] = _short_float_fmt(yposition) writer.element('use', attrib=attrib) else: @@ -1082,7 +1106,7 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): char_id = self._adjust_char_id(char_id) writer.element( 'use', - transform=generate_transform([ + transform=_generate_transform([ ('translate', (xposition, yposition)), ('scale', (scale,)), ]), @@ -1105,7 +1129,7 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): alpha = gc.get_alpha() if gc.get_forced_alpha() else gc.get_rgb()[3] if alpha != 1: - style['opacity'] = short_float_fmt(alpha) + style['opacity'] = _short_float_fmt(alpha) if not ismath: attrib = {} @@ -1118,14 +1142,37 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): weight = fm.weight_dict[prop.get_weight()] if weight != 400: font_parts.append(f'{weight}') + + def _normalize_sans(name): + return 'sans-serif' if name in ['sans', 'sans serif'] else name + + def _expand_family_entry(fn): + fn = _normalize_sans(fn) + # prepend generic font families with all configured font names + if fn in fm.font_family_aliases: + # get all of the font names and fix spelling of sans-serif + # (we accept 3 ways CSS only supports 1) + for name in fm.FontManager._expand_aliases(fn): + yield _normalize_sans(name) + # whether a generic name or a family name, it must appear at + # least once + yield fn + + def _get_all_quoted_names(prop): + # only quote specific names, not generic names + return [name if name in fm.font_family_aliases else repr(name) + for entry in prop.get_family() + for name in _expand_family_entry(entry)] + font_parts.extend([ - f'{short_float_fmt(prop.get_size())}px', - f'{prop.get_family()[0]!r}', # ensure quoting + f'{_short_float_fmt(prop.get_size())}px', + # ensure expansion, quoting, and dedupe of font names + ", ".join(dict.fromkeys(_get_all_quoted_names(prop))) ]) style['font'] = ' '.join(font_parts) if prop.get_stretch() != 'normal': style['font-stretch'] = prop.get_stretch() - attrib['style'] = generate_css(style) + attrib['style'] = _generate_css(style) if mtext and (angle == 0 or mtext.get_rotation_mode() == "anchor"): # If text anchoring can be supported, get the original @@ -1149,20 +1196,18 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): 'center': 'middle'} style['text-anchor'] = ha_mpl_to_svg[mtext.get_ha()] - attrib['x'] = short_float_fmt(ax) - attrib['y'] = short_float_fmt(ay) - attrib['style'] = generate_css(style) - attrib['transform'] = "rotate(%s, %s, %s)" % ( - short_float_fmt(-angle), - short_float_fmt(ax), - short_float_fmt(ay)) - writer.element('text', s, attrib=attrib) + attrib['x'] = _short_float_fmt(ax) + attrib['y'] = _short_float_fmt(ay) + attrib['style'] = _generate_css(style) + attrib['transform'] = _generate_transform([ + ("rotate", (-angle, ax, ay))]) + else: - attrib['transform'] = generate_transform([ + attrib['transform'] = _generate_transform([ ('translate', (x, y)), ('rotate', (-angle,))]) - writer.element('text', s, attrib=attrib) + writer.element('text', s, attrib=attrib) else: writer.comment(s) @@ -1173,8 +1218,8 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): # Apply attributes to 'g', not 'text', because we likely have some # rectangles as well with the same style and transformation. writer.start('g', - style=generate_css(style), - transform=generate_transform([ + style=_generate_css(style), + transform=_generate_transform([ ('translate', (x, y)), ('rotate', (-angle,))]), ) @@ -1193,13 +1238,13 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): if entry.weight != 400: font_parts.append(f'{entry.weight}') font_parts.extend([ - f'{short_float_fmt(fontsize)}px', + f'{_short_float_fmt(fontsize)}px', f'{entry.name!r}', # ensure quoting ]) style = {'font': ' '.join(font_parts)} if entry.stretch != 'normal': style['font-stretch'] = entry.stretch - style = generate_css(style) + style = _generate_css(style) if thetext == 32: thetext = 0xa0 # non-breaking space spans.setdefault(style, []).append((new_x, -new_y, thetext)) @@ -1214,7 +1259,7 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): attrib = { 'style': style, - 'x': ' '.join(short_float_fmt(c[0]) for c in chars), + 'x': ' '.join(_short_float_fmt(c[0]) for c in chars), 'y': ys } @@ -1228,18 +1273,14 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): for x, y, width, height in rects: writer.element( 'rect', - x=short_float_fmt(x), - y=short_float_fmt(-y-1), - width=short_float_fmt(width), - height=short_float_fmt(height) + x=_short_float_fmt(x), + y=_short_float_fmt(-y-1), + width=_short_float_fmt(width), + height=_short_float_fmt(height) ) writer.end('g') - def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): - # docstring inherited - self._draw_text_as_path(gc, x, y, s, prop, angle, ismath="TeX") - def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # docstring inherited @@ -1282,11 +1323,7 @@ class FigureCanvasSVG(FigureCanvasBase): fixed_dpi = 72 - @_check_savefig_extra_args - @_api.delete_parameter("3.4", "dpi") - @_api.delete_parameter("3.5", "args") - def print_svg(self, filename, *args, dpi=None, bbox_inches_restore=None, - metadata=None): + def print_svg(self, filename, *, bbox_inches_restore=None, metadata=None): """ Parameters ---------- @@ -1321,9 +1358,8 @@ def print_svg(self, filename, *args, dpi=None, bbox_inches_restore=None, with cbook.open_file_cm(filename, "w", encoding="utf-8") as fh: if not cbook.file_requires_unicode(fh): fh = codecs.getwriter('utf-8')(fh) - if dpi is None: # always use this branch after deprecation elapses - dpi = self.figure.get_dpi() - self.figure.set_dpi(72) + dpi = self.figure.dpi + self.figure.dpi = 72 width, height = self.figure.get_size_inches() w, h = width * 72, height * 72 renderer = MixedModeRenderer( @@ -1333,8 +1369,7 @@ def print_svg(self, filename, *args, dpi=None, bbox_inches_restore=None, self.figure.draw(renderer) renderer.finalize() - @_api.delete_parameter("3.5", "args") - def print_svgz(self, filename, *args, **kwargs): + def print_svgz(self, filename, **kwargs): with cbook.open_file_cm(filename, "wb") as fh, \ gzip.GzipFile(mode='w', fileobj=fh) as gzipwriter: return self.print_svg(gzipwriter, **kwargs) @@ -1359,4 +1394,5 @@ def draw(self): @_Backend.export class _BackendSVG(_Backend): + backend_version = mpl.__version__ FigureCanvas = FigureCanvasSVG diff --git a/lib/matplotlib/backends/backend_template.py b/lib/matplotlib/backends/backend_template.py index 8d17f7d4e381..915cdeb210bb 100644 --- a/lib/matplotlib/backends/backend_template.py +++ b/lib/matplotlib/backends/backend_template.py @@ -10,9 +10,9 @@ produced. This provides a starting point for backend writers; you can selectively implement drawing methods (`~.RendererTemplate.draw_path`, `~.RendererTemplate.draw_image`, etc.) and slowly see your figure come to life -instead having to have a full blown implementation before getting any results. +instead having to have a full-blown implementation before getting any results. -Copy this file to a directory outside of the Matplotlib source tree, somewhere +Copy this file to a directory outside the Matplotlib source tree, somewhere where Python can import it (by adding the directory to your ``sys.path`` or by packaging it as a normal Python package); if the backend is importable as ``import my.backend`` you can then select it using :: @@ -41,7 +41,7 @@ class RendererTemplate(RendererBase): The renderer handles drawing/rendering operations. This is a minimal do-nothing class that can be used to get started when - writing a new backend. Refer to `backend_bases.RendererBase` for + writing a new backend. Refer to `.backend_bases.RendererBase` for documentation of the methods. """ @@ -63,7 +63,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): # relative timings by leaving it out. backend implementers concerned with # performance will probably want to implement it # def draw_path_collection(self, gc, master_transform, paths, -# all_transforms, offsets, offsetTrans, +# all_transforms, offsets, offset_trans, # facecolors, edgecolors, linewidths, linestyles, # antialiaseds): # pass @@ -108,7 +108,7 @@ def points_to_pixels(self, points): class GraphicsContextTemplate(GraphicsContextBase): """ - The graphics context provides the color, line styles, etc... See the cairo + The graphics context provides the color, line styles, etc. See the cairo and postscript backends for examples of mapping the graphics context attributes (cap styles, join styles, line widths, colors) to a particular backend. In cairo this is done by wrapping a cairo.Context object and @@ -122,7 +122,7 @@ class GraphicsContextTemplate(GraphicsContextBase): do the mapping here, you'll need to override several of the setter methods. - The base GraphicsContext stores colors as a RGB tuple on the unit + The base GraphicsContext stores colors as an RGB tuple on the unit interval, e.g., (0.5, 0.0, 1.0). You may need to map this to colors appropriate for your backend. """ @@ -131,47 +131,19 @@ class GraphicsContextTemplate(GraphicsContextBase): ######################################################################## # # The following functions and classes are for pyplot and implement -# window/figure managers, etc... +# window/figure managers, etc. # ######################################################################## -def draw_if_interactive(): - """ - For image backends - is not required. - For GUI backends - this should be overridden if drawing should be done in - interactive python mode. +class FigureManagerTemplate(FigureManagerBase): """ + Helper class for pyplot mode, wraps everything up into a neat bundle. - -def show(*, block=None): - """ - For image backends - is not required. - For GUI backends - show() is usually the last line of a pyplot script and - tells the backend that it is time to draw. In interactive mode, this - should do nothing. + For non-interactive backends, the base class is sufficient. For + interactive backends, see the documentation of the `.FigureManagerBase` + class for the list of methods that can/should be overridden. """ - for manager in Gcf.get_all_fig_managers(): - # do something to display the GUI - pass - - -def new_figure_manager(num, *args, FigureClass=Figure, **kwargs): - """Create a new figure manager instance.""" - # If a main-level app must be created, this (and - # new_figure_manager_given_figure) is the usual place to do it -- see - # backend_wx, backend_wxagg and backend_tkagg for examples. Not all GUIs - # require explicit instantiation of a main-level app (e.g., backend_gtk3) - # for pylab. - thisFig = FigureClass(*args, **kwargs) - return new_figure_manager_given_figure(num, thisFig) - - -def new_figure_manager_given_figure(num, figure): - """Create a new figure manager instance for the given figure.""" - canvas = FigureCanvasTemplate(figure) - manager = FigureManagerTemplate(canvas, num) - return manager class FigureCanvasTemplate(FigureCanvasBase): @@ -191,6 +163,11 @@ class methods button_press_event, button_release_event, A high-level Figure instance """ + # The instantiated manager class. For further customization, + # ``FigureManager.create_with_canvas`` can also be overridden; see the + # wx-based backends for an example. + manager_class = FigureManagerTemplate + def draw(self): """ Draw the figure using the renderer. @@ -210,8 +187,7 @@ def draw(self): # you should add it to the class-scope filetypes dictionary as follows: filetypes = {**FigureCanvasBase.filetypes, 'foo': 'My magic Foo format'} - @_api.delete_parameter("3.5", "args") - def print_foo(self, filename, *args, **kwargs): + def print_foo(self, filename, **kwargs): """ Write out format foo. @@ -227,14 +203,6 @@ def get_default_filetype(self): return 'foo' -class FigureManagerTemplate(FigureManagerBase): - """ - Helper class for pyplot mode, wraps everything up into a neat bundle. - - For non-interactive backends, the base class is sufficient. - """ - - ######################################################################## # # Now just provide the standard names that backend.__init__ is expecting diff --git a/lib/matplotlib/backends/backend_tkagg.py b/lib/matplotlib/backends/backend_tkagg.py index 31012d80eab8..f95b6011eadf 100644 --- a/lib/matplotlib/backends/backend_tkagg.py +++ b/lib/matplotlib/backends/backend_tkagg.py @@ -1,7 +1,8 @@ from . import _backend_tk from .backend_agg import FigureCanvasAgg -from ._backend_tk import ( - _BackendTk, FigureCanvasTk, FigureManagerTk, NavigationToolbar2Tk) +from ._backend_tk import _BackendTk, FigureCanvasTk +from ._backend_tk import ( # noqa: F401 # pylint: disable=W0611 + FigureManagerTk, NavigationToolbar2Tk) class FigureCanvasTkAgg(FigureCanvasAgg, FigureCanvasTk): diff --git a/lib/matplotlib/backends/backend_tkcairo.py b/lib/matplotlib/backends/backend_tkcairo.py index b4099db24828..a6951c03c65a 100644 --- a/lib/matplotlib/backends/backend_tkcairo.py +++ b/lib/matplotlib/backends/backend_tkcairo.py @@ -3,21 +3,16 @@ import numpy as np from . import _backend_tk -from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo +from .backend_cairo import cairo, FigureCanvasCairo from ._backend_tk import _BackendTk, FigureCanvasTk class FigureCanvasTkCairo(FigureCanvasCairo, FigureCanvasTk): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._renderer = RendererCairo(self.figure.dpi) - def draw(self): width = int(self.figure.bbox.width) height = int(self.figure.bbox.height) surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - self._renderer.set_ctx_from_surface(surface) - self._renderer.set_width_height(width, height) + self._renderer.set_context(cairo.Context(surface)) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) buf = np.reshape(surface.get_data(), (height, width, 4)) diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index 37bf68a76c87..17c12c0a2f8e 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -36,19 +36,44 @@ from matplotlib.backend_bases import _Backend from matplotlib._pylab_helpers import Gcf from . import backend_webagg_core as core -from .backend_webagg_core import TimerAsyncio, TimerTornado +from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611 + TimerAsyncio, TimerTornado) +@mpl._api.deprecated("3.7") class ServerThread(threading.Thread): def run(self): tornado.ioloop.IOLoop.instance().start() -webagg_server_thread = ServerThread() +webagg_server_thread = threading.Thread( + target=lambda: tornado.ioloop.IOLoop.instance().start()) + + +class FigureManagerWebAgg(core.FigureManagerWebAgg): + _toolbar2_class = core.NavigationToolbar2WebAgg + + @classmethod + def pyplot_show(cls, *, block=None): + WebAggApplication.initialize() + + url = "http://{address}:{port}{prefix}".format( + address=WebAggApplication.address, + port=WebAggApplication.port, + prefix=WebAggApplication.url_prefix) + + if mpl.rcParams['webagg.open_in_browser']: + import webbrowser + if not webbrowser.open(url): + print("To view figure, visit {0}".format(url)) + else: + print("To view figure, visit {0}".format(url)) + + WebAggApplication.start() class FigureCanvasWebAgg(core.FigureCanvasWebAggCore): - pass + manager_class = FigureManagerWebAgg class WebAggApplication(tornado.web.Application): @@ -240,6 +265,14 @@ def random_ports(port, n): @classmethod def start(cls): + import asyncio + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + cls.started = True + if cls.started: return @@ -281,8 +314,12 @@ def ipython_inline_display(figure): import tornado.template WebAggApplication.initialize() - if not webagg_server_thread.is_alive(): - webagg_server_thread.start() + import asyncio + try: + asyncio.get_running_loop() + except RuntimeError: + if not webagg_server_thread.is_alive(): + webagg_server_thread.start() fignum = figure.number tpl = Path(core.FigureManagerWebAgg.get_static_file_path(), @@ -299,22 +336,4 @@ def ipython_inline_display(figure): @_Backend.export class _BackendWebAgg(_Backend): FigureCanvas = FigureCanvasWebAgg - FigureManager = core.FigureManagerWebAgg - - @staticmethod - def show(*, block=None): - WebAggApplication.initialize() - - url = "http://{address}:{port}{prefix}".format( - address=WebAggApplication.address, - port=WebAggApplication.port, - prefix=WebAggApplication.url_prefix) - - if mpl.rcParams['webagg.open_in_browser']: - import webbrowser - if not webbrowser.open(url): - print("To view figure, visit {0}".format(url)) - else: - print("To view figure, visit {0}".format(url)) - - WebAggApplication.start() + FigureManager = FigureManagerWebAgg diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index 06c6097bcacc..57cfa311b8f7 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -23,7 +23,8 @@ from matplotlib import _api, backend_bases, backend_tools from matplotlib.backends import backend_agg -from matplotlib.backend_bases import _Backend +from matplotlib.backend_bases import ( + _Backend, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) _log = logging.getLogger(__name__) @@ -154,6 +155,7 @@ def _timer_set_interval(self): class FigureCanvasWebAggCore(backend_agg.FigureCanvasAgg): + manager_class = _api.classproperty(lambda cls: FigureManagerWebAgg) _timer_cls = TimerAsyncio # Webagg and friends having the right methods, but still # having bugs in practice. Do not advertise that it works until @@ -162,20 +164,21 @@ class FigureCanvasWebAggCore(backend_agg.FigureCanvasAgg): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Set to True when the renderer contains data that is newer # than the PNG buffer. self._png_is_old = True - # Set to True by the `refresh` message so that the next frame # sent to the clients will be a full frame. self._force_full = True - + # The last buffer, for diff mode. + self._last_buff = np.empty((0, 0)) # Store the current image mode so that at any point, clients can # request the information. This should be changed by calling # self.set_image_mode(mode) so that the notification can be given # to the connected clients. self._current_image_mode = 'full' + # Track mouse events to fill in the x, y position of key events. + self._last_mouse_xy = (None, None) def show(self): # show the figure window @@ -227,17 +230,18 @@ def get_diff_image(self): if self._png_is_old: renderer = self.get_renderer() + pixels = np.asarray(renderer.buffer_rgba()) # The buffer is created as type uint32 so that entire # pixels can be compared in one numpy call, rather than # needing to compare each plane separately. - buff = (np.frombuffer(renderer.buffer_rgba(), dtype=np.uint32) - .reshape((renderer.height, renderer.width))) - - # If any pixels have transparency, we need to force a full - # draw as we cannot overlay new on top of old. - pixels = buff.view(dtype=np.uint8).reshape(buff.shape + (4,)) - - if self._force_full or np.any(pixels[:, :, 3] != 255): + buff = pixels.view(np.uint32).squeeze(2) + + if (self._force_full + # If the buffer has changed size we need to do a full draw. + or buff.shape != self._last_buff.shape + # If any pixels have transparency, we need to force a full + # draw as we cannot overlay new on top of old. + or (pixels[:, :, 3] != 255).any()): self.set_image_mode('full') output = buff else: @@ -246,7 +250,7 @@ def get_diff_image(self): output = np.where(diff, buff, 0) # Store the current buffer so we can compute the next diff. - np.copyto(self._last_buff, buff) + self._last_buff = buff.copy() self._force_full = False self._png_is_old = False @@ -255,31 +259,6 @@ def get_diff_image(self): Image.fromarray(data).save(png, format="png") return png.getvalue() - def get_renderer(self, cleared=None): - # Mirrors super.get_renderer, but caches the old one so that we can do - # things such as produce a diff image in get_diff_image. - w, h = self.figure.bbox.size.astype(int) - key = w, h, self.figure.dpi - try: - self._lastKey, self._renderer - except AttributeError: - need_new_renderer = True - else: - need_new_renderer = (self._lastKey != key) - - if need_new_renderer: - self._renderer = backend_agg.RendererAgg( - w, h, self.figure.dpi) - self._lastKey = key - self._last_buff = np.copy(np.frombuffer( - self._renderer.buffer_rgba(), dtype=np.uint32 - ).reshape((self._renderer.height, self._renderer.width))) - - elif cleared: - self._renderer.clear() - - return self._renderer - def handle_event(self, event): e_type = event['type'] handler = getattr(self, 'handle_{0}'.format(e_type), @@ -306,40 +285,36 @@ def _handle_mouse(self, event): x = event['x'] y = event['y'] y = self.get_renderer().height - y - - # Javascript button numbers and matplotlib button numbers are - # off by 1 + self._last_mouse_xy = x, y + # JavaScript button numbers and Matplotlib button numbers are off by 1. button = event['button'] + 1 e_type = event['type'] - guiEvent = event.get('guiEvent', None) - if e_type == 'button_press': - self.button_press_event(x, y, button, guiEvent=guiEvent) + modifiers = event['modifiers'] + guiEvent = event.get('guiEvent') + if e_type in ['button_press', 'button_release']: + MouseEvent(e_type + '_event', self, x, y, button, + modifiers=modifiers, guiEvent=guiEvent)._process() elif e_type == 'dblclick': - self.button_press_event(x, y, button, dblclick=True, - guiEvent=guiEvent) - elif e_type == 'button_release': - self.button_release_event(x, y, button, guiEvent=guiEvent) - elif e_type == 'motion_notify': - self.motion_notify_event(x, y, guiEvent=guiEvent) - elif e_type == 'figure_enter': - self.enter_notify_event(xy=(x, y), guiEvent=guiEvent) - elif e_type == 'figure_leave': - self.leave_notify_event() + MouseEvent('button_press_event', self, x, y, button, dblclick=True, + modifiers=modifiers, guiEvent=guiEvent)._process() elif e_type == 'scroll': - self.scroll_event(x, y, event['step'], guiEvent=guiEvent) + MouseEvent('scroll_event', self, x, y, step=event['step'], + modifiers=modifiers, guiEvent=guiEvent)._process() + elif e_type == 'motion_notify': + MouseEvent(e_type + '_event', self, x, y, + modifiers=modifiers, guiEvent=guiEvent)._process() + elif e_type in ['figure_enter', 'figure_leave']: + LocationEvent(e_type + '_event', self, x, y, + modifiers=modifiers, guiEvent=guiEvent)._process() handle_button_press = handle_button_release = handle_dblclick = \ handle_figure_enter = handle_figure_leave = handle_motion_notify = \ handle_scroll = _handle_mouse def _handle_key(self, event): - key = _handle_key(event['key']) - e_type = event['type'] - guiEvent = event.get('guiEvent', None) - if e_type == 'key_press': - self.key_press_event(key, guiEvent=guiEvent) - elif e_type == 'key_release': - self.key_release_event(key, guiEvent=guiEvent) + KeyEvent(event['type'] + '_event', self, + _handle_key(event['key']), *self._last_mouse_xy, + guiEvent=event.get('guiEvent'))._process() handle_key_press = handle_key_release = _handle_key def handle_toolbar_button(self, event): @@ -369,7 +344,8 @@ def handle_resize(self, event): # identical or within a pixel or so). self._png_is_old = True self.manager.resize(*fig.bbox.size, forward=False) - self.resize_event() + ResizeEvent('resize_event', self)._process() + self.draw_idle() def handle_send_image_mode(self, event): # The client requests notification of what the current image mode is. @@ -414,11 +390,8 @@ class NavigationToolbar2WebAgg(backend_bases.NavigationToolbar2): if name_of_method in _ALLOWED_TOOL_ITEMS ] - cursor = _api.deprecate_privatize_attribute("3.5") - def __init__(self, canvas): self.message = '' - self._cursor = None # Remove with deprecation. super().__init__(canvas) def set_message(self, message): @@ -452,20 +425,17 @@ def set_history_buttons(self): class FigureManagerWebAgg(backend_bases.FigureManagerBase): + # This must be None to not break ipympl + _toolbar2_class = None ToolbarCls = NavigationToolbar2WebAgg def __init__(self, canvas, num): self.web_sockets = set() super().__init__(canvas, num) - self.toolbar = self._get_toolbar(canvas) def show(self): pass - def _get_toolbar(self, canvas): - toolbar = self.ToolbarCls(canvas) - return toolbar - def resize(self, w, h, forward=True): self._send_event( 'resize', diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 9939f9403640..eeed515aafa2 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -19,17 +19,15 @@ import matplotlib as mpl from matplotlib.backend_bases import ( - _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, + _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, MouseButton, NavigationToolbar2, RendererBase, - TimerBase, ToolContainerBase, cursors) + TimerBase, ToolContainerBase, cursors, + CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) from matplotlib import _api, cbook, backend_tools from matplotlib._pylab_helpers import Gcf -from matplotlib.backend_managers import ToolManager -from matplotlib.figure import Figure from matplotlib.path import Path from matplotlib.transforms import Affine2D -from matplotlib.widgets import SubplotTool import wx @@ -41,21 +39,7 @@ PIXELS_PER_INCH = 75 -@_api.caching_module_getattr # module-level deprecations -class __getattr__: - IDLE_DELAY = _api.deprecated("3.1", obj_type="", removal="3.6")(property( - lambda self: 5)) - cursord = _api.deprecated("3.5", obj_type="")(property(lambda self: { - cursors.MOVE: wx.CURSOR_HAND, - cursors.HAND: wx.CURSOR_HAND, - cursors.POINTER: wx.CURSOR_ARROW, - cursors.SELECT_REGION: wx.CURSOR_CROSS, - cursors.WAIT: wx.CURSOR_WAIT, - cursors.RESIZE_HORIZONTAL: wx.CURSOR_SIZEWE, - cursors.RESIZE_VERTICAL: wx.CURSOR_SIZENS, - })) - - +@_api.deprecated("3.6") def error_msg_wx(msg, parent=None): """Signal an error condition with a popup error dialog.""" dialog = wx.MessageDialog(parent=parent, @@ -67,6 +51,15 @@ def error_msg_wx(msg, parent=None): return None +# lru_cache holds a reference to the App and prevents it from being gc'ed. +@functools.lru_cache(1) +def _create_wxapp(): + wxapp = wx.App(False) + wxapp.SetExitOnFrameDelete(True) + cbook._setup_new_guiapp() + return wxapp + + class TimerWx(TimerBase): """Subclass of `.TimerBase` using wx.Timer events.""" @@ -86,6 +79,10 @@ def _timer_set_interval(self): self._timer_start() # Restart with new interval. +@_api.deprecated( + "2.0", name="wx", obj_type="backend", removal="the future", + alternative="wxagg", + addendum="See the Matplotlib usage FAQ for more info on backends.") class RendererWx(RendererBase): """ The renderer handles all the drawing primitives using a graphics @@ -140,10 +137,6 @@ class RendererWx(RendererBase): def __init__(self, bitmap, dpi): """Initialise a wxWindows renderer instance.""" - _api.warn_deprecated( - "2.0", name="wx", obj_type="backend", removal="the future", - alternative="wxagg", addendum="See the Matplotlib usage FAQ for " - "more info on backends.") super().__init__() _log.debug("%s - __init__()", type(self)) self.width = bitmap.GetWidth() @@ -157,6 +150,7 @@ def flipy(self): # docstring inherited return True + @_api.deprecated("3.6") def offset_text_height(self): return True @@ -289,7 +283,7 @@ def get_wx_font(self, s, prop): # Font colour is determined by the active wx.Pen # TODO: It may be wise to cache font information self.fontd[key] = font = wx.Font( # Cache the font and gc. - pointSize=int(size + 0.5), + pointSize=round(size), family=self.fontnames.get(prop.get_name(), wx.ROMAN), style=self.fontangles[prop.get_style()], weight=self.fontweights[prop.get_weight()]) @@ -302,14 +296,14 @@ def points_to_pixels(self, points): class GraphicsContextWx(GraphicsContextBase): """ - The graphics context provides the color, line styles, etc... + The graphics context provides the color, line styles, etc. This class stores a reference to a wxMemoryDC, and a wxGraphicsContext that draws to it. Creating a wxGraphicsContext seems to be fairly heavy, so these objects are cached based on the bitmap object that is passed in. - The base GraphicsContext stores colors as a RGB tuple on the unit + The base GraphicsContext stores colors as an RGB tuple on the unit interval, e.g., (0.5, 0.0, 1.0). wxPython uses an int interval, but since wxPython colour management is rather simple, I have not chosen to implement a separate colour manager class. @@ -331,8 +325,7 @@ def __init__(self, bitmap, renderer): dc, gfx_ctx = self._cache.get(bitmap, (None, None)) if dc is None: - dc = wx.MemoryDC() - dc.SelectObject(bitmap) + dc = wx.MemoryDC(bitmap) gfx_ctx = wx.GraphicsContext.Create(dc) gfx_ctx._lastcliprect = None self._cache[bitmap] = dc, gfx_ctx @@ -405,7 +398,7 @@ def set_joinstyle(self, js): self.unselect() def get_wxcolour(self, color): - """Convert a RGB(A) color to a wx.Colour.""" + """Convert an RGB(A) color to a wx.Colour.""" _log.debug("%s - get_wx_color()", type(self)) return wx.Colour(*[int(255 * x) for x in color]) @@ -422,6 +415,7 @@ class _FigureCanvasWxBase(FigureCanvasBase, wx.Panel): required_interactive_framework = "wx" _timer_cls = TimerWx + manager_class = _api.classproperty(lambda cls: FigureManagerWx) keyvald = { wx.WXK_CONTROL: 'control', @@ -501,33 +495,35 @@ def __init__(self, parent, id, figure=None): _log.debug("%s - __init__() - bitmap w:%d h:%d", type(self), w, h) self._isDrawn = False self._rubberband_rect = None - - self.Bind(wx.EVT_SIZE, self._onSize) - self.Bind(wx.EVT_PAINT, self._onPaint) - self.Bind(wx.EVT_CHAR_HOOK, self._onKeyDown) - self.Bind(wx.EVT_KEY_UP, self._onKeyUp) - self.Bind(wx.EVT_LEFT_DOWN, self._onMouseButton) - self.Bind(wx.EVT_LEFT_DCLICK, self._onMouseButton) - self.Bind(wx.EVT_LEFT_UP, self._onMouseButton) - self.Bind(wx.EVT_MIDDLE_DOWN, self._onMouseButton) - self.Bind(wx.EVT_MIDDLE_DCLICK, self._onMouseButton) - self.Bind(wx.EVT_MIDDLE_UP, self._onMouseButton) - self.Bind(wx.EVT_RIGHT_DOWN, self._onMouseButton) - self.Bind(wx.EVT_RIGHT_DCLICK, self._onMouseButton) - self.Bind(wx.EVT_RIGHT_UP, self._onMouseButton) - self.Bind(wx.EVT_MOUSE_AUX1_DOWN, self._onMouseButton) - self.Bind(wx.EVT_MOUSE_AUX1_UP, self._onMouseButton) - self.Bind(wx.EVT_MOUSE_AUX2_DOWN, self._onMouseButton) - self.Bind(wx.EVT_MOUSE_AUX2_UP, self._onMouseButton) - self.Bind(wx.EVT_MOUSE_AUX1_DCLICK, self._onMouseButton) - self.Bind(wx.EVT_MOUSE_AUX2_DCLICK, self._onMouseButton) - self.Bind(wx.EVT_MOUSEWHEEL, self._onMouseWheel) - self.Bind(wx.EVT_MOTION, self._onMotion) - self.Bind(wx.EVT_LEAVE_WINDOW, self._onLeave) - self.Bind(wx.EVT_ENTER_WINDOW, self._onEnter) - - self.Bind(wx.EVT_MOUSE_CAPTURE_CHANGED, self._onCaptureLost) - self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self._onCaptureLost) + self._rubberband_pen_black = wx.Pen('BLACK', 1, wx.PENSTYLE_SHORT_DASH) + self._rubberband_pen_white = wx.Pen('WHITE', 1, wx.PENSTYLE_SOLID) + + self.Bind(wx.EVT_SIZE, self._on_size) + self.Bind(wx.EVT_PAINT, self._on_paint) + self.Bind(wx.EVT_CHAR_HOOK, self._on_key_down) + self.Bind(wx.EVT_KEY_UP, self._on_key_up) + self.Bind(wx.EVT_LEFT_DOWN, self._on_mouse_button) + self.Bind(wx.EVT_LEFT_DCLICK, self._on_mouse_button) + self.Bind(wx.EVT_LEFT_UP, self._on_mouse_button) + self.Bind(wx.EVT_MIDDLE_DOWN, self._on_mouse_button) + self.Bind(wx.EVT_MIDDLE_DCLICK, self._on_mouse_button) + self.Bind(wx.EVT_MIDDLE_UP, self._on_mouse_button) + self.Bind(wx.EVT_RIGHT_DOWN, self._on_mouse_button) + self.Bind(wx.EVT_RIGHT_DCLICK, self._on_mouse_button) + self.Bind(wx.EVT_RIGHT_UP, self._on_mouse_button) + self.Bind(wx.EVT_MOUSE_AUX1_DOWN, self._on_mouse_button) + self.Bind(wx.EVT_MOUSE_AUX1_UP, self._on_mouse_button) + self.Bind(wx.EVT_MOUSE_AUX2_DOWN, self._on_mouse_button) + self.Bind(wx.EVT_MOUSE_AUX2_UP, self._on_mouse_button) + self.Bind(wx.EVT_MOUSE_AUX1_DCLICK, self._on_mouse_button) + self.Bind(wx.EVT_MOUSE_AUX2_DCLICK, self._on_mouse_button) + self.Bind(wx.EVT_MOUSEWHEEL, self._on_mouse_wheel) + self.Bind(wx.EVT_MOTION, self._on_motion) + self.Bind(wx.EVT_ENTER_WINDOW, self._on_enter) + self.Bind(wx.EVT_LEAVE_WINDOW, self._on_leave) + + self.Bind(wx.EVT_MOUSE_CAPTURE_CHANGED, self._on_capture_lost) + self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self._on_capture_lost) self.SetBackgroundStyle(wx.BG_STYLE_PAINT) # Reduce flicker. self.SetBackgroundColour(wx.WHITE) @@ -595,8 +591,7 @@ def _get_imagesave_wildcards(self): wildcards = '|'.join(wildcards) return wildcards, extensions, filter_index - @_api.delete_parameter("3.4", "origin") - def gui_repaint(self, drawDC=None, origin='WX'): + def gui_repaint(self, drawDC=None): """ Update the displayed image on the GUI canvas, using the supplied wx.PaintDC device context. @@ -614,16 +609,16 @@ def gui_repaint(self, drawDC=None, origin='WX'): # DC (see GraphicsContextWx._cache). bmp = (self.bitmap.ConvertToImage().ConvertToBitmap() if wx.Platform == '__WXMSW__' - and isinstance(self.figure._cachedRenderer, RendererWx) + and isinstance(self.figure.canvas.get_renderer(), RendererWx) else self.bitmap) drawDC.DrawBitmap(bmp, 0, 0) if self._rubberband_rect is not None: # Some versions of wx+python don't support numpy.float64 here. - x0, y0, x1, y1 = map(int, self._rubberband_rect) - drawDC.DrawLineList( - [(x0, y0, x1, y0), (x1, y0, x1, y1), - (x0, y0, x0, y1), (x0, y1, x1, y1)], - wx.Pen('BLACK', 1, wx.PENSTYLE_SHORT_DASH)) + x0, y0, x1, y1 = map(round, self._rubberband_rect) + rect = [(x0, y0, x1, y0), (x1, y0, x1, y1), + (x0, y0, x0, y1), (x0, y1, x1, y1)] + drawDC.DrawLineList(rect, self._rubberband_pen_white) + drawDC.DrawLineList(rect, self._rubberband_pen_black) filetypes = { **FigureCanvasBase.filetypes, @@ -646,9 +641,9 @@ def print_figure(self, filename, *args, **kwargs): if self._isDrawn: self.draw() - def _onPaint(self, event): + def _on_paint(self, event): """Called when wxPaintEvt is generated.""" - _log.debug("%s - _onPaint()", type(self)) + _log.debug("%s - _on_paint()", type(self)) drawDC = wx.PaintDC(self) if not self._isDrawn: self.draw(drawDC=drawDC) @@ -656,7 +651,7 @@ def _onPaint(self, event): self.gui_repaint(drawDC=drawDC) drawDC.Destroy() - def _onSize(self, event): + def _on_size(self, event): """ Called when wxEventSize is generated. @@ -664,7 +659,7 @@ def _onSize(self, event): is better to take the performance hit and redraw the whole window. """ - _log.debug("%s - _onSize()", type(self)) + _log.debug("%s - _on_size()", type(self)) sz = self.GetParent().GetSizer() if sz: si = sz.GetItem(self) @@ -698,10 +693,25 @@ def _onSize(self, event): # so no need to do anything here except to make sure # the whole background is repainted. self.Refresh(eraseBackground=False) - FigureCanvasBase.resize_event(self) + ResizeEvent("resize_event", self)._process() + self.draw_idle() - def _get_key(self, event): + @staticmethod + def _mpl_modifiers(event=None, *, exclude=None): + mod_table = [ + ("ctrl", wx.MOD_CONTROL, wx.WXK_CONTROL), + ("alt", wx.MOD_ALT, wx.WXK_ALT), + ("shift", wx.MOD_SHIFT, wx.WXK_SHIFT), + ] + if event is not None: + modifiers = event.GetModifiers() + return [name for name, mod, key in mod_table + if modifiers & mod and exclude != key] + else: + return [name for name, mod, key in mod_table + if wx.GetKeyState(key)] + def _get_key(self, event): keyval = event.KeyCode if keyval in self.keyvald: key = self.keyvald[keyval] @@ -712,29 +722,38 @@ def _get_key(self, event): if not event.ShiftDown(): key = key.lower() else: - key = None + return None + mods = self._mpl_modifiers(event, exclude=keyval) + if "shift" in mods and key.isupper(): + mods.remove("shift") + return "+".join([*mods, key]) - for meth, prefix, key_name in ( - [event.ControlDown, 'ctrl', 'control'], - [event.AltDown, 'alt', 'alt'], - [event.ShiftDown, 'shift', 'shift'],): - if meth() and key_name != key: - if not (key_name == 'shift' and key.isupper()): - key = '{0}+{1}'.format(prefix, key) - - return key + def _mpl_coords(self, pos=None): + """ + Convert a wx position, defaulting to the current cursor position, to + Matplotlib coordinates. + """ + if pos is None: + pos = wx.GetMouseState() + x, y = self.ScreenToClient(pos.X, pos.Y) + else: + x, y = pos.X, pos.Y + # flip y so y=0 is bottom of canvas + return x, self.figure.bbox.height - y - def _onKeyDown(self, event): + def _on_key_down(self, event): """Capture key press.""" - key = self._get_key(event) - FigureCanvasBase.key_press_event(self, key, guiEvent=event) + KeyEvent("key_press_event", self, + self._get_key(event), *self._mpl_coords(), + guiEvent=event)._process() if self: event.Skip() - def _onKeyUp(self, event): + def _on_key_up(self, event): """Release key.""" - key = self._get_key(event) - FigureCanvasBase.key_release_event(self, key, guiEvent=event) + KeyEvent("key_release_event", self, + self._get_key(event), *self._mpl_coords(), + guiEvent=event)._process() if self: event.Skip() @@ -759,16 +778,15 @@ def _set_capture(self, capture=True): if capture: self.CaptureMouse() - def _onCaptureLost(self, event): + def _on_capture_lost(self, event): """Capture changed or lost""" self._set_capture(False) - def _onMouseButton(self, event): + def _on_mouse_button(self, event): """Start measuring on an axis.""" event.Skip() self._set_capture(event.ButtonDown() or event.ButtonDClick()) - x = event.X - y = self.figure.bbox.height - event.Y + x, y = self._mpl_coords(event) button_map = { wx.MOUSE_BTN_LEFT: MouseButton.LEFT, wx.MOUSE_BTN_MIDDLE: MouseButton.MIDDLE, @@ -778,19 +796,21 @@ def _onMouseButton(self, event): } button = event.GetButton() button = button_map.get(button, button) + modifiers = self._mpl_modifiers(event) if event.ButtonDown(): - self.button_press_event(x, y, button, guiEvent=event) + MouseEvent("button_press_event", self, x, y, button, + modifiers=modifiers, guiEvent=event)._process() elif event.ButtonDClick(): - self.button_press_event(x, y, button, dblclick=True, - guiEvent=event) + MouseEvent("button_press_event", self, x, y, button, + dblclick=True, modifiers=modifiers, + guiEvent=event)._process() elif event.ButtonUp(): - self.button_release_event(x, y, button, guiEvent=event) + MouseEvent("button_release_event", self, x, y, button, + modifiers=modifiers, guiEvent=event)._process() - def _onMouseWheel(self, event): + def _on_mouse_wheel(self, event): """Translate mouse wheel events into matplotlib events""" - # Determine mouse location - x = event.GetX() - y = self.figure.bbox.height - event.GetY() + x, y = self._mpl_coords(event) # Convert delta/rotation/rate into a floating point step size step = event.LinesPerAction * event.WheelRotation / event.WheelDelta # Done handling event @@ -804,26 +824,33 @@ def _onMouseWheel(self, event): return # Return without processing event else: self._skipwheelevent = True - FigureCanvasBase.scroll_event(self, x, y, step, guiEvent=event) + MouseEvent("scroll_event", self, x, y, step=step, + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() - def _onMotion(self, event): + def _on_motion(self, event): """Start measuring on an axis.""" - x = event.GetX() - y = self.figure.bbox.height - event.GetY() event.Skip() - FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) + MouseEvent("motion_notify_event", self, + *self._mpl_coords(event), + modifiers=self._mpl_modifiers(event), + guiEvent=event)._process() - def _onLeave(self, event): - """Mouse has left the window.""" + def _on_enter(self, event): + """Mouse has entered the window.""" event.Skip() - FigureCanvasBase.leave_notify_event(self, guiEvent=event) + LocationEvent("figure_enter_event", self, + *self._mpl_coords(event), + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() - def _onEnter(self, event): - """Mouse has entered the window.""" - x = event.GetX() - y = self.figure.bbox.height - event.GetY() + def _on_leave(self, event): + """Mouse has left the window.""" event.Skip() - FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) + LocationEvent("figure_leave_event", self, + *self._mpl_coords(event), + modifiers=self._mpl_modifiers(), + guiEvent=event)._process() class FigureCanvasWx(_FigureCanvasWxBase): @@ -840,36 +867,19 @@ def draw(self, drawDC=None): self._isDrawn = True self.gui_repaint(drawDC=drawDC) - @_check_savefig_extra_args def _print_image(self, filetype, filename): - origBitmap = self.bitmap - - self.bitmap = wx.Bitmap(math.ceil(self.figure.bbox.width), - math.ceil(self.figure.bbox.height)) - renderer = RendererWx(self.bitmap, self.figure.dpi) - - gc = renderer.new_gc() - self.figure.draw(renderer) - - # image is the object that we call SaveFile on. - image = self.bitmap - - # Now that we have rendered into the bitmap, save it to the appropriate - # file type and clean up. - if (cbook.is_writable_file_like(filename) and - not isinstance(image, wx.Image)): - image = image.ConvertToImage() - if not image.SaveFile(filename, filetype): + bitmap = wx.Bitmap(math.ceil(self.figure.bbox.width), + math.ceil(self.figure.bbox.height)) + self.figure.draw(RendererWx(bitmap, self.figure.dpi)) + saved_obj = (bitmap.ConvertToImage() + if cbook.is_writable_file_like(filename) + else bitmap) + if not saved_obj.SaveFile(filename, filetype): raise RuntimeError(f'Could not save figure to {filename}') - - # Restore everything to normal - self.bitmap = origBitmap - - # Note: draw is required here since bits of state about the - # last renderer are strewn about the artist draw methods. Do - # not remove the draw without first verifying that these have - # been cleaned up. The artist contains() methods will fail - # otherwise. + # draw() is required here since bits of state about the last renderer + # are strewn about the artist draw methods. Do not remove the draw + # without first verifying that these have been cleaned up. The artist + # contains() methods will fail otherwise. if self._isDrawn: self.draw() # The "if self" check avoids a "wrapped C/C++ object has been deleted" @@ -892,7 +902,7 @@ def _print_image(self, filetype, filename): class FigureFrameWx(wx.Frame): - def __init__(self, num, fig): + def __init__(self, num, fig, *, canvas_class=None): # On non-Windows platform, explicitly set the position - fix # positioning bug on some Linux platforms if wx.Platform == '__WXMSW__': @@ -902,95 +912,74 @@ def __init__(self, num, fig): super().__init__(parent=None, id=-1, pos=pos) # Frame will be sized later by the Fit method _log.debug("%s - __init__()", type(self)) - self.num = num _set_frame_icon(self) - self.canvas = self.get_canvas(fig) - w, h = map(math.ceil, fig.bbox.size) - self.canvas.SetInitialSize(wx.Size(w, h)) - self.canvas.SetFocus() - self.sizer = wx.BoxSizer(wx.VERTICAL) - self.sizer.Add(self.canvas, 1, wx.TOP | wx.LEFT | wx.EXPAND) - # By adding toolbar in sizer, we are able to put it at the bottom - # of the frame - so appearance is closer to GTK version - - self.figmgr = FigureManagerWx(self.canvas, num, self) - - self.toolbar = self._get_toolbar() - - if self.figmgr.toolmanager: - backend_tools.add_tools_to_manager(self.figmgr.toolmanager) - if self.toolbar: - backend_tools.add_tools_to_container(self.toolbar) - - if self.toolbar is not None: - self.toolbar.Realize() - # On Windows platform, default window size is incorrect, so set - # toolbar width to figure width. - tw, th = self.toolbar.GetSize() - fw, fh = self.canvas.GetSize() - # By adding toolbar in sizer, we are able to put it at the bottom - # of the frame - so appearance is closer to GTK version. - self.toolbar.SetSize(wx.Size(fw, th)) - self.sizer.Add(self.toolbar, 0, wx.LEFT | wx.EXPAND) - self.SetSizer(self.sizer) - self.Fit() + # The parameter will become required after the deprecation elapses. + if canvas_class is not None: + self.canvas = canvas_class(self, -1, fig) + else: + _api.warn_deprecated( + "3.6", message="The canvas_class parameter will become " + "required after the deprecation period starting in Matplotlib " + "%(since)s elapses.") + self.canvas = self.get_canvas(fig) - self.canvas.SetMinSize((2, 2)) + # Auto-attaches itself to self.canvas.manager + manager = FigureManagerWx(self.canvas, num, self) - self.Bind(wx.EVT_CLOSE, self._onClose) + toolbar = self.canvas.manager.toolbar + if toolbar is not None: + self.SetToolBar(toolbar) - @property - def toolmanager(self): - return self.figmgr.toolmanager + # On Windows, canvas sizing must occur after toolbar addition; + # otherwise the toolbar further resizes the canvas. + w, h = map(math.ceil, fig.bbox.size) + self.canvas.SetInitialSize(wx.Size(w, h)) + self.canvas.SetMinSize((2, 2)) + self.canvas.SetFocus() - def _get_toolbar(self): - if mpl.rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2Wx(self.canvas) - elif mpl.rcParams['toolbar'] == 'toolmanager': - toolbar = ToolbarWx(self.toolmanager, self) - else: - toolbar = None - return toolbar + self.Fit() + self.Bind(wx.EVT_CLOSE, self._on_close) + + sizer = _api.deprecated("3.6", alternative="frame.GetSizer()")( + property(lambda self: self.GetSizer())) + figmgr = _api.deprecated("3.6", alternative="frame.canvas.manager")( + property(lambda self: self.canvas.manager)) + num = _api.deprecated("3.6", alternative="frame.canvas.manager.num")( + property(lambda self: self.canvas.manager.num)) + toolbar = _api.deprecated("3.6", alternative="frame.GetToolBar()")( + property(lambda self: self.GetToolBar())) + toolmanager = _api.deprecated( + "3.6", alternative="frame.canvas.manager.toolmanager")( + property(lambda self: self.canvas.manager.toolmanager)) + + @_api.deprecated( + "3.6", alternative="the canvas_class constructor parameter") def get_canvas(self, fig): return FigureCanvasWx(self, -1, fig) + @_api.deprecated("3.6", alternative="frame.canvas.manager") def get_figure_manager(self): _log.debug("%s - get_figure_manager()", type(self)) - return self.figmgr + return self.canvas.manager - def _onClose(self, event): - _log.debug("%s - onClose()", type(self)) - self.canvas.close_event() + def _on_close(self, event): + _log.debug("%s - on_close()", type(self)) + CloseEvent("close_event", self.canvas)._process() self.canvas.stop_event_loop() # set FigureManagerWx.frame to None to prevent repeated attempts to # close this frame from FigureManagerWx.destroy() - self.figmgr.frame = None + self.canvas.manager.frame = None # remove figure manager from Gcf.figs - Gcf.destroy(self.figmgr) + Gcf.destroy(self.canvas.manager) + try: # See issue 2941338. + self.canvas.mpl_disconnect(self.canvas.toolbar._id_drag) + except AttributeError: # If there's no toolbar. + pass # Carry on with close event propagation, frame & children destruction event.Skip() - def GetToolBar(self): - """Override wxFrame::GetToolBar as we don't have managed toolbar""" - return self.toolbar - - def Destroy(self, *args, **kwargs): - try: - self.canvas.mpl_disconnect(self.toolbar._id_drag) - # Rationale for line above: see issue 2941338. - except AttributeError: - pass # classic toolbar lacks the attribute - # The "if self" check avoids a "wrapped C/C++ object has been deleted" - # RuntimeError at exit with e.g. - # MPLBACKEND=wxagg python -c 'from pylab import *; plot()'. - if self and not self.IsBeingDeleted(): - super().Destroy(*args, **kwargs) - # self.toolbar.Destroy() should not be necessary if the close event - # is allowed to propagate. - return True - class FigureManagerWx(FigureManagerBase): """ @@ -1010,20 +999,25 @@ class FigureManagerWx(FigureManagerBase): def __init__(self, canvas, num, frame): _log.debug("%s - __init__()", type(self)) self.frame = self.window = frame - self._initializing = True super().__init__(canvas, num) - self._initializing = False - @property - def toolbar(self): - return self.frame.GetToolBar() + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + # docstring inherited + wxapp = wx.GetApp() or _create_wxapp() + frame = FigureFrameWx(num, figure, canvas_class=canvas_class) + manager = figure.canvas.manager + if mpl.is_interactive(): + manager.frame.Show() + figure.canvas.draw_idle() + return manager - @toolbar.setter - def toolbar(self, value): - # Never allow this, except that base class inits this to None before - # the frame is set up. - if not self._initializing: - raise AttributeError("can't set attribute") + @classmethod + def start_main_loop(cls): + if not wx.App.IsMainLoopRunning(): + wxapp = wx.GetApp() + if wxapp is not None: + wxapp.MainLoop() def show(self): # docstring inherited @@ -1055,9 +1049,9 @@ def set_window_title(self, title): def resize(self, width, height): # docstring inherited - self.canvas.SetInitialSize( - wx.Size(math.ceil(width), math.ceil(height))) - self.window.GetSizer().Fit(self.window) + # Directly using SetClientSize doesn't handle the toolbar on Windows. + self.window.SetSize(self.window.ClientToWindowSize(wx.Size( + math.ceil(width), math.ceil(height)))) def _load_bitmap(filename): @@ -1079,8 +1073,8 @@ def _set_frame_icon(frame): class NavigationToolbar2Wx(NavigationToolbar2, wx.ToolBar): - def __init__(self, canvas, coordinates=True): - wx.ToolBar.__init__(self, canvas.GetParent(), -1) + def __init__(self, canvas, coordinates=True, *, style=wx.TB_BOTTOM): + wx.ToolBar.__init__(self, canvas.GetParent(), -1, style=style) if 'wxMac' in wx.PlatformInfo: self.SetToolBitmapSize((24, 24)) @@ -1104,7 +1098,7 @@ def __init__(self, canvas, coordinates=True): self._coordinates = coordinates if self._coordinates: self.AddStretchableSpace() - self._label_text = wx.StaticText(self) + self._label_text = wx.StaticText(self, style=wx.ALIGN_RIGHT) self.AddControl(self._label_text) self.Realize() @@ -1118,7 +1112,9 @@ def _icon(name): *name*, including the extension and relative to Matplotlib's "images" data directory. """ - image = np.array(PIL.Image.open(cbook._get_data_path("images", name))) + pilimg = PIL.Image.open(cbook._get_data_path("images", name)) + # ensure RGBA as wx BitMap expects RGBA format + image = np.array(pilimg.convert("RGBA")) try: dark = wx.SystemSettings.GetAppearance().IsDark() except AttributeError: # wxpython < 4.1 @@ -1136,10 +1132,6 @@ def _icon(name): return wx.Bitmap.FromBufferRGBA( image.shape[1], image.shape[0], image.tobytes()) - @_api.deprecated("3.4") - def get_canvas(self, frame, fig): - return type(self.canvas)(frame, -1, fig) - def _update_buttons_checked(self): if "Pan" in self.wx_ids: self.ToggleTool(self.wx_ids["Pan"], self.mode.name == "PAN") @@ -1158,15 +1150,15 @@ def save_figure(self, *args): # Fetch the required filename and file type. filetypes, exts, filter_index = self.canvas._get_imagesave_wildcards() default_file = self.canvas.get_default_filename() - dlg = wx.FileDialog( + dialog = wx.FileDialog( self.canvas.GetParent(), "Save to file", mpl.rcParams["savefig.directory"], default_file, filetypes, wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) - dlg.SetFilterIndex(filter_index) - if dlg.ShowModal() == wx.ID_OK: - path = pathlib.Path(dlg.GetPath()) + dialog.SetFilterIndex(filter_index) + if dialog.ShowModal() == wx.ID_OK: + path = pathlib.Path(dialog.GetPath()) _log.debug('%s - Save file path: %s', type(self), path) - fmt = exts[dlg.GetFilterIndex()] + fmt = exts[dialog.GetFilterIndex()] ext = path.suffix[1:] if ext in self.canvas.get_supported_filetypes() and fmt != ext: # looks like they forgot to set the image type drop @@ -1179,9 +1171,13 @@ def save_figure(self, *args): if mpl.rcParams["savefig.directory"]: mpl.rcParams["savefig.directory"] = str(path.parent) try: - self.canvas.figure.savefig(str(path), format=fmt) + self.canvas.figure.savefig(path, format=fmt) except Exception as e: - error_msg_wx(str(e)) + dialog = wx.MessageDialog( + parent=self.canvas.GetParent(), message=str(e), + caption='Matplotlib error') + dialog.ShowModal() + dialog.Destroy() def draw_rubberband(self, event, x0, y0, x1, y1): height = self.canvas.figure.bbox.height @@ -1208,11 +1204,13 @@ def set_history_buttons(self): # tools for matplotlib.backend_managers.ToolManager: class ToolbarWx(ToolContainerBase, wx.ToolBar): - def __init__(self, toolmanager, parent, style=wx.TB_HORIZONTAL): + def __init__(self, toolmanager, parent=None, style=wx.TB_BOTTOM): + if parent is None: + parent = toolmanager.canvas.GetParent() ToolContainerBase.__init__(self, toolmanager) wx.ToolBar.__init__(self, parent, -1, style=style) self._space = self.AddStretchableSpace() - self._label_text = wx.StaticText(self) + self._label_text = wx.StaticText(self, style=wx.ALIGN_RIGHT) self.AddControl(self._label_text) self._toolitems = {} self._groups = {} # Mapping of groups to the separator after them. @@ -1292,24 +1290,20 @@ def set_message(self, s): self._label_text.SetLabel(s) +@backend_tools._register_tool_class(_FigureCanvasWxBase) class ConfigureSubplotsWx(backend_tools.ConfigureSubplotsBase): def trigger(self, *args): NavigationToolbar2Wx.configure_subplots(self) +@backend_tools._register_tool_class(_FigureCanvasWxBase) class SaveFigureWx(backend_tools.SaveFigureBase): def trigger(self, *args): NavigationToolbar2Wx.save_figure( self._make_classic_style_pseudo_toolbar()) -@_api.deprecated("3.5", alternative="ToolSetCursor") -class SetCursorWx(backend_tools.SetCursorBase): - def set_cursor(self, cursor): - NavigationToolbar2Wx.set_cursor( - self._make_classic_style_pseudo_toolbar(), cursor) - - +@backend_tools._register_tool_class(_FigureCanvasWxBase) class RubberbandWx(backend_tools.RubberbandBase): def draw_rubberband(self, x0, y0, x1, y1): NavigationToolbar2Wx.draw_rubberband( @@ -1342,15 +1336,15 @@ def __init__(self, parent, help_entries): grid_sizer.Add(label, 0, 0, 0) # finalize layout, create button sizer.Add(grid_sizer, 0, wx.ALL, 6) - OK = wx.Button(self, wx.ID_OK) - sizer.Add(OK, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 8) + ok = wx.Button(self, wx.ID_OK) + sizer.Add(ok, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 8) self.SetSizer(sizer) sizer.Fit(self) self.Layout() - self.Bind(wx.EVT_CLOSE, self.OnClose) - OK.Bind(wx.EVT_BUTTON, self.OnClose) + self.Bind(wx.EVT_CLOSE, self._on_close) + ok.Bind(wx.EVT_BUTTON, self._on_close) - def OnClose(self, event): + def _on_close(self, event): _HelpDialog._instance = None # remove global reference self.DestroyLater() event.Skip() @@ -1365,12 +1359,14 @@ def show(cls, parent, help_entries): cls._instance.Show() +@backend_tools._register_tool_class(_FigureCanvasWxBase) class HelpWx(backend_tools.ToolHelpBase): def trigger(self, *args): _HelpDialog.show(self.figure.canvas.GetTopLevelParent(), self._get_help_entries()) +@backend_tools._register_tool_class(_FigureCanvasWxBase) class ToolCopyToClipboardWx(backend_tools.ToolCopyToClipboardBase): def trigger(self, *args, **kwargs): if not self.canvas._isDrawn: @@ -1383,44 +1379,12 @@ def trigger(self, *args, **kwargs): wx.TheClipboard.Close() -backend_tools.ToolSaveFigure = SaveFigureWx -backend_tools.ToolConfigureSubplots = ConfigureSubplotsWx -backend_tools.ToolRubberband = RubberbandWx -backend_tools.ToolHelp = HelpWx -backend_tools.ToolCopyToClipboard = ToolCopyToClipboardWx +FigureManagerWx._toolbar2_class = NavigationToolbar2Wx +FigureManagerWx._toolmanager_toolbar_class = ToolbarWx @_Backend.export class _BackendWx(_Backend): FigureCanvas = FigureCanvasWx FigureManager = FigureManagerWx - _frame_class = FigureFrameWx - - @classmethod - def new_figure_manager(cls, num, *args, **kwargs): - # Create a wx.App instance if it has not been created so far. - wxapp = wx.GetApp() - if wxapp is None: - wxapp = wx.App(False) - wxapp.SetExitOnFrameDelete(True) - cbook._setup_new_guiapp() - # Retain a reference to the app object so that it does not get - # garbage collected. - _BackendWx._theWxApp = wxapp - return super().new_figure_manager(num, *args, **kwargs) - - @classmethod - def new_figure_manager_given_figure(cls, num, figure): - frame = cls._frame_class(num, figure) - figmgr = frame.get_figure_manager() - if mpl.is_interactive(): - figmgr.frame.Show() - figure.canvas.draw_idle() - return figmgr - - @staticmethod - def mainloop(): - if not wx.App.IsMainLoopRunning(): - wxapp = wx.GetApp() - if wxapp is not None: - wxapp.MainLoop() + mainloop = FigureManagerWx.start_main_loop diff --git a/lib/matplotlib/backends/backend_wxagg.py b/lib/matplotlib/backends/backend_wxagg.py index 106578e7e14b..ca7f91583766 100644 --- a/lib/matplotlib/backends/backend_wxagg.py +++ b/lib/matplotlib/backends/backend_wxagg.py @@ -1,11 +1,14 @@ import wx +from .. import _api from .backend_agg import FigureCanvasAgg -from .backend_wx import ( - _BackendWx, _FigureCanvasWxBase, FigureFrameWx, +from .backend_wx import _BackendWx, _FigureCanvasWxBase, FigureFrameWx +from .backend_wx import ( # noqa: F401 # pylint: disable=W0611 NavigationToolbar2Wx as NavigationToolbar2WxAgg) +@_api.deprecated( + "3.6", alternative="FigureFrameWx(..., canvas_class=FigureCanvasWxAgg)") class FigureFrameWxAgg(FigureFrameWx): def get_canvas(self, fig): return FigureCanvasWxAgg(self, -1, fig) @@ -27,66 +30,32 @@ def draw(self, drawDC=None): Render the figure using agg. """ FigureCanvasAgg.draw(self) - - self.bitmap = _convert_agg_to_wx_bitmap(self.get_renderer(), None) + self.bitmap = _rgba_to_wx_bitmap(self.get_renderer().buffer_rgba()) self._isDrawn = True self.gui_repaint(drawDC=drawDC) def blit(self, bbox=None): # docstring inherited + bitmap = _rgba_to_wx_bitmap(self.get_renderer().buffer_rgba()) if bbox is None: - self.bitmap = _convert_agg_to_wx_bitmap(self.get_renderer(), None) - self.gui_repaint() - return - - srcBmp = _convert_agg_to_wx_bitmap(self.get_renderer(), None) - srcDC = wx.MemoryDC() - srcDC.SelectObject(srcBmp) - - destDC = wx.MemoryDC() - destDC.SelectObject(self.bitmap) - - x = int(bbox.x0) - y = int(self.bitmap.GetHeight() - bbox.y1) - destDC.Blit(x, y, int(bbox.width), int(bbox.height), srcDC, x, y) - - destDC.SelectObject(wx.NullBitmap) - srcDC.SelectObject(wx.NullBitmap) + self.bitmap = bitmap + else: + srcDC = wx.MemoryDC(bitmap) + destDC = wx.MemoryDC(self.bitmap) + x = int(bbox.x0) + y = int(self.bitmap.GetHeight() - bbox.y1) + destDC.Blit(x, y, int(bbox.width), int(bbox.height), srcDC, x, y) + destDC.SelectObject(wx.NullBitmap) + srcDC.SelectObject(wx.NullBitmap) self.gui_repaint() -def _convert_agg_to_wx_bitmap(agg, bbox): - """ - Convert the region of the agg buffer bounded by bbox to a wx.Bitmap. If - bbox is None, the entire buffer is converted. - Note: agg must be a backend_agg.RendererAgg instance. - """ - if bbox is None: - # agg => rgba buffer -> bitmap - return wx.Bitmap.FromBufferRGBA(int(agg.width), int(agg.height), - agg.buffer_rgba()) - else: - # agg => rgba buffer -> bitmap => clipped bitmap - srcBmp = wx.Bitmap.FromBufferRGBA(int(agg.width), int(agg.height), - agg.buffer_rgba()) - srcDC = wx.MemoryDC() - srcDC.SelectObject(srcBmp) - - destBmp = wx.Bitmap(int(bbox.width), int(bbox.height)) - destDC = wx.MemoryDC() - destDC.SelectObject(destBmp) - - x = int(bbox.x0) - y = int(int(agg.height) - bbox.y1) - destDC.Blit(0, 0, int(bbox.width), int(bbox.height), srcDC, x, y) - - srcDC.SelectObject(wx.NullBitmap) - destDC.SelectObject(wx.NullBitmap) - - return destBmp +def _rgba_to_wx_bitmap(rgba): + """Convert an RGBA buffer to a wx.Bitmap.""" + h, w, _ = rgba.shape + return wx.Bitmap.FromBufferRGBA(w, h, rgba) @_BackendWx.export class _BackendWxAgg(_BackendWx): FigureCanvas = FigureCanvasWxAgg - _frame_class = FigureFrameWxAgg diff --git a/lib/matplotlib/backends/backend_wxcairo.py b/lib/matplotlib/backends/backend_wxcairo.py index 230ebcf527d1..0416a187d091 100644 --- a/lib/matplotlib/backends/backend_wxcairo.py +++ b/lib/matplotlib/backends/backend_wxcairo.py @@ -1,17 +1,20 @@ import wx.lib.wxcairo as wxcairo -from .backend_cairo import cairo, FigureCanvasCairo, RendererCairo -from .backend_wx import ( - _BackendWx, _FigureCanvasWxBase, FigureFrameWx, +from .. import _api +from .backend_cairo import cairo, FigureCanvasCairo +from .backend_wx import _BackendWx, _FigureCanvasWxBase, FigureFrameWx +from .backend_wx import ( # noqa: F401 # pylint: disable=W0611 NavigationToolbar2Wx as NavigationToolbar2WxCairo) +@_api.deprecated( + "3.6", alternative="FigureFrameWx(..., canvas_class=FigureCanvasWxCairo)") class FigureFrameWxCairo(FigureFrameWx): def get_canvas(self, fig): return FigureCanvasWxCairo(self, -1, fig) -class FigureCanvasWxCairo(_FigureCanvasWxBase, FigureCanvasCairo): +class FigureCanvasWxCairo(FigureCanvasCairo, _FigureCanvasWxBase): """ The FigureCanvas contains the figure and does event handling. @@ -21,20 +24,10 @@ class FigureCanvasWxCairo(_FigureCanvasWxBase, FigureCanvasCairo): we give a hint as to our preferred minimum size. """ - def __init__(self, parent, id, figure): - # _FigureCanvasWxBase should be fixed to have the same signature as - # every other FigureCanvas and use cooperative inheritance, but in the - # meantime the following will make do. - _FigureCanvasWxBase.__init__(self, parent, id, figure) - FigureCanvasCairo.__init__(self, figure) - self._renderer = RendererCairo(self.figure.dpi) - def draw(self, drawDC=None): - width = int(self.figure.bbox.width) - height = int(self.figure.bbox.height) - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - self._renderer.set_ctx_from_surface(surface) - self._renderer.set_width_height(width, height) + size = self.figure.bbox.size.astype(int) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, *size) + self._renderer.set_context(cairo.Context(surface)) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) self.bitmap = wxcairo.BitmapFromImageSurface(surface) @@ -45,4 +38,3 @@ def draw(self, drawDC=None): @_BackendWx.export class _BackendWxCairo(_BackendWx): FigureCanvas = FigureCanvasWxCairo - _frame_class = FigureFrameWxCairo diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index 47888726d766..663671894a74 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -5,9 +5,7 @@ - if any of PyQt6, PySide6, PyQt5, or PySide2 have already been imported (checked in that order), use it; - otherwise, if the QT_API environment variable (used by Enthought) is set, use - it to determine which binding to use (but do not change the backend based on - it; i.e. if the Qt5Agg backend is requested but QT_API is set to "pyqt4", - then actually use Qt5 with PyQt5 or PySide2 (whichever can be imported); + it to determine which binding to use; - otherwise, use whatever the rcParams indicate. """ @@ -23,7 +21,6 @@ from packaging.version import parse as parse_version import matplotlib as mpl -from matplotlib import _api from . import _QT_FORCE_QT5_BINDING @@ -31,18 +28,12 @@ QT_API_PYSIDE6 = "PySide6" QT_API_PYQT5 = "PyQt5" QT_API_PYSIDE2 = "PySide2" -QT_API_PYQTv2 = "PyQt4v2" -QT_API_PYSIDE = "PySide" -QT_API_PYQT = "PyQt4" # Use the old sip v1 API (Py3 defaults to v2). QT_API_ENV = os.environ.get("QT_API") if QT_API_ENV is not None: QT_API_ENV = QT_API_ENV.lower() -# Mapping of QT_API_ENV to requested binding. ETS does not support PyQt4v1. -# (https://github.com/enthought/pyface/blob/master/pyface/qt/__init__.py) -_ETS = { +_ETS = { # Mapping of QT_API_ENV to requested binding. "pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6, "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2, - None: None } # First, check if anything is already imported. if sys.modules.get("PyQt6.QtCore"): @@ -55,15 +46,10 @@ QT_API = QT_API_PYSIDE2 # Otherwise, check the QT_API environment variable (from Enthought). This can # only override the binding, not the backend (in other words, we check that the -# requested backend actually matches). Use dict.__getitem__ to avoid +# requested backend actually matches). Use _get_backend_or_none to avoid # triggering backend resolution (which can result in a partially but # incompletely imported backend_qt5). -elif ( - isinstance(dict.__getitem__(mpl.rcParams, "backend"), str) and - dict.__getitem__(mpl.rcParams, "backend").lower() in [ - "qt5agg", "qt5cairo" - ] -): +elif (mpl.rcParams._get_backend_or_none() or "").lower().startswith("qt5"): if QT_API_ENV in ["pyqt5", "pyside2"]: QT_API = _ETS[QT_API_ENV] else: @@ -73,19 +59,17 @@ # fully manually embedding Matplotlib in a Qt app without using pyplot). elif QT_API_ENV is None: QT_API = None +elif QT_API_ENV in _ETS: + QT_API = _ETS[QT_API_ENV] else: - try: - QT_API = _ETS[QT_API_ENV] - except KeyError as err: - raise RuntimeError( - "The environment variable QT_API has the unrecognized value " - f"{QT_API_ENV!r}; " - f"valid values are {set(k for k in _ETS if k is not None)}" - ) from None + raise RuntimeError( + "The environment variable QT_API has the unrecognized value {!r}; " + "valid values are {}".format(QT_API_ENV, ", ".join(_ETS))) def _setup_pyqt5plus(): - global QtCore, QtGui, QtWidgets, __version__, _isdeleted, _getSaveFileName + global QtCore, QtGui, QtWidgets, __version__ + global _getSaveFileName, _isdeleted, _to_int if QT_API == QT_API_PYQT6: from PyQt6 import QtCore, QtGui, QtWidgets, sip @@ -94,10 +78,15 @@ def _setup_pyqt5plus(): QtCore.Slot = QtCore.pyqtSlot QtCore.Property = QtCore.pyqtProperty _isdeleted = sip.isdeleted + _to_int = operator.attrgetter('value') elif QT_API == QT_API_PYSIDE6: from PySide6 import QtCore, QtGui, QtWidgets, __version__ import shiboken6 def _isdeleted(obj): return not shiboken6.isValid(obj) + if parse_version(__version__) >= parse_version('6.4'): + _to_int = operator.attrgetter('value') + else: + _to_int = int elif QT_API == QT_API_PYQT5: from PyQt5 import QtCore, QtGui, QtWidgets import sip @@ -106,11 +95,16 @@ def _isdeleted(obj): return not shiboken6.isValid(obj) QtCore.Slot = QtCore.pyqtSlot QtCore.Property = QtCore.pyqtProperty _isdeleted = sip.isdeleted + _to_int = int elif QT_API == QT_API_PYSIDE2: from PySide2 import QtCore, QtGui, QtWidgets, __version__ - import shiboken2 + try: + from PySide2 import shiboken2 + except ImportError: + import shiboken2 def _isdeleted(obj): return not shiboken2.isValid(obj) + _to_int = int else: raise AssertionError(f"Unexpected QT_API: {QT_API}") _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName @@ -131,7 +125,6 @@ def _isdeleted(obj): (_setup_pyqt5plus, QT_API_PYQT5), (_setup_pyqt5plus, QT_API_PYSIDE2), ] - for _setup, QT_API in _candidates: try: _setup() @@ -139,26 +132,32 @@ def _isdeleted(obj): continue break else: - raise ImportError("Failed to import any qt binding") + raise ImportError( + "Failed to import any of the following Qt binding modules: {}" + .format(", ".join(_ETS.values()))) else: # We should not get there. raise AssertionError(f"Unexpected QT_API: {QT_API}") +_version_info = tuple(QtCore.QLibraryInfo.version().segments()) + + +if _version_info < (5, 10): + raise ImportError( + f"The Qt version imported is " + f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires " + f"Qt>=5.10") # Fixes issues with Big Sur # https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2 if (sys.platform == 'darwin' and parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and - parse_version(QtCore.qVersion()) < parse_version("5.15.2") and - "QT_MAC_WANTS_LAYER" not in os.environ): - os.environ["QT_MAC_WANTS_LAYER"] = "1" + _version_info < (5, 15, 2)): + os.environ.setdefault("QT_MAC_WANTS_LAYER", "1") # PyQt6 enum compat helpers. -_to_int = operator.attrgetter("value") if QT_API == "PyQt6" else int - - @functools.lru_cache(None) def _enum(name): # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6). @@ -175,36 +174,6 @@ def _exec(obj): obj.exec() if hasattr(obj, "exec") else obj.exec_() -def _devicePixelRatioF(obj): - """ - Return obj.devicePixelRatioF() with graceful fallback for older Qt. - - This can be replaced by the direct call when we require Qt>=5.6. - """ - try: - # Not available on Qt<5.6 - return obj.devicePixelRatioF() or 1 - except AttributeError: - pass - try: - # Not available on Qt4 or some older Qt5. - # self.devicePixelRatio() returns 0 in rare cases - return obj.devicePixelRatio() or 1 - except AttributeError: - return 1 - - -def _setDevicePixelRatio(obj, val): - """ - Call obj.setDevicePixelRatio(val) with graceful fallback for older Qt. - - This can be replaced by the direct call when we require Qt>=5.6. - """ - if hasattr(obj, 'setDevicePixelRatio'): - # Not available on Qt4 or some older Qt5. - obj.setDevicePixelRatio(val) - - @contextlib.contextmanager def _maybe_allow_interrupt(qapp): """ @@ -274,11 +243,3 @@ def handle(*args): signal.signal(signal.SIGINT, old_sigint_handler) if handler_args is not None: old_sigint_handler(*handler_args) - - -@_api.caching_module_getattr -class __getattr__: - ETS = _api.deprecated("3.5")(property(lambda self: dict( - pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5)))) - QT_RC_MAJOR_VERSION = _api.deprecated("3.5")(property( - lambda self: int(QtCore.qVersion().split(".")[0]))) diff --git a/lib/matplotlib/backends/qt_editor/_formlayout.py b/lib/matplotlib/backends/qt_editor/_formlayout.py index 957f195a11f7..1306e0c02fa6 100644 --- a/lib/matplotlib/backends/qt_editor/_formlayout.py +++ b/lib/matplotlib/backends/qt_editor/_formlayout.py @@ -517,7 +517,7 @@ def fedit(data, title="", comment="", icon=None, parent=None, apply=None): box) for each member of a datagroup inside a datagroup Supported types for field_value: - - int, float, str, unicode, bool + - int, float, str, bool - colors: in Qt-compatible text form, i.e. in hex format or name (red, ...) (automatically detected from a string) - list/tuple: diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index 31e929c4a7c2..2a9510980106 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -5,6 +5,7 @@ """Module that provides a GUI-based editor for Matplotlib's figure options.""" +from itertools import chain from matplotlib import cbook, cm, colors as mcolors, markers, image as mimage from matplotlib.backends.qt_compat import QtGui from matplotlib.backends.qt_editor import _formlayout @@ -38,30 +39,40 @@ def convert_limits(lim, converter): # Cast to builtin floats as they have nicer reprs. return map(float, lim) - xconverter = axes.xaxis.converter - xmin, xmax = convert_limits(axes.get_xlim(), xconverter) - yconverter = axes.yaxis.converter - ymin, ymax = convert_limits(axes.get_ylim(), yconverter) - general = [('Title', axes.get_title()), - sep, - (None, "X-Axis"), - ('Left', xmin), ('Right', xmax), - ('Label', axes.get_xlabel()), - ('Scale', [axes.get_xscale(), - 'linear', 'log', 'symlog', 'logit']), - sep, - (None, "Y-Axis"), - ('Bottom', ymin), ('Top', ymax), - ('Label', axes.get_ylabel()), - ('Scale', [axes.get_yscale(), - 'linear', 'log', 'symlog', 'logit']), - sep, - ('(Re-)Generate automatic legend', False), - ] - - # Save the unit data - xunits = axes.xaxis.get_units() - yunits = axes.yaxis.get_units() + axis_map = axes._axis_map + axis_limits = { + name: tuple(convert_limits( + getattr(axes, f'get_{name}lim')(), axis.converter + )) + for name, axis in axis_map.items() + } + general = [ + ('Title', axes.get_title()), + sep, + *chain.from_iterable([ + ( + (None, f"{name.title()}-Axis"), + ('Min', axis_limits[name][0]), + ('Max', axis_limits[name][1]), + ('Label', axis.get_label().get_text()), + ('Scale', [axis.get_scale(), + 'linear', 'log', 'symlog', 'logit']), + sep, + ) + for name, axis in axis_map.items() + ]), + ('(Re-)Generate automatic legend', False), + ] + + # Save the converter and unit data + axis_converter = { + name: axis.converter + for name, axis in axis_map.items() + } + axis_units = { + name: axis.get_units() + for name, axis in axis_map.items() + } # Get / Curves labeled_lines = [] @@ -135,10 +146,10 @@ def prepare_data(d, init): continue labeled_mappables.append((label, mappable)) mappables = [] - cmaps = [(cmap, name) for name, cmap in sorted(cm._cmap_registry.items())] + cmaps = [(cmap, name) for name, cmap in sorted(cm._colormaps.items())] for label, mappable in labeled_mappables: cmap = mappable.get_cmap() - if cmap not in cm._cmap_registry.values(): + if cmap not in cm._colormaps.values(): cmaps = [(cmap, cmap.name), *cmaps] low, high = mappable.get_clim() mappabledata = [ @@ -165,8 +176,10 @@ def prepare_data(d, init): def apply_callback(data): """A callback to apply changes.""" - orig_xlim = axes.get_xlim() - orig_ylim = axes.get_ylim() + orig_limits = { + name: getattr(axes, f"get_{name}lim")() + for name in axis_map + } general = data.pop(0) curves = data.pop(0) if has_curve else [] @@ -174,28 +187,24 @@ def apply_callback(data): if data: raise ValueError("Unexpected field") - # Set / General - (title, xmin, xmax, xlabel, xscale, ymin, ymax, ylabel, yscale, - generate_legend) = general + title = general.pop(0) + axes.set_title(title) + generate_legend = general.pop() + + for i, (name, axis) in enumerate(axis_map.items()): + axis_min = general[4*i] + axis_max = general[4*i + 1] + axis_label = general[4*i + 2] + axis_scale = general[4*i + 3] + if axis.get_scale() != axis_scale: + getattr(axes, f"set_{name}scale")(axis_scale) - if axes.get_xscale() != xscale: - axes.set_xscale(xscale) - if axes.get_yscale() != yscale: - axes.set_yscale(yscale) + axis._set_lim(axis_min, axis_max, auto=False) + axis.set_label_text(axis_label) - axes.set_title(title) - axes.set_xlim(xmin, xmax) - axes.set_xlabel(xlabel) - axes.set_ylim(ymin, ymax) - axes.set_ylabel(ylabel) - - # Restore the unit data - axes.xaxis.converter = xconverter - axes.yaxis.converter = yconverter - axes.xaxis.set_units(xunits) - axes.yaxis.set_units(yunits) - axes.xaxis._update_axisinfo() - axes.yaxis._update_axisinfo() + # Restore the unit data + axis.converter = axis_converter[name] + axis.set_units(axis_units[name]) # Set / Curves for index, curve in enumerate(curves): @@ -230,20 +239,22 @@ def apply_callback(data): # re-generate legend, if checkbox is checked if generate_legend: draggable = None - ncol = 1 + ncols = 1 if axes.legend_ is not None: old_legend = axes.get_legend() draggable = old_legend._draggable is not None - ncol = old_legend._ncol - new_legend = axes.legend(ncol=ncol) + ncols = old_legend._ncols + new_legend = axes.legend(ncols=ncols) if new_legend: new_legend.set_draggable(draggable) # Redraw figure = axes.get_figure() figure.canvas.draw() - if not (axes.get_xlim() == orig_xlim and axes.get_ylim() == orig_ylim): - figure.canvas.toolbar.push_current() + for name in axis_map: + if getattr(axes, f"get_{name}lim")() != orig_limits[name]: + figure.canvas.toolbar.push_current() + break _formlayout.fedit( datalist, title="Figure options", parent=parent, diff --git a/lib/matplotlib/backends/web_backend/all_figures.html b/lib/matplotlib/backends/web_backend/all_figures.html index c29ee55f4efe..62f04b65c9bf 100644 --- a/lib/matplotlib/backends/web_backend/all_figures.html +++ b/lib/matplotlib/backends/web_backend/all_figures.html @@ -1,8 +1,9 @@ - + + - - + + @@ -23,7 +24,9 @@ figure_div.id = "figure-div"; main_div.appendChild(figure_div); var websocket_type = mpl.get_websocket_type(); - var websocket = new websocket_type("{{ ws_uri }}" + fig_id + "/ws"); + var uri = "{{ ws_uri }}" + fig_id + "/ws"; + if (window.location.protocol === "https:") uri = uri.replace('ws:', 'wss:') + var websocket = new websocket_type(uri); var fig = new mpl.figure(fig_id, websocket, mpl_ondownload, figure_div); fig.focus_on_mouseover = true; diff --git a/lib/matplotlib/backends/web_backend/css/fbm.css b/lib/matplotlib/backends/web_backend/css/fbm.css index 4c6236fb58c7..ce35d99a5e64 100644 --- a/lib/matplotlib/backends/web_backend/css/fbm.css +++ b/lib/matplotlib/backends/web_backend/css/fbm.css @@ -1,95 +1,95 @@ /* Flexible box model classes */ /* Taken from Alex Russell https://infrequently.org/2009/08/css-3-progress/ */ - + .hbox { display: -webkit-box; -webkit-box-orient: horizontal; -webkit-box-align: stretch; - + display: -moz-box; -moz-box-orient: horizontal; -moz-box-align: stretch; - + display: box; box-orient: horizontal; box-align: stretch; } - + .hbox > * { -webkit-box-flex: 0; -moz-box-flex: 0; box-flex: 0; } - + .vbox { display: -webkit-box; -webkit-box-orient: vertical; -webkit-box-align: stretch; - + display: -moz-box; -moz-box-orient: vertical; -moz-box-align: stretch; - + display: box; box-orient: vertical; box-align: stretch; } - + .vbox > * { -webkit-box-flex: 0; -moz-box-flex: 0; box-flex: 0; } - + .reverse { -webkit-box-direction: reverse; -moz-box-direction: reverse; box-direction: reverse; } - + .box-flex0 { -webkit-box-flex: 0; -moz-box-flex: 0; box-flex: 0; } - + .box-flex1, .box-flex { -webkit-box-flex: 1; -moz-box-flex: 1; box-flex: 1; } - + .box-flex2 { -webkit-box-flex: 2; -moz-box-flex: 2; box-flex: 2; } - + .box-group1 { -webkit-box-flex-group: 1; -moz-box-flex-group: 1; box-flex-group: 1; } - + .box-group2 { -webkit-box-flex-group: 2; -moz-box-flex-group: 2; box-flex-group: 2; } - + .start { -webkit-box-pack: start; -moz-box-pack: start; box-pack: start; } - + .end { -webkit-box-pack: end; -moz-box-pack: end; box-pack: end; } - + .center { -webkit-box-pack: center; -moz-box-pack: center; diff --git a/lib/matplotlib/backends/web_backend/css/page.css b/lib/matplotlib/backends/web_backend/css/page.css index c380ef0a3ffc..ded0d9220379 100644 --- a/lib/matplotlib/backends/web_backend/css/page.css +++ b/lib/matplotlib/backends/web_backend/css/page.css @@ -80,4 +80,3 @@ span#login_widget { margin: 10px; vertical-align: top; } - diff --git a/lib/matplotlib/backends/web_backend/ipython_inline_figure.html b/lib/matplotlib/backends/web_backend/ipython_inline_figure.html index 9cc6aa9020e2..b941d352a7d6 100644 --- a/lib/matplotlib/backends/web_backend/ipython_inline_figure.html +++ b/lib/matplotlib/backends/web_backend/ipython_inline_figure.html @@ -2,7 +2,7 @@ websocket server, so we have to get in client-side and fetch our resources that way. --> @@ -18,8 +19,9 @@ ready( function () { var websocket_type = mpl.get_websocket_type(); - var websocket = new websocket_type( - "{{ ws_uri }}" + {{ str(fig_id) }} + "/ws"); + var uri = "{{ ws_uri }}" + {{ str(fig_id) }} + "/ws"; + if (window.location.protocol === 'https:') uri = uri.replace('ws:', 'wss:') + var websocket = new websocket_type(uri); var fig = new mpl.figure( {{ str(fig_id) }}, websocket, mpl_ondownload, document.getElementById("figure")); diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index de9e340d64ab..f310f287e2c0 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -1,5 +1,5 @@ """ -A module providing some utility functions regarding Bezier path manipulation. +A module providing some utility functions regarding Bézier path manipulation. """ from functools import lru_cache @@ -94,7 +94,7 @@ def _de_casteljau1(beta, t): def split_de_casteljau(beta, t): """ - Split a Bezier segment defined by its control points *beta* into two + Split a Bézier segment defined by its control points *beta* into two separate segments divided at *t* and return their control points. """ beta = np.asarray(beta) @@ -113,7 +113,7 @@ def split_de_casteljau(beta, t): def find_bezier_t_intersecting_with_closedpath( bezier_point_at_t, inside_closedpath, t0=0., t1=1., tolerance=0.01): """ - Find the intersection of the Bezier curve with a closed path. + Find the intersection of the Bézier curve with a closed path. The intersection point *t* is approximated by two parameters *t0*, *t1* such that *t0* <= *t* <= *t1*. @@ -126,7 +126,7 @@ def find_bezier_t_intersecting_with_closedpath( Parameters ---------- bezier_point_at_t : callable - A function returning x, y coordinates of the Bezier at parameter *t*. + A function returning x, y coordinates of the Bézier at parameter *t*. It must have the signature:: bezier_point_at_t(t: float) -> tuple[float, float] @@ -146,7 +146,7 @@ def find_bezier_t_intersecting_with_closedpath( Returns ------- t0, t1 : float - The Bezier path parameters. + The Bézier path parameters. """ start = bezier_point_at_t(t0) end = bezier_point_at_t(t1) @@ -172,7 +172,6 @@ def find_bezier_t_intersecting_with_closedpath( if start_inside ^ middle_inside: t1 = middle_t end = middle - end_inside = middle_inside else: t0 = middle_t start = middle @@ -181,7 +180,7 @@ def find_bezier_t_intersecting_with_closedpath( class BezierSegment: """ - A d-dimensional Bezier segment. + A d-dimensional Bézier segment. Parameters ---------- @@ -200,7 +199,7 @@ def __init__(self, control_points): def __call__(self, t): """ - Evaluate the Bezier curve at point(s) t in [0, 1]. + Evaluate the Bézier curve at point(s) *t* in [0, 1]. Parameters ---------- @@ -240,7 +239,7 @@ def degree(self): @property def polynomial_coefficients(self): r""" - The polynomial coefficients of the Bezier curve. + The polynomial coefficients of the Bézier curve. .. warning:: Follows opposite convention from `numpy.polyval`. @@ -248,7 +247,7 @@ def polynomial_coefficients(self): ------- (n+1, d) array Coefficients after expanding in polynomial basis, where :math:`n` - is the degree of the bezier curve and :math:`d` its dimension. + is the degree of the Bézier curve and :math:`d` its dimension. These are the numbers (:math:`C_j`) such that the curve can be written :math:`\sum_{j=0}^n C_j t^j`. @@ -309,12 +308,12 @@ def axis_aligned_extrema(self): def split_bezier_intersecting_with_closedpath( bezier, inside_closedpath, tolerance=0.01): """ - Split a Bezier curve into two at the intersection with a closed path. + Split a Bézier curve into two at the intersection with a closed path. Parameters ---------- bezier : (N, 2) array-like - Control points of the Bezier segment. See `.BezierSegment`. + Control points of the Bézier segment. See `.BezierSegment`. inside_closedpath : callable A function returning True if a given point (x, y) is inside the closed path. See also `.find_bezier_t_intersecting_with_closedpath`. @@ -325,7 +324,7 @@ def split_bezier_intersecting_with_closedpath( Returns ------- left, right - Lists of control points for the two Bezier segments. + Lists of control points for the two Bézier segments. """ bz = BezierSegment(bezier) @@ -462,13 +461,13 @@ def check_if_parallel(dx1, dy1, dx2, dy2, tolerance=1.e-5): def get_parallels(bezier2, width): """ - Given the quadratic Bezier control points *bezier2*, returns - control points of quadratic Bezier lines roughly parallel to given + Given the quadratic Bézier control points *bezier2*, returns + control points of quadratic Bézier lines roughly parallel to given one separated by *width*. """ # The parallel Bezier lines are constructed by following ways. - # c1 and c2 are control points representing the begin and end of the + # c1 and c2 are control points representing the start and end of the # Bezier line. # cm is the middle point @@ -486,7 +485,7 @@ def get_parallels(bezier2, width): cos_t2, sin_t2 = cos_t1, sin_t1 else: # t1 and t2 is the angle between c1 and cm, cm, c2. They are - # also a angle of the tangential line of the path at c1 and c2 + # also an angle of the tangential line of the path at c1 and c2 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy) cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c2x, c2y) @@ -536,7 +535,7 @@ def get_parallels(bezier2, width): def find_control_points(c1x, c1y, mmx, mmy, c2x, c2y): """ - Find control points of the Bezier curve passing through (*c1x*, *c1y*), + Find control points of the Bézier curve passing through (*c1x*, *c1y*), (*mmx*, *mmy*), and (*c2x*, *c2y*), at parametric values 0, 0.5, and 1. """ cmx = .5 * (4 * mmx - (c1x + c2x)) @@ -546,8 +545,8 @@ def find_control_points(c1x, c1y, mmx, mmy, c2x, c2y): def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.): """ - Being similar to get_parallels, returns control points of two quadratic - Bezier lines having a width roughly parallel to given one separated by + Being similar to `get_parallels`, returns control points of two quadratic + Bézier lines having a width roughly parallel to given one separated by *width*. """ @@ -557,7 +556,7 @@ def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.): c3x, c3y = bezier2[2] # t1 and t2 is the angle between c1 and cm, cm, c3. - # They are also a angle of the tangential line of the path at c1 and c3 + # They are also an angle of the tangential line of the path at c1 and c3 cos_t1, sin_t1 = get_cos_sin(c1x, c1y, cmx, cmy) cos_t2, sin_t2 = get_cos_sin(cmx, cmy, c3x, c3y) diff --git a/lib/matplotlib/blocking_input.py b/lib/matplotlib/blocking_input.py deleted file mode 100644 index bf986ca5237b..000000000000 --- a/lib/matplotlib/blocking_input.py +++ /dev/null @@ -1,354 +0,0 @@ -""" -Classes used for blocking interaction with figure windows: - -`BlockingInput` - Creates a callable object to retrieve events in a blocking way for - interactive sessions. Base class of the other classes listed here. - -`BlockingKeyMouseInput` - Creates a callable object to retrieve key or mouse clicks in a blocking - way for interactive sessions. Used by `~.Figure.waitforbuttonpress`. - -`BlockingMouseInput` - Creates a callable object to retrieve mouse clicks in a blocking way for - interactive sessions. Used by `~.Figure.ginput`. - -`BlockingContourLabeler` - Creates a callable object to retrieve mouse clicks in a blocking way that - will then be used to place labels on a `.ContourSet`. Used by - `~.Axes.clabel`. -""" - -import logging -from numbers import Integral - -from matplotlib import _api -from matplotlib.backend_bases import MouseButton -import matplotlib.lines as mlines - -_api.warn_deprecated("3.5", name=__name__, obj_type="module") -_log = logging.getLogger(__name__) - - -class BlockingInput: - """Callable for retrieving events in a blocking way.""" - - def __init__(self, fig, eventslist=()): - self.fig = fig - self.eventslist = eventslist - - def on_event(self, event): - """ - Event handler; will be passed to the current figure to retrieve events. - """ - # Add a new event to list - using a separate function is overkill for - # the base class, but this is consistent with subclasses. - self.add_event(event) - _log.info("Event %i", len(self.events)) - - # This will extract info from events. - self.post_event() - - # Check if we have enough events already. - if len(self.events) >= self.n > 0: - self.fig.canvas.stop_event_loop() - - def post_event(self): - """For baseclass, do nothing but collect events.""" - - def cleanup(self): - """Disconnect all callbacks.""" - for cb in self.callbacks: - self.fig.canvas.mpl_disconnect(cb) - self.callbacks = [] - - def add_event(self, event): - """For base class, this just appends an event to events.""" - self.events.append(event) - - def pop_event(self, index=-1): - """ - Remove an event from the event list -- by default, the last. - - Note that this does not check that there are events, much like the - normal pop method. If no events exist, this will throw an exception. - """ - self.events.pop(index) - - pop = pop_event - - def __call__(self, n=1, timeout=30): - """Blocking call to retrieve *n* events.""" - _api.check_isinstance(Integral, n=n) - self.n = n - self.events = [] - - if self.fig.canvas.manager: - # Ensure that the figure is shown, if we are managing it. - self.fig.show() - # Connect the events to the on_event function call. - self.callbacks = [self.fig.canvas.mpl_connect(name, self.on_event) - for name in self.eventslist] - try: - # Start event loop. - self.fig.canvas.start_event_loop(timeout=timeout) - finally: # Run even on exception like ctrl-c. - # Disconnect the callbacks. - self.cleanup() - # Return the events in this case. - return self.events - - -class BlockingMouseInput(BlockingInput): - """ - Callable for retrieving mouse clicks in a blocking way. - - This class will also retrieve keypresses and map them to mouse clicks: - delete and backspace are a right click, enter is like a middle click, - and all others are like a left click. - """ - - button_add = MouseButton.LEFT - button_pop = MouseButton.RIGHT - button_stop = MouseButton.MIDDLE - - def __init__(self, fig, - mouse_add=MouseButton.LEFT, - mouse_pop=MouseButton.RIGHT, - mouse_stop=MouseButton.MIDDLE): - super().__init__(fig=fig, - eventslist=('button_press_event', 'key_press_event')) - self.button_add = mouse_add - self.button_pop = mouse_pop - self.button_stop = mouse_stop - - def post_event(self): - """Process an event.""" - if len(self.events) == 0: - _log.warning("No events yet") - elif self.events[-1].name == 'key_press_event': - self.key_event() - else: - self.mouse_event() - - def mouse_event(self): - """Process a mouse click event.""" - event = self.events[-1] - button = event.button - if button == self.button_pop: - self.mouse_event_pop(event) - elif button == self.button_stop: - self.mouse_event_stop(event) - elif button == self.button_add: - self.mouse_event_add(event) - - def key_event(self): - """ - Process a key press event, mapping keys to appropriate mouse clicks. - """ - event = self.events[-1] - if event.key is None: - # At least in OSX gtk backend some keys return None. - return - if event.key in ['backspace', 'delete']: - self.mouse_event_pop(event) - elif event.key in ['escape', 'enter']: - self.mouse_event_stop(event) - else: - self.mouse_event_add(event) - - def mouse_event_add(self, event): - """ - Process an button-1 event (add a click if inside axes). - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - if event.inaxes: - self.add_click(event) - else: # If not a valid click, remove from event list. - BlockingInput.pop(self) - - def mouse_event_stop(self, event): - """ - Process an button-2 event (end blocking input). - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - # Remove last event just for cleanliness. - BlockingInput.pop(self) - # This will exit even if not in infinite mode. This is consistent with - # MATLAB and sometimes quite useful, but will require the user to test - # how many points were actually returned before using data. - self.fig.canvas.stop_event_loop() - - def mouse_event_pop(self, event): - """ - Process an button-3 event (remove the last click). - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - # Remove this last event. - BlockingInput.pop(self) - # Now remove any existing clicks if possible. - if self.events: - self.pop(event) - - def add_click(self, event): - """ - Add the coordinates of an event to the list of clicks. - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - self.clicks.append((event.xdata, event.ydata)) - _log.info("input %i: %f, %f", - len(self.clicks), event.xdata, event.ydata) - # If desired, plot up click. - if self.show_clicks: - line = mlines.Line2D([event.xdata], [event.ydata], - marker='+', color='r') - event.inaxes.add_line(line) - self.marks.append(line) - self.fig.canvas.draw() - - def pop_click(self, event, index=-1): - """ - Remove a click (by default, the last) from the list of clicks. - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - self.clicks.pop(index) - if self.show_clicks: - self.marks.pop(index).remove() - self.fig.canvas.draw() - - def pop(self, event, index=-1): - """ - Remove a click and the associated event from the list of clicks. - - Defaults to the last click. - """ - self.pop_click(event, index) - super().pop(index) - - def cleanup(self, event=None): - """ - Parameters - ---------- - event : `~.backend_bases.MouseEvent`, optional - Not used - """ - # Clean the figure. - if self.show_clicks: - for mark in self.marks: - mark.remove() - self.marks = [] - self.fig.canvas.draw() - # Call base class to remove callbacks. - super().cleanup() - - def __call__(self, n=1, timeout=30, show_clicks=True): - """ - Blocking call to retrieve *n* coordinate pairs through mouse clicks. - """ - self.show_clicks = show_clicks - self.clicks = [] - self.marks = [] - super().__call__(n=n, timeout=timeout) - return self.clicks - - -class BlockingContourLabeler(BlockingMouseInput): - """ - Callable for retrieving mouse clicks and key presses in a blocking way. - - Used to place contour labels. - """ - - def __init__(self, cs): - self.cs = cs - super().__init__(fig=cs.axes.figure) - - def add_click(self, event): - self.button1(event) - - def pop_click(self, event, index=-1): - self.button3(event) - - def button1(self, event): - """ - Process an button-1 event (add a label to a contour). - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - # Shorthand - if event.inaxes == self.cs.ax: - self.cs.add_label_near(event.x, event.y, self.inline, - inline_spacing=self.inline_spacing, - transform=False) - self.fig.canvas.draw() - else: # Remove event if not valid - BlockingInput.pop(self) - - def button3(self, event): - """ - Process an button-3 event (remove a label if not in inline mode). - - Unfortunately, if one is doing inline labels, then there is currently - no way to fix the broken contour - once humpty-dumpty is broken, he - can't be put back together. In inline mode, this does nothing. - - Parameters - ---------- - event : `~.backend_bases.MouseEvent` - """ - if self.inline: - pass - else: - self.cs.pop_label() - self.cs.ax.figure.canvas.draw() - - def __call__(self, inline, inline_spacing=5, n=-1, timeout=-1): - self.inline = inline - self.inline_spacing = inline_spacing - super().__call__(n=n, timeout=timeout, show_clicks=False) - - -class BlockingKeyMouseInput(BlockingInput): - """ - Callable for retrieving mouse clicks and key presses in a blocking way. - """ - - def __init__(self, fig): - super().__init__(fig=fig, - eventslist=('button_press_event', 'key_press_event')) - - def post_event(self): - """Determine if it is a key event.""" - if self.events: - self.keyormouse = self.events[-1].name == 'key_press_event' - else: - _log.warning("No events yet.") - - def __call__(self, timeout=30): - """ - Blocking call to retrieve a single mouse click or key press. - - Returns ``True`` if key press, ``False`` if mouse click, or ``None`` if - timed out. - """ - self.keyormouse = None - super().__call__(n=1, timeout=timeout) - - return self.keyormouse diff --git a/lib/matplotlib/category.py b/lib/matplotlib/category.py index 55f21a57ca96..4ac2379ea5f5 100644 --- a/lib/matplotlib/category.py +++ b/lib/matplotlib/category.py @@ -43,7 +43,7 @@ def convert(value, unit, axis): Returns ------- - float or ndarray[float] + float or `~numpy.ndarray` of float """ if unit is None: raise ValueError( @@ -53,17 +53,6 @@ def convert(value, unit, axis): StrCategoryConverter._validate_unit(unit) # dtype = object preserves numerical pass throughs values = np.atleast_1d(np.array(value, dtype=object)) - # pass through sequence of non binary numbers - with _api.suppress_matplotlib_deprecation_warning(): - is_numlike = all(units.ConversionInterface.is_numlike(v) - and not isinstance(v, (str, bytes)) - for v in values) - if values.size and is_numlike: - _api.warn_deprecated( - "3.5", message="Support for passing numbers through unit " - "converters is deprecated since %(since)s and support will be " - "removed %(removal)s; use Axis.convert_units instead.") - return np.asarray(values, dtype=float) # force an update so it also does type checking unit.update(values) return np.vectorize(unit._mapping.__getitem__, otypes=[float])(values) diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index fe964c250de9..1e51f6a834cc 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -22,25 +22,25 @@ import time import traceback import types -import warnings import weakref import numpy as np import matplotlib from matplotlib import _api, _c_internal_utils -from matplotlib._api.deprecation import ( - MatplotlibDeprecationWarning, mplDeprecation) -@_api.deprecated("3.4") -def deprecated(*args, **kwargs): - return _api.deprecated(*args, **kwargs) - - -@_api.deprecated("3.4") -def warn_deprecated(*args, **kwargs): - _api.warn_deprecated(*args, **kwargs) +@_api.caching_module_getattr +class __getattr__: + # module-level deprecations + MatplotlibDeprecationWarning = _api.deprecated( + "3.6", obj_type="", + alternative="matplotlib.MatplotlibDeprecationWarning")( + property(lambda self: _api.deprecation.MatplotlibDeprecationWarning)) + mplDeprecation = _api.deprecated( + "3.6", obj_type="", + alternative="matplotlib.MatplotlibDeprecationWarning")( + property(lambda self: _api.deprecation.MatplotlibDeprecationWarning)) def _get_running_interactive_framework(): @@ -132,9 +132,9 @@ class CallbackRegistry: for a set of signals and callbacks: >>> def oneat(x): - ... print('eat', x) + ... print('eat', x) >>> def ondrink(x): - ... print('drink', x) + ... print('drink', x) >>> from matplotlib.cbook import CallbackRegistry >>> callbacks = CallbackRegistry() @@ -146,13 +146,13 @@ class CallbackRegistry: drink 123 >>> callbacks.process('eat', 456) eat 456 - >>> callbacks.process('be merry', 456) # nothing will be called + >>> callbacks.process('be merry', 456) # nothing will be called >>> callbacks.disconnect(id_eat) - >>> callbacks.process('eat', 456) # nothing will be called + >>> callbacks.process('eat', 456) # nothing will be called >>> with callbacks.blocked(signal='drink'): - ... callbacks.process('drink', 123) # nothing will be called + ... callbacks.process('drink', 123) # nothing will be called >>> callbacks.process('drink', 123) drink 123 @@ -175,13 +175,20 @@ class CallbackRegistry: The default handler prints the exception (with `traceback.print_exc`) if an interactive event loop is running; it re-raises the exception if no interactive event loop is running. + + signals : list, optional + If not None, *signals* is a list of signals that this registry handles: + attempting to `process` or to `connect` to a signal not in the list + throws a `ValueError`. The default, None, does not restrict the + handled signals. """ # We maintain two mappings: # callbacks: signal -> {cid -> weakref-to-callback} # _func_cid_map: signal -> {weakref-to-callback -> cid} - def __init__(self, exception_handler=_exception_printer): + def __init__(self, exception_handler=_exception_printer, *, signals=None): + self._signals = None if signals is None else list(signals) # Copy it. self.exception_handler = exception_handler self.callbacks = {} self._cid_gen = itertools.count() @@ -211,12 +218,10 @@ def __setstate__(self, state): s: {proxy: cid for cid, proxy in d.items()} for s, d in self.callbacks.items()} - @_api.rename_parameter("3.4", "s", "signal") def connect(self, signal, func): """Register *func* to be called when signal *signal* is generated.""" - if signal == "units finalize": - _api.warn_deprecated( - "3.5", name=signal, obj_type="signal", alternative="units") + if self._signals is not None: + _api.check_in_list(self._signals, signal=signal) self._func_cid_map.setdefault(signal, {}) proxy = _weak_or_strong_ref(func, self._remove_proxy) if proxy in self._func_cid_map[signal]: @@ -227,6 +232,16 @@ def connect(self, signal, func): self.callbacks[signal][cid] = proxy return cid + def _connect_picklable(self, signal, func): + """ + Like `.connect`, but the callback is kept when pickling/unpickling. + + Currently internal-use only. + """ + cid = self.connect(signal, func) + self._pickled_cids.add(cid) + return cid + # Keep a reference to sys.is_finalizing, as sys may have been cleared out # at that point. def _remove_proxy(self, proxy, *, _is_finalizing=sys.is_finalizing): @@ -280,6 +295,8 @@ def process(self, s, *args, **kwargs): All of the functions registered to receive callbacks on *s* will be called with ``*args`` and ``**kwargs``. """ + if self._signals is not None: + _api.check_in_list(self._signals, signal=s) for cid, ref in list(self.callbacks.get(s, {}).items()): func = ref() if func is not None: @@ -354,7 +371,8 @@ def __repr__(self): def _local_over_kwdict( - local_var, kwargs, *keys, warning_cls=MatplotlibDeprecationWarning): + local_var, kwargs, *keys, + warning_cls=_api.MatplotlibDeprecationWarning): out = local_var for key in keys: kwarg_val = kwargs.pop(key, None) @@ -390,6 +408,26 @@ def strip_math(s): return s +def _strip_comment(s): + """Strip everything from the first unquoted #.""" + pos = 0 + while True: + quote_pos = s.find('"', pos) + hash_pos = s.find('#', pos) + if quote_pos < 0: + without_comment = s if hash_pos < 0 else s[:hash_pos] + return without_comment.strip() + elif 0 <= hash_pos < quote_pos: + return s[:hash_pos].strip() + else: + closing_quote_pos = s.find('"', quote_pos + 1) + if closing_quote_pos < 0: + raise ValueError( + f"Missing closing quote in: {s!r}. If you need a double-" + 'quote inside a string, use escaping: e.g. "the \" char"') + pos = closing_quote_pos + 1 # behind closing quote + + def is_writable_file_like(obj): """Return whether *obj* looks like a file object with a *write* method.""" return callable(getattr(obj, 'write', None)) @@ -540,6 +578,7 @@ def flatten(seq, scalarp=is_scalar_or_string): yield from flatten(item, scalarp) +@_api.deprecated("3.6", alternative="functools.lru_cache") class maxdict(dict): """ A dictionary with a maximum size. @@ -666,37 +705,6 @@ def remove(self, o): self.push(elem) -@_api.deprecated("3.5", alternative="psutil.virtual_memory") -def report_memory(i=0): # argument may go away - """Return the memory consumed by the process.""" - def call(command, os_name): - try: - return subprocess.check_output(command) - except subprocess.CalledProcessError as err: - raise NotImplementedError( - "report_memory works on %s only if " - "the '%s' program is found" % (os_name, command[0]) - ) from err - - pid = os.getpid() - if sys.platform == 'sunos5': - lines = call(['ps', '-p', '%d' % pid, '-o', 'osz'], 'Sun OS') - mem = int(lines[-1].strip()) - elif sys.platform == 'linux': - lines = call(['ps', '-p', '%d' % pid, '-o', 'rss,sz'], 'Linux') - mem = int(lines[1].split()[1]) - elif sys.platform == 'darwin': - lines = call(['ps', '-p', '%d' % pid, '-o', 'rss,vsz'], 'Mac OS') - mem = int(lines[1].split()[0]) - elif sys.platform == 'win32': - lines = call(["tasklist", "/nh", "/fi", "pid eq %d" % pid], 'Windows') - mem = int(lines.strip().split()[-2].replace(',', '')) - else: - raise NotImplementedError( - "We don't have a memory monitor for %s" % sys.platform) - return mem - - def safe_masked_invalid(x, copy=False): x = np.array(x, subok=True, copy=copy) if not x.dtype.isnative: @@ -876,6 +884,34 @@ def get_siblings(self, a): return [x() for x in siblings] +class GrouperView: + """Immutable view over a `.Grouper`.""" + + def __init__(self, grouper): + self._grouper = grouper + + class _GrouperMethodForwarder: + def __init__(self, deprecated_kw=None): + self._deprecated_kw = deprecated_kw + + def __set_name__(self, owner, name): + wrapped = getattr(Grouper, name) + forwarder = functools.wraps(wrapped)( + lambda self, *args, **kwargs: wrapped( + self._grouper, *args, **kwargs)) + if self._deprecated_kw: + forwarder = _api.deprecated(**self._deprecated_kw)(forwarder) + setattr(owner, name, forwarder) + + __contains__ = _GrouperMethodForwarder() + __iter__ = _GrouperMethodForwarder() + joined = _GrouperMethodForwarder() + get_siblings = _GrouperMethodForwarder() + clean = _GrouperMethodForwarder(deprecated_kw=dict(since="3.6")) + join = _GrouperMethodForwarder(deprecated_kw=dict(since="3.6")) + remove = _GrouperMethodForwarder(deprecated_kw=dict(since="3.6")) + + def simple_linear_interpolation(a, steps): """ Resample an array with ``steps - 1`` points between original point pairs. @@ -948,7 +984,7 @@ def delete_masked_points(*args): else: x = np.asarray(x) margs.append(x) - masks = [] # list of masks that are True where good + masks = [] # List of masks that are True where good. for i, x in enumerate(margs): if seqlist[i]: if x.ndim > 1: @@ -1100,6 +1136,7 @@ def boxplot_stats(X, whis=1.5, bootstrap=None, labels=None, med 50th percentile q1 first quartile (25th percentile) q3 third quartile (75th percentile) + iqr interquartile range cilo lower notch around the median cihi upper notch around the median whislo end of the lower whisker @@ -1181,11 +1218,11 @@ def _compute_conf_interval(data, med, iqr, bootstrap): stats['med'] = np.nan stats['q1'] = np.nan stats['q3'] = np.nan + stats['iqr'] = np.nan stats['cilo'] = np.nan stats['cihi'] = np.nan stats['whislo'] = np.nan stats['whishi'] = np.nan - stats['med'] = np.nan continue # up-convert to an array, just to be safe @@ -1302,7 +1339,12 @@ def _check_1d(x): """Convert scalars to 1D arrays; pass-through arrays as is.""" # Unpack in case of e.g. Pandas or xarray object x = _unpack_to_numpy(x) - if not hasattr(x, 'shape') or len(x.shape) < 1: + # plot requires `shape` and `ndim`. If passed an + # object that doesn't provide them, then force to numpy array. + # Note this will strip unit information. + if (not hasattr(x, 'shape') or + not hasattr(x, 'ndim') or + len(x.shape) < 1): return np.atleast_1d(x) else: return x @@ -1433,7 +1475,7 @@ def violin_stats(X, method, points=100, quantiles=None): else: quantiles = [[]] * len(X) - # quantiles should has the same size as dataset + # quantiles should have the same size as dataset if len(X) != len(quantiles): raise ValueError("List of violinplot statistics and quantiles values" " must have the same length") @@ -1624,22 +1666,53 @@ def safe_first_element(obj): """ Return the first element in *obj*. - This is an type-independent way of obtaining the first element, supporting - both index access and the iterator protocol. + This is a type-independent way of obtaining the first element, + supporting both index access and the iterator protocol. + """ + return _safe_first_finite(obj, skip_nonfinite=False) + + +def _safe_first_finite(obj, *, skip_nonfinite=True): """ - if isinstance(obj, collections.abc.Iterator): - # needed to accept `array.flat` as input. - # np.flatiter reports as an instance of collections.Iterator - # but can still be indexed via []. - # This has the side effect of re-setting the iterator, but - # that is acceptable. + Return the first non-None (and optionally finite) element in *obj*. + + This is a method for internal use. + + This is a type-independent way of obtaining the first non-None element, + supporting both index access and the iterator protocol. + The first non-None element will be obtained when skip_none is True. + """ + def safe_isfinite(val): + if val is None: + return False try: - return obj[0] + return np.isfinite(val) if np.isscalar(val) else True except TypeError: - pass - raise RuntimeError("matplotlib does not support generators " - "as input") - return next(iter(obj)) + # This is something that numpy can not make heads or tails + # of, assume "finite" + return True + if skip_nonfinite is False: + if isinstance(obj, collections.abc.Iterator): + # needed to accept `array.flat` as input. + # np.flatiter reports as an instance of collections.Iterator + # but can still be indexed via []. + # This has the side effect of re-setting the iterator, but + # that is acceptable. + try: + return obj[0] + except TypeError: + pass + raise RuntimeError("matplotlib does not support generators " + "as input") + return next(iter(obj)) + elif isinstance(obj, np.flatiter): + # TODO do the finite filtering on this + return obj[0] + elif isinstance(obj, collections.abc.Iterator): + raise RuntimeError("matplotlib does not " + "support generators as input") + else: + return next(val for val in obj if safe_isfinite(val)) def sanitize_sequence(data): @@ -1780,61 +1853,6 @@ def _str_lower_equal(obj, s): return isinstance(obj, str) and obj.lower() == s -def _define_aliases(alias_d, cls=None): - """ - Class decorator for defining property aliases. - - Use as :: - - @cbook._define_aliases({"property": ["alias", ...], ...}) - class C: ... - - For each property, if the corresponding ``get_property`` is defined in the - class so far, an alias named ``get_alias`` will be defined; the same will - be done for setters. If neither the getter nor the setter exists, an - exception will be raised. - - The alias map is stored as the ``_alias_map`` attribute on the class and - can be used by `.normalize_kwargs` (which assumes that higher priority - aliases come last). - """ - if cls is None: # Return the actual class decorator. - return functools.partial(_define_aliases, alias_d) - - def make_alias(name): # Enforce a closure over *name*. - @functools.wraps(getattr(cls, name)) - def method(self, *args, **kwargs): - return getattr(self, name)(*args, **kwargs) - return method - - for prop, aliases in alias_d.items(): - exists = False - for prefix in ["get_", "set_"]: - if prefix + prop in vars(cls): - exists = True - for alias in aliases: - method = make_alias(prefix + prop) - method.__name__ = prefix + alias - method.__doc__ = "Alias for `{}`.".format(prefix + prop) - setattr(cls, prefix + alias, method) - if not exists: - raise ValueError( - "Neither getter nor setter exists for {!r}".format(prop)) - - def get_aliased_and_aliases(d): - return {*d, *(alias for aliases in d.values() for alias in aliases)} - - preexisting_aliases = getattr(cls, "_alias_map", {}) - conflicting = (get_aliased_and_aliases(preexisting_aliases) - & get_aliased_and_aliases(alias_d)) - if conflicting: - # Need to decide on conflict resolution policy. - raise NotImplementedError( - f"Parent class already defines conflicting aliases: {conflicting}") - cls._alias_map = {**preexisting_aliases, **alias_d} - return cls - - def _array_perimeter(arr): """ Get the elements on the perimeter of *arr*. @@ -1853,7 +1871,7 @@ def _array_perimeter(arr): Examples -------- - >>> i, j = np.ogrid[:3,:4] + >>> i, j = np.ogrid[:3, :4] >>> a = i*10 + j >>> a array([[ 0, 1, 2, 3], @@ -1897,7 +1915,7 @@ def _unfold(arr, axis, size, step): Examples -------- - >>> i, j = np.ogrid[:3,:7] + >>> i, j = np.ogrid[:3, :7] >>> a = i*10 + j >>> a array([[ 0, 1, 2, 3, 4, 5, 6], @@ -2038,7 +2056,7 @@ def discard(self, key): self._od.pop(key, None) -# Agg's buffers are unmultiplied RGBA8888, which neither PyQt5 nor cairo +# Agg's buffers are unmultiplied RGBA8888, which neither PyQt<=5.1 nor cairo # support; however, both do support premultiplied ARGB32. @@ -2177,7 +2195,7 @@ def _g_sig_digits(value, delta): if delta == 0: # delta = 0 may occur when trying to format values over a tiny range; # in that case, replace it by the distance to the closest float. - delta = np.spacing(value) + delta = abs(np.spacing(value)) # If e.g. value = 45.67 and delta = 0.02, then we want to round to 2 digits # after the decimal point (floor(log10(0.02)) = -2); 45.67 contributes 2 # digits before the decimal point (floor(log10(45.67)) + 1 = 2): the total @@ -2284,3 +2302,30 @@ def _unpack_to_numpy(x): if isinstance(xtmp, np.ndarray): return xtmp return x + + +def _auto_format_str(fmt, value): + """ + Apply *value* to the format string *fmt*. + + This works both with unnamed %-style formatting and + unnamed {}-style formatting. %-style formatting has priority. + If *fmt* is %-style formattable that will be used. Otherwise, + {}-formatting is applied. Strings without formatting placeholders + are passed through as is. + + Examples + -------- + >>> _auto_format_str('%.2f m', 0.2) + '0.20 m' + >>> _auto_format_str('{} m', 0.2) + '0.2 m' + >>> _auto_format_str('const', 0.2) + 'const' + >>> _auto_format_str('%d or {}', 0.2) + '0 or {}' + """ + try: + return fmt % (value,) + except (TypeError, ValueError): + return fmt.format(value) diff --git a/lib/matplotlib/cbook/deprecation.py b/lib/matplotlib/cbook/deprecation.py deleted file mode 100644 index 5a0f02f7decf..000000000000 --- a/lib/matplotlib/cbook/deprecation.py +++ /dev/null @@ -1,8 +0,0 @@ -# imports are for backward compatibility -from matplotlib._api.deprecation import ( - MatplotlibDeprecationWarning, mplDeprecation, warn_deprecated, deprecated) - -warn_deprecated("3.4", - message="The module matplotlib.cbook.deprecation is " - "considered internal and it will be made private in the " - "future.") diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index 4bc1e8b05d33..d170b7d6b37f 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -15,24 +15,18 @@ normalization. """ -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping +import functools import numpy as np from numpy import ma import matplotlib as mpl -from matplotlib import _api, colors, cbook +from matplotlib import _api, colors, cbook, scale from matplotlib._cm import datad from matplotlib._cm_listed import cmaps as cmaps_listed -@_api.caching_module_getattr # module-level deprecations -class __getattr__: - LUTSIZE = _api.deprecated( - "3.5", obj_type="", alternative="rcParams['image.lut']")( - property(lambda self: _LUTSIZE)) - - _LUTSIZE = mpl.rcParams['image.lut'] @@ -52,62 +46,14 @@ def _gen_cmap_registry(): # Generate reversed cmaps. for cmap in list(cmap_d.values()): rmap = cmap.reversed() - cmap._global = True - rmap._global = True cmap_d[rmap.name] = rmap return cmap_d -class _DeprecatedCmapDictWrapper(MutableMapping): - """Dictionary mapping for deprecated _cmap_d access.""" - - def __init__(self, cmap_registry): - self._cmap_registry = cmap_registry - - def __delitem__(self, key): - self._warn_deprecated() - self._cmap_registry.__delitem__(key) - - def __getitem__(self, key): - self._warn_deprecated() - return self._cmap_registry.__getitem__(key) - - def __iter__(self): - self._warn_deprecated() - return self._cmap_registry.__iter__() - - def __len__(self): - self._warn_deprecated() - return self._cmap_registry.__len__() - - def __setitem__(self, key, val): - self._warn_deprecated() - self._cmap_registry.__setitem__(key, val) - - def get(self, key, default=None): - self._warn_deprecated() - return self._cmap_registry.get(key, default) - - def _warn_deprecated(self): - _api.warn_deprecated( - "3.3", - message="The global colormaps dictionary is no longer " - "considered public API.", - alternative="Please use register_cmap() and get_cmap() to " - "access the contents of the dictionary." - ) - - class ColormapRegistry(Mapping): r""" Container for colormaps that are known to Matplotlib by name. - .. admonition:: Experimental - - While we expect the API to be final, we formally mark it as - experimental for 3.5 because we want to keep the option to still adapt - the API for 3.6 should the need arise. - The universal registry instance is `matplotlib.colormaps`. There should be no need for users to instantiate `.ColormapRegistry` themselves. @@ -125,12 +71,15 @@ class ColormapRegistry(Mapping): """ def __init__(self, cmaps): self._cmaps = cmaps + self._builtin_cmaps = tuple(cmaps) + # A shim to allow register_cmap() to force an override + self._allow_override_builtin = False def __getitem__(self, item): try: return self._cmaps[item].copy() except KeyError: - raise KeyError(f"{item!r} is not a known colormap name") + raise KeyError(f"{item!r} is not a known colormap name") from None def __iter__(self): return iter(self._cmaps) @@ -146,7 +95,7 @@ def __call__(self): """ Return a list of the registered colormap names. - This exists only for backward-compatibilty in `.pyplot` which had a + This exists only for backward-compatibility in `.pyplot` which had a ``plt.colormaps()`` method. The recommended way to get this list is now ``list(colormaps)``. """ @@ -172,30 +121,106 @@ def register(self, cmap, *, name=None, force=False): name : str, optional The name for the colormap. If not given, ``cmap.name`` is used. - force: bool, default: False + force : bool, default: False If False, a ValueError is raised if trying to overwrite an already registered name. True supports overwriting registered colormaps other than the builtin colormaps. """ + _api.check_isinstance(colors.Colormap, cmap=cmap) + name = name or cmap.name - if name in self and not force: - raise ValueError( - f'A colormap named "{name}" is already registered.') - register_cmap(name, cmap.copy()) + if name in self: + if not force: + # don't allow registering an already existing cmap + # unless explicitly asked to + raise ValueError( + f'A colormap named "{name}" is already registered.') + elif (name in self._builtin_cmaps + and not self._allow_override_builtin): + # We don't allow overriding a builtin unless privately + # coming from register_cmap() + raise ValueError("Re-registering the builtin cmap " + f"{name!r} is not allowed.") + + # Warn that we are updating an already existing colormap + _api.warn_external(f"Overwriting the cmap {name!r} " + "that was already in the registry.") + self._cmaps[name] = cmap.copy() + + def unregister(self, name): + """ + Remove a colormap from the registry. + + You cannot remove built-in colormaps. + + If the named colormap is not registered, returns with no error, raises + if you try to de-register a default colormap. + + .. warning:: + + Colormap names are currently a shared namespace that may be used + by multiple packages. Use `unregister` only if you know you + have registered that name before. In particular, do not + unregister just in case to clean the name before registering a + new colormap. + + Parameters + ---------- + name : str + The name of the colormap to be removed. + + Raises + ------ + ValueError + If you try to remove a default built-in colormap. + """ + if name in self._builtin_cmaps: + raise ValueError(f"cannot unregister {name!r} which is a builtin " + "colormap.") + self._cmaps.pop(name, None) + + def get_cmap(self, cmap): + """ + Return a color map specified through *cmap*. + + Parameters + ---------- + cmap : str or `~matplotlib.colors.Colormap` or None + + - if a `.Colormap`, return it + - if a string, look it up in ``mpl.colormaps`` + - if None, return the Colormap defined in :rc:`image.cmap` + + Returns + ------- + Colormap + """ + # get the default color map + if cmap is None: + return self[mpl.rcParams["image.cmap"]] + + # if the user passed in a Colormap, simply return it + if isinstance(cmap, colors.Colormap): + return cmap + if isinstance(cmap, str): + _api.check_in_list(sorted(_colormaps), cmap=cmap) + # otherwise, it must be a string so look it up + return self[cmap] + raise TypeError( + 'get_cmap expects None or an instance of a str or Colormap . ' + + f'you passed {cmap!r} of type {type(cmap)}' + ) -_cmap_registry = _gen_cmap_registry() -globals().update(_cmap_registry) -# This is no longer considered public API -cmap_d = _DeprecatedCmapDictWrapper(_cmap_registry) -__builtin_cmaps = tuple(_cmap_registry) # public access to the colormaps should be via `matplotlib.colormaps`. For now, # we still create the registry here, but that should stay an implementation # detail. -_colormaps = ColormapRegistry(_cmap_registry) +_colormaps = ColormapRegistry(_gen_cmap_registry()) +globals().update(_colormaps) +@_api.deprecated("3.7", alternative="``matplotlib.colormaps.register(name)``") def register_cmap(name=None, cmap=None, *, override_builtin=False): """ Add a colormap to the set recognized by :func:`get_cmap`. @@ -223,14 +248,6 @@ def register_cmap(name=None, cmap=None, *, override_builtin=False): colormap. Please do not use this unless you are sure you need it. - - Notes - ----- - Registering a colormap stores a reference to the colormap object - which can currently be modified and inadvertently change the global - colormap state. This behavior is deprecated and in Matplotlib 3.5 - the registered colormap will be immutable. - """ _api.check_isinstance((str, None), name=name) if name is None: @@ -239,36 +256,18 @@ def register_cmap(name=None, cmap=None, *, override_builtin=False): except AttributeError as err: raise ValueError("Arguments must include a name or a " "Colormap") from err - if name in _cmap_registry: - if not override_builtin and name in __builtin_cmaps: - msg = f"Trying to re-register the builtin cmap {name!r}." - raise ValueError(msg) - else: - msg = f"Trying to register the cmap {name!r} which already exists." - _api.warn_external(msg) - - if not isinstance(cmap, colors.Colormap): - raise ValueError("You must pass a Colormap instance. " - f"You passed {cmap} a {type(cmap)} object.") + # override_builtin is allowed here for backward compatibility + # this is just a shim to enable that to work privately in + # the global ColormapRegistry + _colormaps._allow_override_builtin = override_builtin + _colormaps.register(cmap, name=name, force=override_builtin) + _colormaps._allow_override_builtin = False - cmap._global = True - _cmap_registry[name] = cmap - return - -def get_cmap(name=None, lut=None): +def _get_cmap(name=None, lut=None): """ Get a colormap instance, defaulting to rc values if *name* is None. - Colormaps added with :func:`register_cmap` take precedence over - built-in colormaps. - - Notes - ----- - Currently, this returns the global colormap object. This is undesired - because users could accidentally modify the global colormap. - From Matplotlib 3.6 on, this will return a copy instead. - Parameters ---------- name : `matplotlib.colors.Colormap` or str or None, default: None @@ -278,18 +277,35 @@ def get_cmap(name=None, lut=None): lut : int or None, default: None If *name* is not already a Colormap instance and *lut* is not None, the colormap will be resampled to have *lut* entries in the lookup table. + + Returns + ------- + Colormap """ if name is None: name = mpl.rcParams['image.cmap'] if isinstance(name, colors.Colormap): return name - _api.check_in_list(sorted(_cmap_registry), name=name) + _api.check_in_list(sorted(_colormaps), name=name) if lut is None: - return _cmap_registry[name] + return _colormaps[name] else: - return _cmap_registry[name]._resample(lut) - - + return _colormaps[name].resampled(lut) + +# do it in two steps like this so we can have an un-deprecated version in +# pyplot. +get_cmap = _api.deprecated( + '3.7', + name='get_cmap', + alternative=( + "``matplotlib.colormaps[name]`` " + + "or ``matplotlib.colormaps.get_cmap(obj)``" + ) +)(_get_cmap) + + +@_api.deprecated("3.7", + alternative="``matplotlib.colormaps.unregister(name)``") def unregister_cmap(name): """ Remove a colormap recognized by :func:`get_cmap`. @@ -299,7 +315,7 @@ def unregister_cmap(name): If the named colormap is not registered, returns with no error, raises if you try to de-register a default colormap. - .. warning :: + .. warning:: Colormap names are currently a shared namespace that may be used by multiple packages. Use `unregister_cmap` only if you know you @@ -321,14 +337,38 @@ def unregister_cmap(name): ------ ValueError If you try to de-register a default built-in colormap. + """ + cmap = _colormaps.get(name, None) + _colormaps.unregister(name) + return cmap + + +def _auto_norm_from_scale(scale_cls): + """ + Automatically generate a norm class from *scale_cls*. + + This differs from `.colors.make_norm_from_scale` in the following points: + + - This function is not a class decorator, but directly returns a norm class + (as if decorating `.Normalize`). + - The scale is automatically constructed with ``nonpositive="mask"``, if it + supports such a parameter, to work around the difference in defaults + between standard scales (which use "clip") and norms (which use "mask"). + Note that ``make_norm_from_scale`` caches the generated norm classes + (not the instances) and reuses them for later calls. For example, + ``type(_auto_norm_from_scale("log")) == LogNorm``. """ - if name not in _cmap_registry: - return - if name in __builtin_cmaps: - raise ValueError(f"cannot unregister {name!r} which is a builtin " - "colormap.") - return _cmap_registry.pop(name) + # Actually try to construct an instance, to verify whether + # ``nonpositive="mask"`` is supported. + try: + norm = colors.make_norm_from_scale( + functools.partial(scale_cls, nonpositive="mask"))( + colors.Normalize)() + except TypeError: + norm = colors.make_norm_from_scale(scale_cls)( + colors.Normalize)() + return type(norm) class ScalarMappable: @@ -341,12 +381,13 @@ class ScalarMappable: def __init__(self, norm=None, cmap=None): """ - Parameters ---------- - norm : `matplotlib.colors.Normalize` (or subclass thereof) + norm : `.Normalize` (or subclass thereof) or str or None The normalizing object which scales data, typically into the interval ``[0, 1]``. + If a `str`, a `.Normalize` subclass is dynamically generated based + on the scale with the corresponding name. If *None*, *norm* defaults to a *colors.Normalize* object which initializes its scaling based on the first data processed. cmap : str or `~matplotlib.colors.Colormap` @@ -359,10 +400,7 @@ def __init__(self, norm=None, cmap=None): self.set_cmap(cmap) # The Colormap instance of this ScalarMappable. #: The last colorbar associated with this ScalarMappable. May be None. self.colorbar = None - self.callbacks = cbook.CallbackRegistry() - - callbacksSM = _api.deprecated("3.5", alternative="callbacks")( - property(lambda self: self.callbacks)) + self.callbacks = cbook.CallbackRegistry(signals=["changed"]) def _scale_norm(self, norm, vmin, vmax): """ @@ -376,11 +414,11 @@ def _scale_norm(self, norm, vmin, vmax): """ if vmin is not None or vmax is not None: self.set_clim(vmin, vmax) - if norm is not None: + if isinstance(norm, colors.Normalize): raise ValueError( - "Passing parameters norm and vmin/vmax simultaneously is " - "not supported. Please pass vmin/vmax directly to the " - "norm when creating it.") + "Passing a Normalize instance simultaneously with " + "vmin/vmax is not supported. Please pass vmin/vmax " + "directly to the norm when creating it.") # always resolve the autoscaling so we have concrete limits # rather than deferring to draw time. @@ -391,12 +429,12 @@ def to_rgba(self, x, alpha=None, bytes=False, norm=True): Return a normalized rgba array corresponding to *x*. In the normal case, *x* is a 1D or 2D sequence of scalars, and - the corresponding ndarray of rgba values will be returned, + the corresponding `~numpy.ndarray` of rgba values will be returned, based on the norm and colormap set for this ScalarMappable. There is one special case, for handling images that are already rgb or rgba, such as might have been read from an image file. - If *x* is an ndarray with 3 dimensions, + If *x* is an `~numpy.ndarray` with 3 dimensions, and the last dimension is either 3 or 4, then it will be treated as an rgb or rgba array, and no mapping will be done. The array can be uint8, or it can be floating point with @@ -405,7 +443,7 @@ def to_rgba(self, x, alpha=None, bytes=False, norm=True): If the last dimension is 3, the *alpha* kwarg (defaulting to 1) will be used to fill in the transparency. If the last dimension is 4, the *alpha* kwarg is ignored; it does not - replace the pre-existing alpha. A ValueError will be raised + replace the preexisting alpha. A ValueError will be raised if the third dimension is other than 3 or 4. In either case, if *bytes* is *False* (default), the rgba @@ -543,8 +581,8 @@ def set_cmap(self, cmap): cmap : `.Colormap` or str or None """ in_init = self.cmap is None - cmap = get_cmap(cmap) - self.cmap = cmap + + self.cmap = _ensure_cmap(cmap) if not in_init: self.changed() # Things are not set up properly yet. @@ -554,9 +592,18 @@ def norm(self): @norm.setter def norm(self, norm): - _api.check_isinstance((colors.Normalize, None), norm=norm) + _api.check_isinstance((colors.Normalize, str, None), norm=norm) if norm is None: norm = colors.Normalize() + elif isinstance(norm, str): + try: + scale_cls = scale._scale_mapping[norm] + except KeyError: + raise ValueError( + "Invalid norm str name; the following values are " + "supported: {}".format(", ".join(scale._scale_mapping)) + ) from None + norm = _auto_norm_from_scale(scale_cls)() if norm is self.norm: # We aren't updating anything @@ -578,7 +625,7 @@ def set_norm(self, norm): Parameters ---------- - norm : `.Normalize` or None + norm : `.Normalize` or str or None Notes ----- @@ -617,3 +664,61 @@ def changed(self): """ self.callbacks.process('changed', self) self.stale = True + + +# The docstrings here must be generic enough to apply to all relevant methods. +mpl._docstring.interpd.update( + cmap_doc="""\ +cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` + The Colormap instance or registered colormap name used to map scalar data + to colors.""", + norm_doc="""\ +norm : str or `~matplotlib.colors.Normalize`, optional + The normalization method used to scale scalar data to the [0, 1] range + before mapping to colors using *cmap*. By default, a linear scaling is + used, mapping the lowest value to 0 and the highest to 1. + + If given, this can be one of the following: + + - An instance of `.Normalize` or one of its subclasses + (see :doc:`/tutorials/colors/colormapnorms`). + - A scale name, i.e. one of "linear", "log", "symlog", "logit", etc. For a + list of available scales, call `matplotlib.scale.get_scale_names()`. + In that case, a suitable `.Normalize` subclass is dynamically generated + and instantiated.""", + vmin_vmax_doc="""\ +vmin, vmax : float, optional + When using scalar data and no explicit *norm*, *vmin* and *vmax* define + the data range that the colormap covers. By default, the colormap covers + the complete value range of the supplied data. It is an error to use + *vmin*/*vmax* when a *norm* instance is given (but using a `str` *norm* + name together with *vmin*/*vmax* is acceptable).""", +) + + +def _ensure_cmap(cmap): + """ + Ensure that we have a `.Colormap` object. + + For internal use to preserve type stability of errors. + + Parameters + ---------- + cmap : None, str, Colormap + + - if a `Colormap`, return it + - if a string, look it up in mpl.colormaps + - if None, look up the default color map in mpl.colormaps + + Returns + ------- + Colormap + + """ + if isinstance(cmap, colors.Colormap): + return cmap + cmap_name = cmap if cmap is not None else mpl.rcParams["image.cmap"] + # use check_in_list to ensure type stability of the exception raised by + # the internal usage of this (ValueError vs KeyError) + _api.check_in_list(sorted(_colormaps), cmap=cmap_name) + return mpl.colormaps[cmap_name] diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index d1ac18f79764..bf88dd2b68a3 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -9,7 +9,6 @@ line segments). """ -import inspect import math from numbers import Number import warnings @@ -17,19 +16,20 @@ import numpy as np import matplotlib as mpl -from . import (_api, _path, artist, cbook, cm, colors as mcolors, docstring, +from . import (_api, _path, artist, cbook, cm, colors as mcolors, _docstring, hatch as mhatch, lines as mlines, path as mpath, transforms) from ._enums import JoinStyle, CapStyle # "color" is excluded; it is a compound setter, and its docstring differs # in LineCollection. -@cbook._define_aliases({ +@_api.define_aliases({ "antialiased": ["antialiaseds", "aa"], "edgecolor": ["edgecolors", "ec"], "facecolor": ["facecolors", "fc"], "linestyle": ["linestyles", "dashes", "ls"], "linewidth": ["linewidths", "lw"], + "offset_transform": ["transOffset"], }) class Collection(artist.Artist, cm.ScalarMappable): r""" @@ -73,7 +73,8 @@ class Collection(artist.Artist, cm.ScalarMappable): # subclass-by-subclass basis. _edge_default = False - @docstring.interpd + @_docstring.interpd + @_api.make_keyword_only("3.6", name="edgecolors") def __init__(self, edgecolors=None, facecolors=None, @@ -83,7 +84,7 @@ def __init__(self, joinstyle=None, antialiaseds=None, offsets=None, - transOffset=None, + offset_transform=None, norm=None, # optional for ScalarMappable cmap=None, # ditto pickradius=5.0, @@ -126,16 +127,12 @@ def __init__(self, A vector by which to translate each patch after rendering (default is no translation). The translation is performed in screen (pixel) coordinates (i.e. after the Artist's transform is applied). - transOffset : `~.transforms.Transform`, default: `.IdentityTransform` + offset_transform : `~.Transform`, default: `.IdentityTransform` A single transform which will be applied to each *offsets* vector before it is used. - norm : `~.colors.Normalize`, optional - Forwarded to `.ScalarMappable`. The default of - ``None`` means that the first draw call will set ``vmin`` and - ``vmax`` using the minimum and maximum values of the data. - cmap : `~.colors.Colormap`, optional - Forwarded to `.ScalarMappable`. The default of - ``None`` will result in :rc:`image.cmap` being used. + cmap, norm + Data normalization and colormapping parameters. See + `.ScalarMappable` for a detailed description. hatch : str, optional Hatching pattern to use in filled paths, if any. Valid strings are ['/', '\\', '|', '-', '+', 'x', 'o', 'O', '.', '*']. See @@ -179,7 +176,6 @@ def __init__(self, self.set_pickradius(pickradius) self.set_urls(urls) self.set_hatch(hatch) - self._offset_position = "screen" self.set_zorder(zorder) if capstyle: @@ -197,30 +193,18 @@ def __init__(self, # Broadcast (2,) -> (1, 2) but nothing else. if offsets.shape == (2,): offsets = offsets[None, :] - elif transOffset is not None: - _api.warn_deprecated( - '3.5', - removal='3.6', - message='Passing *transOffset* without *offsets* has no ' - 'effect. This behavior is deprecated since %(since)s ' - 'and %(removal)s, *transOffset* will begin having an ' - 'effect regardless of *offsets*. In the meantime, if ' - 'you wish to set *transOffset*, call ' - 'collection.set_offset_transform(transOffset) ' - 'explicitly.') - transOffset = None self._offsets = offsets - self._transOffset = transOffset + self._offset_transform = offset_transform self._path_effects = None - self.update(kwargs) + self._internal_update(kwargs) self._paths = None def get_paths(self): return self._paths - def set_paths(self): + def set_paths(self, paths): raise NotImplementedError def get_transforms(self): @@ -228,22 +212,24 @@ def get_transforms(self): def get_offset_transform(self): """Return the `.Transform` instance used by this artist offset.""" - if self._transOffset is None: - self._transOffset = transforms.IdentityTransform() - elif (not isinstance(self._transOffset, transforms.Transform) - and hasattr(self._transOffset, '_as_mpl_transform')): - self._transOffset = self._transOffset._as_mpl_transform(self.axes) - return self._transOffset + if self._offset_transform is None: + self._offset_transform = transforms.IdentityTransform() + elif (not isinstance(self._offset_transform, transforms.Transform) + and hasattr(self._offset_transform, '_as_mpl_transform')): + self._offset_transform = \ + self._offset_transform._as_mpl_transform(self.axes) + return self._offset_transform - def set_offset_transform(self, transOffset): + @_api.rename_parameter("3.6", "transOffset", "offset_transform") + def set_offset_transform(self, offset_transform): """ Set the artist offset transform. Parameters ---------- - transOffset : `.Transform` + offset_transform : `.Transform` """ - self._transOffset = transOffset + self._offset_transform = offset_transform def get_datalim(self, transData): # Calculate the data limits and return them as a `.Bbox`. @@ -263,10 +249,10 @@ def get_datalim(self, transData): # 3. otherwise return a null Bbox. transform = self.get_transform() - transOffset = self.get_offset_transform() - if not (isinstance(transOffset, transforms.IdentityTransform) - or transOffset.contains_branch(transData)): - # if there are offsets but in some coords other than data, + offset_trf = self.get_offset_transform() + if not (isinstance(offset_trf, transforms.IdentityTransform) + or offset_trf.contains_branch(transData)): + # if the offsets are in some coords other than data, # then don't use them for autoscaling. return transforms.Bbox.null() offsets = self.get_offsets() @@ -284,18 +270,18 @@ def get_datalim(self, transData): # be careful to only apply the affine part that remains. if any(transform.contains_branch_seperately(transData)): - if isinstance(offsets, np.ma.MaskedArray): - offsets = offsets.filled(np.nan) - # get_path_collection_extents handles nan but not masked arrays # collections that are just in data units (like quiver) # can properly have the axes limits set by their shape + # offset. LineCollections that have no offsets can # also use this algorithm (like streamplot). + if isinstance(offsets, np.ma.MaskedArray): + offsets = offsets.filled(np.nan) + # get_path_collection_extents handles nan but not masked arrays return mpath.get_path_collection_extents( transform.get_affine() - transData, paths, self.get_transforms(), - transOffset.transform_non_affine(offsets), - transOffset.get_affine().frozen()) + offset_trf.transform_non_affine(offsets), + offset_trf.get_affine().frozen()) # NOTE: None is the default case where no offsets were passed in if self._offsets is not None: @@ -304,8 +290,7 @@ def get_datalim(self, transData): # (i.e. like scatter). We can't uniquely set limits based on # those shapes, so we just set the limits based on their # location. - - offsets = (transOffset - transData).transform(offsets) + offsets = (offset_trf - transData).transform(offsets) # note A-B means A B^{-1} offsets = np.ma.masked_invalid(offsets) if not offsets.mask.all(): @@ -314,7 +299,7 @@ def get_datalim(self, transData): return bbox return transforms.Bbox.null() - def get_window_extent(self, renderer): + def get_window_extent(self, renderer=None): # TODO: check to ensure that this does not fail for # cases other than scatter plot legend return self.get_datalim(transforms.IdentityTransform()) @@ -323,7 +308,7 @@ def _prepare_points(self): # Helper for drawing and hit testing. transform = self.get_transform() - transOffset = self.get_offset_transform() + offset_trf = self.get_offset_transform() offsets = self.get_offsets() paths = self.get_paths() @@ -337,23 +322,23 @@ def _prepare_points(self): paths.append(mpath.Path(np.column_stack([xs, ys]), path.codes)) xs = self.convert_xunits(offsets[:, 0]) ys = self.convert_yunits(offsets[:, 1]) - offsets = np.column_stack([xs, ys]) + offsets = np.ma.column_stack([xs, ys]) if not transform.is_affine: paths = [transform.transform_path_non_affine(path) for path in paths] transform = transform.get_affine() - if not transOffset.is_affine: - offsets = transOffset.transform_non_affine(offsets) + if not offset_trf.is_affine: + offsets = offset_trf.transform_non_affine(offsets) # This might have changed an ndarray into a masked array. - transOffset = transOffset.get_affine() + offset_trf = offset_trf.get_affine() if isinstance(offsets, np.ma.MaskedArray): offsets = offsets.filled(np.nan) # Changing from a masked array to nan-filled ndarray # is probably most efficient at this point. - return transform, transOffset, offsets, paths + return transform, offset_trf, offsets, paths @artist.allow_rasterization def draw(self, renderer): @@ -363,7 +348,7 @@ def draw(self, renderer): self.update_scalarmappable() - transform, transOffset, offsets, paths = self._prepare_points() + transform, offset_trf, offsets, paths = self._prepare_points() gc = renderer.new_gc() self._set_gc_clip(gc) @@ -419,30 +404,31 @@ def draw(self, renderer): gc.set_url(self._urls[0]) renderer.draw_markers( gc, paths[0], combined_transform.frozen(), - mpath.Path(offsets), transOffset, tuple(facecolors[0])) + mpath.Path(offsets), offset_trf, tuple(facecolors[0])) else: renderer.draw_path_collection( gc, transform.frozen(), paths, - self.get_transforms(), offsets, transOffset, + self.get_transforms(), offsets, offset_trf, self.get_facecolor(), self.get_edgecolor(), self._linewidths, self._linestyles, self._antialiaseds, self._urls, - self._offset_position) + "screen") # offset_position, kept for backcompat. gc.restore() renderer.close_group(self.__class__.__name__) self.stale = False - def set_pickradius(self, pr): + @_api.rename_parameter("3.6", "pr", "pickradius") + def set_pickradius(self, pickradius): """ Set the pick radius used for containment tests. Parameters ---------- - pr : float + pickradius : float Pick radius, in points. """ - self._pickradius = pr + self._pickradius = pickradius def get_pickradius(self): return self._pickradius @@ -470,7 +456,7 @@ def contains(self, mouseevent): if self.axes: self.axes._unstale_viewLim() - transform, transOffset, offsets, paths = self._prepare_points() + transform, offset_trf, offsets, paths = self._prepare_points() # Tests if the point is contained on one of the polygons formed # by the control points of each of the paths. A point is considered @@ -480,8 +466,7 @@ def contains(self, mouseevent): ind = _path.point_in_path_collection( mouseevent.x, mouseevent.y, pickradius, transform.frozen(), paths, self.get_transforms(), - offsets, transOffset, pickradius <= 0, - self._offset_position) + offsets, offset_trf, pickradius <= 0) return len(ind) > 0, dict(ind=ind) @@ -560,9 +545,11 @@ def set_offsets(self, offsets): offsets = np.asanyarray(offsets) if offsets.shape == (2,): # Broadcast (2,) -> (1, 2) but nothing else. offsets = offsets[None, :] - self._offsets = np.column_stack( - (np.asarray(self.convert_xunits(offsets[:, 0]), 'float'), - np.asarray(self.convert_yunits(offsets[:, 1]), 'float'))) + cstack = (np.ma.column_stack if isinstance(offsets, np.ma.MaskedArray) + else np.column_stack) + self._offsets = cstack( + (np.asanyarray(self.convert_xunits(offsets[:, 0]), float), + np.asanyarray(self.convert_yunits(offsets[:, 1]), float))) self.stale = True def get_offsets(self): @@ -587,7 +574,7 @@ def set_linewidth(self, lw): if lw is None: lw = self._get_default_linewidth() # get the un-scaled/broadcast lw - self._us_lw = np.atleast_1d(np.asarray(lw)) + self._us_lw = np.atleast_1d(lw) # scale all of the dash patterns. self._linewidths, self._linestyles = self._bcast_lwls( @@ -621,18 +608,13 @@ def set_linestyle(self, ls): complete description. """ try: - if isinstance(ls, str): - ls = cbook.ls_mapper.get(ls, ls) - dashes = [mlines._get_dash_pattern(ls)] - else: - try: - dashes = [mlines._get_dash_pattern(ls)] - except ValueError: - dashes = [mlines._get_dash_pattern(x) for x in ls] - - except ValueError as err: - raise ValueError('Do not know how to convert {!r} to ' - 'dashes'.format(ls)) from err + dashes = [mlines._get_dash_pattern(ls)] + except ValueError: + try: + dashes = [mlines._get_dash_pattern(x) for x in ls] + except ValueError as err: + emsg = f'Do not know how to convert {ls!r} to dashes' + raise ValueError(emsg) from err # get the list of raw 'unscaled' dash patterns self._us_linestyles = dashes @@ -641,7 +623,7 @@ def set_linestyle(self, ls): self._linewidths, self._linestyles = self._bcast_lwls( self._us_lw, self._us_linestyles) - @docstring.interpd + @_docstring.interpd def set_capstyle(self, cs): """ Set the `.CapStyle` for the collection (for all its elements). @@ -655,7 +637,7 @@ def set_capstyle(self, cs): def get_capstyle(self): return self._capstyle.name - @docstring.interpd + @_docstring.interpd def set_joinstyle(self, js): """ Set the `.JoinStyle` for the collection (for all its elements). @@ -701,7 +683,7 @@ def _bcast_lwls(linewidths, dashes): dashes = list(dashes) * (l_lw // gcd) linewidths = list(linewidths) * (l_dashes // gcd) - # scale the dash patters + # scale the dash patterns dashes = [mlines._scale_dashes(o, d, lw) for (o, d), lw in zip(dashes, linewidths)] @@ -730,7 +712,7 @@ def set_color(self, c): Parameters ---------- - c : color or list of rgba tuples + c : color or list of RGBA tuples See Also -------- @@ -966,7 +948,7 @@ def set_sizes(self, sizes, dpi=72.0): Parameters ---------- - sizes : ndarray or None + sizes : `numpy.ndarray` or None The size to set for each element of the collection. The value is the 'area' of the element. dpi : float, default: 72 @@ -1140,9 +1122,9 @@ def legend_elements(self, prop="colors", num="auto", ix = np.argsort(xarr) values = np.interp(label_values, xarr[ix], yarr[ix]) - kw = dict(markeredgewidth=self.get_linewidths()[0], - alpha=self.get_alpha()) - kw.update(kwargs) + kw = {"markeredgewidth": self.get_linewidths()[0], + "alpha": self.get_alpha(), + **kwargs} for val, lab in zip(values, label_values): if prop == "colors": @@ -1163,6 +1145,8 @@ def legend_elements(self, prop="colors", num="auto", class PolyCollection(_CollectionWithSizes): + + @_api.make_keyword_only("3.6", name="closed") def __init__(self, verts, sizes=None, closed=True, **kwargs): """ Parameters @@ -1226,11 +1210,7 @@ def set_verts(self, verts, closed=True): self._paths = [] for xy in verts: if len(xy): - if isinstance(xy, np.ma.MaskedArray): - xy = np.ma.concatenate([xy, xy[:1]]) - else: - xy = np.concatenate([xy, xy[:1]]) - self._paths.append(mpath.Path(xy, closed=True)) + self._paths.append(mpath.Path._create_closed(xy)) else: self._paths.append(mpath.Path(xy)) @@ -1241,15 +1221,30 @@ def set_verts_and_codes(self, verts, codes): if len(verts) != len(codes): raise ValueError("'codes' must be a 1D list or array " "with the same length of 'verts'") - self._paths = [] - for xy, cds in zip(verts, codes): - if len(xy): - self._paths.append(mpath.Path(xy, cds)) - else: - self._paths.append(mpath.Path(xy)) + self._paths = [mpath.Path(xy, cds) if len(xy) else mpath.Path(xy) + for xy, cds in zip(verts, codes)] self.stale = True + @classmethod + @_api.deprecated("3.7", alternative="fill_between") + def span_where(cls, x, ymin, ymax, where, **kwargs): + """ + Return a `.BrokenBarHCollection` that plots horizontal bars from + over the regions in *x* where *where* is True. The bars range + on the y-axis from *ymin* to *ymax* + *kwargs* are passed on to the collection. + """ + xranges = [] + for ind0, ind1 in cbook.contiguous_regions(where): + xslice = x[ind0:ind1] + if not len(xslice): + continue + xranges.append((xslice[0], xslice[-1] - xslice[0])) + return BrokenBarHCollection(xranges, [ymin, ymax - ymin], **kwargs) + + +@_api.deprecated("3.7") class BrokenBarHCollection(PolyCollection): """ A collection of horizontal bars spanning *yrange* with a sequence of @@ -1275,23 +1270,6 @@ def __init__(self, xranges, yrange, **kwargs): (xmin, ymin)] for xmin, xwidth in xranges] super().__init__(verts, **kwargs) - @classmethod - def span_where(cls, x, ymin, ymax, where, **kwargs): - """ - Return a `.BrokenBarHCollection` that plots horizontal bars from - over the regions in *x* where *where* is True. The bars range - on the y-axis from *ymin* to *ymax* - - *kwargs* are passed on to the collection. - """ - xranges = [] - for ind0, ind1 in cbook.contiguous_regions(where): - xslice = x[ind0:ind1] - if not len(xslice): - continue - xranges.append((xslice[0], xslice[-1] - xslice[0])) - return cls(xranges, [ymin, ymax - ymin], **kwargs) - class RegularPolyCollection(_CollectionWithSizes): """A collection of n-sided regular polygons.""" @@ -1299,6 +1277,7 @@ class RegularPolyCollection(_CollectionWithSizes): _path_generator = mpath.Path.unit_regular_polygon _factor = np.pi ** (-1/2) + @_api.make_keyword_only("3.6", name="rotation") def __init__(self, numsides, rotation=0, @@ -1330,7 +1309,7 @@ def __init__(self, edgecolors=("black",), linewidths=(1,), offsets=offsets, - transOffset=ax.transData, + offset_transform=ax.transData, ) """ super().__init__(**kwargs) @@ -1389,7 +1368,7 @@ class LineCollection(Collection): _edge_default = True def __init__(self, segments, # Can be None. - *args, # Deprecated. + *, zorder=2, # Collection.zorder is 1 **kwargs ): @@ -1410,7 +1389,7 @@ def __init__(self, segments, # Can be None. allowed). antialiaseds : bool or list of bool, default: :rc:`lines.antialiased` Whether to use antialiasing for each line. - zorder : int, default: 2 + zorder : float, default: 2 zorder of the lines once drawn. facecolors : color or list of color, default: 'none' @@ -1425,18 +1404,6 @@ def __init__(self, segments, # Can be None. **kwargs Forwarded to `.Collection`. """ - argnames = ["linewidths", "colors", "antialiaseds", "linestyles", - "offsets", "transOffset", "norm", "cmap", "pickradius", - "zorder", "facecolors"] - if args: - argkw = {name: val for name, val in zip(argnames, args)} - kwargs.update(argkw) - cbook.warn_deprecated( - "3.4", message="Since %(since)s, passing LineCollection " - "arguments other than the first, 'segments', as positional " - "arguments is deprecated, and they will become keyword-only " - "arguments %(removal)s." - ) # Unfortunately, mplot3d needs this explicit setting of 'facecolors'. kwargs.setdefault('facecolors', 'none') super().__init__( @@ -1447,14 +1414,10 @@ def __init__(self, segments, # Can be None. def set_segments(self, segments): if segments is None: return - _segments = [] - - for seg in segments: - if not isinstance(seg, np.ma.MaskedArray): - seg = np.asarray(seg, float) - _segments.append(seg) - self._paths = [mpath.Path(_seg) for _seg in _segments] + self._paths = [mpath.Path(seg) if isinstance(seg, np.ma.MaskedArray) + else mpath.Path(np.asarray(seg, float)) + for seg in segments] self.stale = True set_verts = set_segments # for compatibility with PolyCollection @@ -1504,7 +1467,7 @@ def set_color(self, c): ---------- c : color or list of colors Single color (all lines have same color), or a - sequence of rgba tuples; if it is a sequence the lines will + sequence of RGBA tuples; if it is a sequence the lines will cycle through the sequence. """ self.set_edgecolor(c) @@ -1527,6 +1490,7 @@ class EventCollection(LineCollection): _edge_default = True + @_api.make_keyword_only("3.6", name="lineoffset") def __init__(self, positions, # Cannot be None. orientation='horizontal', @@ -1722,6 +1686,7 @@ def __init__(self, sizes, **kwargs): class EllipseCollection(Collection): """A collection of ellipses, drawn using splines.""" + @_api.make_keyword_only("3.6", name="units") def __init__(self, widths, heights, angles, units='points', **kwargs): """ Parameters @@ -1775,7 +1740,7 @@ def _set_transforms(self): elif self._units == 'dots': sc = 1.0 else: - raise ValueError('unrecognized units: %s' % self._units) + raise ValueError(f'Unrecognized units: {self._units!r}') self._transforms = np.zeros((len(self._widths), 3, 3)) widths = self._widths * sc @@ -1804,29 +1769,35 @@ class PatchCollection(Collection): """ A generic collection of patches. - This makes it easier to assign a colormap to a heterogeneous + PatchCollection draws faster than a large number of equivalent individual + Patches. It also makes it easier to assign a colormap to a heterogeneous collection of patches. - - This also may improve plotting speed, since PatchCollection will - draw faster than a large number of patches. """ + @_api.make_keyword_only("3.6", name="match_original") def __init__(self, patches, match_original=False, **kwargs): """ - *patches* - a sequence of Patch objects. This list may include + Parameters + ---------- + patches : list of `.Patch` + A sequence of Patch objects. This list may include a heterogeneous assortment of different patch types. - *match_original* + match_original : bool, default: False If True, use the colors and linewidths of the original patches. If False, new colors may be assigned by providing the standard collection arguments, facecolor, edgecolor, linewidths, norm or cmap. - If any of *edgecolors*, *facecolors*, *linewidths*, *antialiaseds* are - None, they default to their `.rcParams` patch setting, in sequence - form. + **kwargs + All other parameters are forwarded to `.Collection`. + + If any of *edgecolors*, *facecolors*, *linewidths*, *antialiaseds* + are None, they default to their `.rcParams` patch setting, in + sequence form. + Notes + ----- The use of `~matplotlib.cm.ScalarMappable` functionality is optional. If the `~matplotlib.cm.ScalarMappable` matrix ``_A`` has been set (via a call to `~.ScalarMappable.set_array`), at draw time a call to scalar @@ -1922,7 +1893,7 @@ class QuadMesh(Collection): r""" Class for the efficient drawing of a quadrilateral mesh. - A quadrilateral mesh is a grid of M by N adjacent qudrilaterals that are + A quadrilateral mesh is a grid of M by N adjacent quadrilaterals that are defined via a (M+1, N+1) grid of vertices. The quadrilateral (m, n) is defined by the vertices :: @@ -1950,62 +1921,15 @@ class QuadMesh(Collection): i.e. `~.Artist.contains` checks whether the test point is within any of the mesh quadrilaterals. - There exists a deprecated API version ``QuadMesh(M, N, coords)``, where - the dimensions are given explicitly and ``coords`` is a (M*N, 2) - array-like. This has been deprecated in Matplotlib 3.5. The following - describes the semantics of this deprecated API. - - A quadrilateral mesh consists of a grid of vertices. - The dimensions of this array are (*meshWidth* + 1, *meshHeight* + 1). - Each vertex in the mesh has a different set of "mesh coordinates" - representing its position in the topology of the mesh. - For any values (*m*, *n*) such that 0 <= *m* <= *meshWidth* - and 0 <= *n* <= *meshHeight*, the vertices at mesh coordinates - (*m*, *n*), (*m*, *n* + 1), (*m* + 1, *n* + 1), and (*m* + 1, *n*) - form one of the quadrilaterals in the mesh. There are thus - (*meshWidth* * *meshHeight*) quadrilaterals in the mesh. The mesh - need not be regular and the polygons need not be convex. - - A quadrilateral mesh is represented by a (2 x ((*meshWidth* + 1) * - (*meshHeight* + 1))) numpy array *coordinates*, where each row is - the *x* and *y* coordinates of one of the vertices. To define the - function that maps from a data point to its corresponding color, - use the :meth:`set_cmap` method. Each of these arrays is indexed in - row-major order by the mesh coordinates of the vertex (or the mesh - coordinates of the lower left vertex, in the case of the colors). - - For example, the first entry in *coordinates* is the coordinates of the - vertex at mesh coordinates (0, 0), then the one at (0, 1), then at (0, 2) - .. (0, meshWidth), (1, 0), (1, 1), and so on. """ - def __init__(self, *args, **kwargs): - # signature deprecation since="3.5": Change to new signature after the - # deprecation has expired. Also remove setting __init__.__signature__, - # and remove the Notes from the docstring. - params = _api.select_matching_signature( - [ - lambda meshWidth, meshHeight, coordinates, antialiased=True, - shading='flat', **kwargs: locals(), - lambda coordinates, antialiased=True, shading='flat', **kwargs: - locals() - ], - *args, **kwargs).values() - *old_w_h, coords, antialiased, shading, kwargs = params - if old_w_h: # The old signature matched. - _api.warn_deprecated( - "3.5", - message="This usage of Quadmesh is deprecated: Parameters " - "meshWidth and meshHeights will be removed; " - "coordinates must be 2D; all parameters except " - "coordinates will be keyword-only.") - w, h = old_w_h - coords = np.asarray(coords, np.float64).reshape((h + 1, w + 1, 2)) + def __init__(self, coordinates, *, antialiased=True, shading='flat', + **kwargs): kwargs.setdefault("pickradius", 0) # end of signature deprecation code - _api.check_shape((None, None, 2), coordinates=coords) - self._coordinates = coords + _api.check_shape((None, None, 2), coordinates=coordinates) + self._coordinates = coordinates self._antialiased = antialiased self._shading = shading self._bbox = transforms.Bbox.unit() @@ -2013,12 +1937,7 @@ def __init__(self, *args, **kwargs): # super init delayed after own init because array kwarg requires # self._coordinates and self._shading super().__init__(**kwargs) - self.mouseover = False - - # Only needed during signature deprecation - __init__.__signature__ = inspect.signature( - lambda self, coordinates, *, - antialiased=True, shading='flat', pickradius=0, **kwargs: None) + self.set_mouseover(False) def get_paths(self): if self._paths is None: @@ -2035,7 +1954,16 @@ def set_array(self, A): Parameters ---------- - A : (M, N) array-like or M*N array-like + A : array-like + The mesh data. Supported array shapes are: + + - (M, N) or (M*N,): a mesh with scalar data. The values are mapped + to colors using normalization and a colormap. See parameters + *norm*, *cmap*, *vmin*, *vmax*. + - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). + - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), + i.e. including transparency. + If the values are provided as a 2D grid, the shape must match the coordinates grid. If the values are 1D, they are reshaped to 2D. M, N follow from the coordinates grid, where the coordinates grid @@ -2043,37 +1971,18 @@ def set_array(self, A): shading. """ height, width = self._coordinates.shape[0:-1] - misshapen_data = False - faulty_data = False - if self._shading == 'flat': - h, w = height-1, width-1 + h, w = height - 1, width - 1 else: h, w = height, width - + ok_shapes = [(h, w, 3), (h, w, 4), (h, w), (h * w,)] if A is not None: shape = np.shape(A) - if len(shape) == 1: - if shape[0] != (h*w): - faulty_data = True - elif shape != (h, w): - if np.prod(shape) == (h * w): - misshapen_data = True - else: - faulty_data = True - - if misshapen_data: - _api.warn_deprecated( - "3.5", message=f"For X ({width}) and Y ({height}) " - f"with {self._shading} shading, the expected shape of " - f"A is ({h}, {w}). Passing A ({A.shape}) is deprecated " - "since %(since)s and will become an error %(removal)s.") - - if faulty_data: - raise TypeError( - f"Dimensions of A {A.shape} are incompatible with " - f"X ({width}) and/or Y ({height})") - + if shape not in ok_shapes: + raise ValueError( + f"For X ({width}) and Y ({height}) with {self._shading} " + f"shading, A should have shape " + f"{' or '.join(map(str, ok_shapes))}, not {A.shape}") return super().set_array(A) def get_datalim(self, transData): @@ -2089,12 +1998,6 @@ def get_coordinates(self): """ return self._coordinates - @staticmethod - @_api.deprecated("3.5", alternative="`QuadMesh(coordinates).get_paths()" - "<.QuadMesh.get_paths>`") - def convert_mesh_to_paths(meshWidth, meshHeight, coordinates): - return QuadMesh._convert_mesh_to_paths(coordinates) - @staticmethod def _convert_mesh_to_paths(coordinates): """ @@ -2116,15 +2019,11 @@ def _convert_mesh_to_paths(coordinates): ], axis=2).reshape((-1, 5, 2)) return [mpath.Path(x) for x in points] - @_api.deprecated("3.5") - def convert_mesh_to_triangles(self, meshWidth, meshHeight, coordinates): - return self._convert_mesh_to_triangles(coordinates) - def _convert_mesh_to_triangles(self, coordinates): """ Convert a given mesh into a sequence of triangles, each point with its own color. The result can be used to construct a call to - `~.RendererBase.draw_gouraud_triangle`. + `~.RendererBase.draw_gouraud_triangles`. """ if isinstance(coordinates, np.ma.MaskedArray): p = coordinates.data @@ -2164,7 +2063,7 @@ def draw(self, renderer): return renderer.open_group(self.__class__.__name__, self.get_gid()) transform = self.get_transform() - transOffset = self.get_offset_transform() + offset_trf = self.get_offset_transform() offsets = self.get_offsets() if self.have_units(): @@ -2182,9 +2081,9 @@ def draw(self, renderer): else: coordinates = self._coordinates - if not transOffset.is_affine: - offsets = transOffset.transform_non_affine(offsets) - transOffset = transOffset.get_affine() + if not offset_trf.is_affine: + offsets = offset_trf.transform_non_affine(offsets) + offset_trf = offset_trf.get_affine() gc = renderer.new_gc() gc.set_snap(self.get_snap()) @@ -2199,7 +2098,7 @@ def draw(self, renderer): renderer.draw_quad_mesh( gc, transform.frozen(), coordinates.shape[1] - 1, coordinates.shape[0] - 1, - coordinates, offsets, transOffset, + coordinates, offsets, offset_trf, # Backends expect flattened rgba arrays (n*m, 4) for fc and ec self.get_facecolor().reshape((-1, 4)), self._antialiased, self.get_edgecolors().reshape((-1, 4))) @@ -2209,12 +2108,6 @@ def draw(self, renderer): def get_cursor_data(self, event): contained, info = self.contains(event) - if len(info["ind"]) == 1: - ind, = info["ind"] - array = self.get_array() - if array is not None: - return array[ind] - else: - return None - else: - return None + if contained and self.get_array() is not None: + return self.get_array().ravel()[info["ind"]] + return None diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index 15a9b35a8611..966eb0760b47 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -11,25 +11,23 @@ End-users most likely won't need to directly use this module's API. """ -import copy import logging -import textwrap import numpy as np import matplotlib as mpl -from matplotlib import _api, collections, cm, colors, contour, ticker +from matplotlib import _api, cbook, collections, cm, colors, contour, ticker import matplotlib.artist as martist import matplotlib.patches as mpatches import matplotlib.path as mpath -import matplotlib.scale as mscale import matplotlib.spines as mspines import matplotlib.transforms as mtransforms -from matplotlib import docstring +from matplotlib import _docstring _log = logging.getLogger(__name__) -_make_axes_param_doc = """ +_docstring.interpd.update( + _make_axes_kw_doc=""" location : None or {'left', 'right', 'top', 'bottom'} The location, relative to the parent axes, where the colorbar axes is created. It also determines the *orientation* of the colorbar @@ -37,174 +35,86 @@ and bottom are horizontal). If None, the location will come from the *orientation* if it is set (vertical colorbars on the right, horizontal ones at the bottom), or default to 'right' if *orientation* is unset. + orientation : None or {'vertical', 'horizontal'} The orientation of the colorbar. It is preferable to set the *location* of the colorbar, as that also determines the *orientation*; passing incompatible values for *location* and *orientation* raises an exception. + fraction : float, default: 0.15 Fraction of original axes to use for colorbar. + shrink : float, default: 1.0 Fraction by which to multiply the size of the colorbar. + aspect : float, default: 20 Ratio of long to short dimensions. -""" -_make_axes_other_param_doc = """ + pad : float, default: 0.05 if vertical, 0.15 if horizontal Fraction of original axes between colorbar and new image axes. + anchor : (float, float), optional The anchor point of the colorbar axes. Defaults to (0.0, 0.5) if vertical; (0.5, 1.0) if horizontal. + panchor : (float, float), or *False*, optional The anchor point of the colorbar parent axes. If *False*, the parent axes' anchor will be unchanged. - Defaults to (1.0, 0.5) if vertical; (0.5, 0.0) if horizontal. -""" - -_colormap_kw_doc = """ - - ============ ==================================================== - Property Description - ============ ==================================================== - *extend* {'neither', 'both', 'min', 'max'} - If not 'neither', make pointed end(s) for out-of- - range values. These are set for a given colormap - using the colormap set_under and set_over methods. - *extendfrac* {*None*, 'auto', length, lengths} - If set to *None*, both the minimum and maximum - triangular colorbar extensions with have a length of - 5% of the interior colorbar length (this is the - default setting). If set to 'auto', makes the - triangular colorbar extensions the same lengths as - the interior boxes (when *spacing* is set to - 'uniform') or the same lengths as the respective - adjacent interior boxes (when *spacing* is set to - 'proportional'). If a scalar, indicates the length - of both the minimum and maximum triangular colorbar - extensions as a fraction of the interior colorbar - length. A two-element sequence of fractions may also - be given, indicating the lengths of the minimum and - maximum colorbar extensions respectively as a - fraction of the interior colorbar length. - *extendrect* bool - If *False* the minimum and maximum colorbar extensions - will be triangular (the default). If *True* the - extensions will be rectangular. - *spacing* {'uniform', 'proportional'} - Uniform spacing gives each discrete color the same - space; proportional makes the space proportional to - the data interval. - *ticks* *None* or list of ticks or Locator - If None, ticks are determined automatically from the - input. - *format* None or str or Formatter - If None, `~.ticker.ScalarFormatter` is used. - If a format string is given, e.g., '%.3f', that is used. - An alternative `~.ticker.Formatter` may be given instead. - *drawedges* bool - Whether to draw lines at color boundaries. - *label* str - The label on the colorbar's long axis. - ============ ==================================================== - - The following will probably be useful only in the context of - indexed colors (that is, when the mappable has norm=NoNorm()), - or other unusual circumstances. - - ============ =================================================== - Property Description - ============ =================================================== - *boundaries* None or a sequence - *values* None or a sequence which must be of length 1 less - than the sequence of *boundaries*. For each region - delimited by adjacent entries in *boundaries*, the - colormapped to the corresponding value in values - will be used. - ============ =================================================== - -""" - -docstring.interpd.update(colorbar_doc=""" -Add a colorbar to a plot. - -Parameters ----------- -mappable - The `matplotlib.cm.ScalarMappable` (i.e., `~matplotlib.image.AxesImage`, - `~matplotlib.contour.ContourSet`, etc.) described by this colorbar. - This argument is mandatory for the `.Figure.colorbar` method but optional - for the `.pyplot.colorbar` function, which sets the default to the current - image. - - Note that one can create a `.ScalarMappable` "on-the-fly" to generate - colorbars not attached to a previously drawn artist, e.g. :: - - fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax) - -cax : `~matplotlib.axes.Axes`, optional - Axes into which the colorbar will be drawn. - -ax : `~matplotlib.axes.Axes`, list of Axes, optional - One or more parent axes from which space for a new colorbar axes will be - stolen, if *cax* is None. This has no effect if *cax* is set. - -use_gridspec : bool, optional - If *cax* is ``None``, a new *cax* is created as an instance of Axes. If - *ax* is an instance of Subplot and *use_gridspec* is ``True``, *cax* is - created as an instance of Subplot using the :mod:`.gridspec` module. - -Returns -------- -colorbar : `~matplotlib.colorbar.Colorbar` - -Notes ------ -Additional keyword arguments are of two kinds: - - axes properties: -%s -%s - colorbar properties: -%s - -If *mappable* is a `~.contour.ContourSet`, its *extend* kwarg is included -automatically. - -The *shrink* kwarg provides a simple way to scale the colorbar with respect -to the axes. Note that if *cax* is specified, it determines the size of the -colorbar and *shrink* and *aspect* kwargs are ignored. - -For more precise control, you can manually specify the positions of -the axes objects in which the mappable and the colorbar are drawn. In -this case, do not use any of the axes properties kwargs. - -It is known that some vector graphics viewers (svg and pdf) renders white gaps -between segments of the colorbar. This is due to bugs in the viewers, not -Matplotlib. As a workaround, the colorbar can be rendered with overlapping -segments:: - - cbar = colorbar() - cbar.solids.set_edgecolor("face") - draw() - -However this has negative consequences in other circumstances, e.g. with -semi-transparent images (alpha < 1) and colorbar extensions; therefore, this -workaround is not used by default (see issue #1188). -""" % (textwrap.indent(_make_axes_param_doc, " "), - textwrap.indent(_make_axes_other_param_doc, " "), - _colormap_kw_doc)) - - -@_api.caching_module_getattr # module-level deprecations -class __getattr__: - colorbar_doc = _api.deprecated("3.4", obj_type="")(property( - lambda self: docstring.interpd.params["colorbar_doc"])) - colorbar_kw_doc = _api.deprecated("3.4", obj_type="")(property( - lambda self: _colormap_kw_doc)) - make_axes_kw_doc = _api.deprecated("3.4", obj_type="")(property( - lambda self: _make_axes_param_doc + _make_axes_other_param_doc)) - - -def _set_ticks_on_axis_warn(*args, **kw): + Defaults to (1.0, 0.5) if vertical; (0.5, 0.0) if horizontal.""", + _colormap_kw_doc=""" +extend : {'neither', 'both', 'min', 'max'} + Make pointed end(s) for out-of-range values (unless 'neither'). These are + set for a given colormap using the colormap set_under and set_over methods. + +extendfrac : {*None*, 'auto', length, lengths} + If set to *None*, both the minimum and maximum triangular colorbar + extensions will have a length of 5% of the interior colorbar length (this + is the default setting). + + If set to 'auto', makes the triangular colorbar extensions the same lengths + as the interior boxes (when *spacing* is set to 'uniform') or the same + lengths as the respective adjacent interior boxes (when *spacing* is set to + 'proportional'). + + If a scalar, indicates the length of both the minimum and maximum + triangular colorbar extensions as a fraction of the interior colorbar + length. A two-element sequence of fractions may also be given, indicating + the lengths of the minimum and maximum colorbar extensions respectively as + a fraction of the interior colorbar length. + +extendrect : bool + If *False* the minimum and maximum colorbar extensions will be triangular + (the default). If *True* the extensions will be rectangular. + +spacing : {'uniform', 'proportional'} + For discrete colorbars (`.BoundaryNorm` or contours), 'uniform' gives each + color the same space; 'proportional' makes the space proportional to the + data interval. + +ticks : None or list of ticks or Locator + If None, ticks are determined automatically from the input. + +format : None or str or Formatter + If None, `~.ticker.ScalarFormatter` is used. + Format strings, e.g., ``"%4.2e"`` or ``"{x:.2e}"``, are supported. + An alternative `~.ticker.Formatter` may be given instead. + +drawedges : bool + Whether to draw lines at color boundaries. + +label : str + The label on the colorbar's long axis. + +boundaries, values : None or a sequence + If unset, the colormap will be displayed on a 0-1 scale. + If sequences, *values* must have a length 1 less than *boundaries*. For + each region delimited by adjacent entries in *boundaries*, the color mapped + to the corresponding value in values will be used. + Normally only useful for indexed colors (i.e. ``norm=NoNorm()``) or other + unusual circumstances.""") + + +def _set_ticks_on_axis_warn(*args, **kwargs): # a top level function which gets put in at the axes' # set_xticks and set_yticks by Colorbar.__init__. _api.warn_external("Use the colorbar set_ticks() method instead.") @@ -213,8 +123,7 @@ def _set_ticks_on_axis_warn(*args, **kw): class _ColorbarSpine(mspines.Spine): def __init__(self, axes): self._ax = axes - super().__init__(axes, 'colorbar', - mpath.Path(np.empty((0, 2)), closed=True)) + super().__init__(axes, 'colorbar', mpath.Path(np.empty((0, 2)))) mpatches.Patch.set_transform(self, axes.transAxes) def get_window_extent(self, renderer=None): @@ -278,14 +187,12 @@ def __call__(self, ax, renderer): def get_subplotspec(self): # make tight_layout happy.. - ss = getattr(self._cbar.ax, 'get_subplotspec', None) - if ss is None: - if not hasattr(self._orig_locator, "get_subplotspec"): - return None - ss = self._orig_locator.get_subplotspec - return ss() + return ( + self._cbar.ax.get_subplotspec() + or getattr(self._orig_locator, "get_subplotspec", lambda: None)()) +@_docstring.interpd class Colorbar: r""" Draw a colorbar in an existing axes. @@ -338,40 +245,46 @@ class Colorbar: alpha : float The colorbar transparency between 0 (transparent) and 1 (opaque). - values, boundaries - If unset, the colormap will be displayed on a 0-1 scale. - - orientation : {'vertical', 'horizontal'} + orientation : None or {'vertical', 'horizontal'} + If None, use the value determined by *location*. If both + *orientation* and *location* are None then defaults to 'vertical'. ticklocation : {'auto', 'left', 'right', 'top', 'bottom'} - - extend : {'neither', 'both', 'min', 'max'} - - spacing : {'uniform', 'proportional'} - - ticks : `~matplotlib.ticker.Locator` or array-like of float - - format : str or `~matplotlib.ticker.Formatter` + The location of the colorbar ticks. The *ticklocation* must match + *orientation*. For example, a horizontal colorbar can only have ticks + at the top or the bottom. If 'auto', the ticks will be the same as + *location*, so a colorbar to the left will have ticks to the left. If + *location* is None, the ticks will be at the bottom for a horizontal + colorbar and at the right for a vertical. drawedges : bool + Whether to draw lines at color boundaries. filled : bool - extendfrac + %(_colormap_kw_doc)s - extendrec + location : None or {'left', 'right', 'top', 'bottom'} + Set the *orientation* and *ticklocation* of the colorbar using a + single argument. Colorbars on the left and right are vertical, + colorbars at the top and bottom are horizontal. The *ticklocation* is + the same as *location*, so if *location* is 'top', the ticks are on + the top. *orientation* and/or *ticklocation* can be provided as well + and overrides the value set by *location*, but there will be an error + for incompatible combinations. - label : str + .. versionadded:: 3.7 """ n_rasterize = 50 # rasterize solids if number of colors >= n_rasterize + @_api.delete_parameter("3.6", "filled") def __init__(self, ax, mappable=None, *, cmap=None, norm=None, alpha=None, values=None, boundaries=None, - orientation='vertical', + orientation=None, ticklocation='auto', extend=None, spacing='uniform', # uniform or proportional @@ -382,6 +295,7 @@ def __init__(self, ax, mappable=None, *, cmap=None, extendfrac=None, extendrect=False, label='', + location=None, ): if mappable is None: @@ -412,14 +326,23 @@ def __init__(self, ax, mappable=None, *, cmap=None, mappable.colorbar_cid = mappable.callbacks.connect( 'changed', self.update_normal) + location_orientation = _get_orientation_from_location(location) + _api.check_in_list( - ['vertical', 'horizontal'], orientation=orientation) + [None, 'vertical', 'horizontal'], orientation=orientation) _api.check_in_list( ['auto', 'left', 'right', 'top', 'bottom'], ticklocation=ticklocation) _api.check_in_list( ['uniform', 'proportional'], spacing=spacing) + if location_orientation is not None and orientation is not None: + if location_orientation != orientation: + raise TypeError( + "location and orientation are mutually exclusive") + else: + orientation = orientation or location_orientation or "vertical" + self.ax = ax self.ax._axes_locator = _ColorbarAxesLocator(self) @@ -446,7 +369,7 @@ def __init__(self, ax, mappable=None, *, cmap=None, self.spacing = spacing self.orientation = orientation self.drawedges = drawedges - self.filled = filled + self._filled = filled self.extendfrac = extendfrac self.extendrect = extendrect self._extend_patches = [] @@ -457,41 +380,42 @@ def __init__(self, ax, mappable=None, *, cmap=None, for spine in self.ax.spines.values(): spine.set_visible(False) self.outline = self.ax.spines['outline'] = _ColorbarSpine(self.ax) - self._short_axis().set_visible(False) - # Only kept for backcompat; remove after deprecation of .patch elapses. - self._patch = mpatches.Polygon( - np.empty((0, 2)), - color=mpl.rcParams['axes.facecolor'], linewidth=0.01, zorder=-1) - ax.add_artist(self._patch) self.dividers = collections.LineCollection( [], colors=[mpl.rcParams['axes.edgecolor']], - linewidths=[0.5 * mpl.rcParams['axes.linewidth']]) + linewidths=[0.5 * mpl.rcParams['axes.linewidth']], + clip_on=False) self.ax.add_collection(self.dividers) - self.locator = None - self.minorlocator = None - self.formatter = None - self.__scale = None # linear, log10 for now. Hopefully more? + self._locator = None + self._minorlocator = None + self._formatter = None + self._minorformatter = None if ticklocation == 'auto': - ticklocation = 'bottom' if orientation == 'horizontal' else 'right' + ticklocation = _get_ticklocation_from_orientation( + orientation) if location is None else location self.ticklocation = ticklocation self.set_label(label) self._reset_locator_formatter_scale() if np.iterable(ticks): - self.locator = ticker.FixedLocator(ticks, nbins=len(ticks)) + self._locator = ticker.FixedLocator(ticks, nbins=len(ticks)) else: - self.locator = ticks # Handle default in _ticker() + self._locator = ticks if isinstance(format, str): - self.formatter = ticker.FormatStrFormatter(format) + # Check format between FormatStrFormatter and StrMethodFormatter + try: + self._formatter = ticker.FormatStrFormatter(format) + _ = self._formatter(0) + except TypeError: + self._formatter = ticker.StrMethodFormatter(format) else: - self.formatter = format # Assume it is a Formatter or None - self.draw_all() + self._formatter = format # Assume it is a Formatter or None + self._draw_all() if isinstance(mappable, contour.ContourSet) and not mappable.filled: self.add_lines(mappable) @@ -516,6 +440,46 @@ def __init__(self, ax, mappable=None, *, cmap=None, self._extend_cid2 = self.ax.callbacks.connect( "ylim_changed", self._do_extends) + @property + def locator(self): + """Major tick `.Locator` for the colorbar.""" + return self._long_axis().get_major_locator() + + @locator.setter + def locator(self, loc): + self._long_axis().set_major_locator(loc) + self._locator = loc + + @property + def minorlocator(self): + """Minor tick `.Locator` for the colorbar.""" + return self._long_axis().get_minor_locator() + + @minorlocator.setter + def minorlocator(self, loc): + self._long_axis().set_minor_locator(loc) + self._minorlocator = loc + + @property + def formatter(self): + """Major tick label `.Formatter` for the colorbar.""" + return self._long_axis().get_major_formatter() + + @formatter.setter + def formatter(self, fmt): + self._long_axis().set_major_formatter(fmt) + self._formatter = fmt + + @property + def minorformatter(self): + """Minor tick `.Formatter` for the colorbar.""" + return self._long_axis().get_minor_formatter() + + @minorformatter.setter + def minorformatter(self, fmt): + self._long_axis().set_minor_formatter(fmt) + self._minorformatter = fmt + def _cbar_cla(self): """Function to clear the interactive colorbar state.""" for x in self._interactive_funcs: @@ -524,8 +488,7 @@ def _cbar_cla(self): del self.ax.cla self.ax.cla() - # Also remove ._patch after deprecation elapses. - patch = _api.deprecate_privatize_attribute("3.5", alternative="ax") + filled = _api.deprecate_privatize_attribute("3.6") def update_normal(self, mappable): """ @@ -548,14 +511,22 @@ def update_normal(self, mappable): self.norm = mappable.norm self._reset_locator_formatter_scale() - self.draw_all() + self._draw_all() if isinstance(self.mappable, contour.ContourSet): CS = self.mappable if not CS.filled: self.add_lines(CS) self.stale = True + @_api.deprecated("3.6", alternative="fig.draw_without_rendering()") def draw_all(self): + """ + Calculate any free parameters based on the current cmap and norm, + and do all the drawing. + """ + self._draw_all() + + def _draw_all(self): """ Calculate any free parameters based on the current cmap and norm, and do all the drawing. @@ -599,7 +570,7 @@ def draw_all(self): # boundary norms + uniform spacing requires a manual locator. self.update_ticks() - if self.filled: + if self._filled: ind = np.arange(len(self._values)) if self._extend_lower(): ind = ind[1:] @@ -627,14 +598,40 @@ def _add_solids(self, X, Y, C): if not self.drawedges: if len(self._y) >= self.n_rasterize: self.solids.set_rasterized(True) - self.dividers.set_segments( - np.dstack([X, Y])[1:-1] if self.drawedges else []) + self._update_dividers() + + def _update_dividers(self): + if not self.drawedges: + self.dividers.set_segments([]) + return + # Place all *internal* dividers. + if self.orientation == 'vertical': + lims = self.ax.get_ylim() + bounds = (lims[0] < self._y) & (self._y < lims[1]) + else: + lims = self.ax.get_xlim() + bounds = (lims[0] < self._y) & (self._y < lims[1]) + y = self._y[bounds] + # And then add outer dividers if extensions are on. + if self._extend_lower(): + y = np.insert(y, 0, lims[0]) + if self._extend_upper(): + y = np.append(y, lims[1]) + X, Y = np.meshgrid([0, 1], y) + if self.orientation == 'vertical': + segments = np.dstack([X, Y]) + else: + segments = np.dstack([Y, X]) + self.dividers.set_segments(segments) def _add_solids_patches(self, X, Y, C, mappable): - hatches = mappable.hatches * len(C) # Have enough hatches. + hatches = mappable.hatches * (len(C) + 1) # Have enough hatches. + if self._extend_lower(): + # remove first hatch that goes into the extend patch + hatches = hatches[1:] patches = [] for i in range(len(X) - 1): - xy = np.array([[X[i, 0], Y[i, 0]], + xy = np.array([[X[i, 0], Y[i, 1]], [X[i, 1], Y[i, 0]], [X[i + 1, 1], Y[i + 1, 0]], [X[i + 1, 0], Y[i + 1, 1]]]) @@ -678,7 +675,7 @@ def _do_extends(self, ax=None): # xyout is the path for the spine: self.outline.set_xy(xyout) - if not self.filled: + if not self._filled: return # Make extend triangles or rectangles filled patches. These are @@ -686,9 +683,9 @@ def _do_extends(self, ax=None): mappable = getattr(self, 'mappable', None) if (isinstance(mappable, contour.ContourSet) and any(hatch is not None for hatch in mappable.hatches)): - hatches = mappable.hatches + hatches = mappable.hatches * (len(self._y) + 1) else: - hatches = [None] + hatches = [None] * (len(self._y) + 1) if self._extend_lower(): if not self.extendrect: @@ -703,14 +700,17 @@ def _do_extends(self, ax=None): val = -1 if self._long_axis().get_inverted() else 0 color = self.cmap(self.norm(self._values[val])) patch = mpatches.PathPatch( - mpath.Path(xy), facecolor=color, linewidth=0, - antialiased=False, transform=self.ax.transAxes, + mpath.Path(xy), facecolor=color, alpha=self.alpha, + linewidth=0, antialiased=False, + transform=self.ax.transAxes, hatch=hatches[0], clip_on=False, # Place it right behind the standard patches, which is # needed if we updated the extends zorder=np.nextafter(self.ax.patch.zorder, -np.inf)) self.ax.add_patch(patch) self._extend_patches.append(patch) + # remove first hatch that goes into the extend patch + hatches = hatches[1:] if self._extend_upper(): if not self.extendrect: # triangle @@ -723,16 +723,19 @@ def _do_extends(self, ax=None): # add the patch val = 0 if self._long_axis().get_inverted() else -1 color = self.cmap(self.norm(self._values[val])) + hatch_idx = len(self._y) - 1 patch = mpatches.PathPatch( - mpath.Path(xy), facecolor=color, + mpath.Path(xy), facecolor=color, alpha=self.alpha, linewidth=0, antialiased=False, - transform=self.ax.transAxes, hatch=hatches[-1], clip_on=False, + transform=self.ax.transAxes, hatch=hatches[hatch_idx], + clip_on=False, # Place it right behind the standard patches, which is # needed if we updated the extends zorder=np.nextafter(self.ax.patch.zorder, -np.inf)) self.ax.add_patch(patch) self._extend_patches.append(patch) - return + + self._update_dividers() def add_lines(self, *args, **kwargs): """ @@ -816,13 +819,13 @@ def add_lines(self, *args, **kwargs): def update_ticks(self): """ - Setup the ticks and ticklabels. This should not be needed by users. + Set up the ticks and ticklabels. This should not be needed by users. """ - # Get the locator and formatter; defaults to self.locator if not None. + # Get the locator and formatter; defaults to self._locator if not None. self._get_ticker_locator_formatter() - self._long_axis().set_major_locator(self.locator) - self._long_axis().set_minor_locator(self.minorlocator) - self._long_axis().set_major_formatter(self.formatter) + self._long_axis().set_major_locator(self._locator) + self._long_axis().set_minor_locator(self._minorlocator) + self._long_axis().set_major_formatter(self._formatter) def _get_ticker_locator_formatter(self): """ @@ -834,13 +837,15 @@ def _get_ticker_locator_formatter(self): Called by update_ticks... """ - locator = self.locator - formatter = self.formatter - minorlocator = self.minorlocator + locator = self._locator + formatter = self._formatter + minorlocator = self._minorlocator if isinstance(self.norm, colors.BoundaryNorm): b = self.norm.boundaries if locator is None: locator = ticker.FixedLocator(b, nbins=10) + if minorlocator is None: + minorlocator = ticker.FixedLocator(b) elif isinstance(self.norm, colors.NoNorm): if locator is None: # put ticks on integers between the boundaries of NoNorm @@ -865,14 +870,12 @@ def _get_ticker_locator_formatter(self): if formatter is None: formatter = self._long_axis().get_major_formatter() - self.locator = locator - self.formatter = formatter - self.minorlocator = minorlocator + self._locator = locator + self._formatter = formatter + self._minorlocator = minorlocator _log.debug('locator: %r', locator) - @_api.delete_parameter("3.5", "update_ticks") - def set_ticks(self, ticks, update_ticks=True, labels=None, *, - minor=False, **kwargs): + def set_ticks(self, ticks, *, labels=None, minor=False, **kwargs): """ Set tick locations. @@ -891,10 +894,10 @@ def set_ticks(self, ticks, update_ticks=True, labels=None, *, if np.iterable(ticks): self._long_axis().set_ticks(ticks, labels=labels, minor=minor, **kwargs) - self.locator = self._long_axis().get_major_locator() + self._locator = self._long_axis().get_major_locator() else: - self.locator = ticks - self._long_axis().set_major_locator(self.locator) + self._locator = ticks + self._long_axis().set_major_locator(self._locator) self.stale = True def get_ticks(self, minor=False): @@ -911,11 +914,9 @@ def get_ticks(self, minor=False): else: return self._long_axis().get_majorticklocs() - @_api.delete_parameter("3.5", "update_ticks") - def set_ticklabels(self, ticklabels, update_ticks=True, *, minor=False, - **kwargs): + def set_ticklabels(self, ticklabels, *, minor=False, **kwargs): """ - Set tick labels. + [*Discouraged*] Set tick labels. .. admonition:: Discouraged @@ -937,7 +938,7 @@ def set_ticklabels(self, ticklabels, update_ticks=True, *, minor=False, of locations. update_ticks : bool, default: True - This keyword argument is ignored and will be be removed. + This keyword argument is ignored and will be removed. Deprecated minor : bool @@ -953,13 +954,12 @@ def minorticks_on(self): Turn on colorbar minor ticks. """ self.ax.minorticks_on() - self.minorlocator = self._long_axis().get_minor_locator() self._short_axis().set_minor_locator(ticker.NullLocator()) def minorticks_off(self): """Turn the minor ticks of the colorbar off.""" - self.minorlocator = ticker.NullLocator() - self._long_axis().set_minor_locator(self.minorlocator) + self._minorlocator = ticker.NullLocator() + self._long_axis().set_minor_locator(self._minorlocator) def set_label(self, label, *, loc=None, **kwargs): """ @@ -1018,19 +1018,12 @@ def _set_scale(self, scale, **kwargs): Notes ----- - By default, Matplotlib supports the above mentioned scales. + By default, Matplotlib supports the above-mentioned scales. Additionally, custom scales may be registered using `matplotlib.scale.register_scale`. These scales can then also be used here. """ - if self.orientation == 'vertical': - self.ax.set_yscale(scale, **kwargs) - else: - self.ax.set_xscale(scale, **kwargs) - if isinstance(scale, mscale.ScaleBase): - self.__scale = scale.name - else: - self.__scale = scale + self._long_axis()._set_axes_scale(scale, **kwargs) def remove(self): """ @@ -1069,32 +1062,6 @@ def remove(self): # use_gridspec was True ax.set_subplotspec(subplotspec) - def _ticker(self, locator, formatter): - """ - Return the sequence of ticks (colorbar data locations), - ticklabels (strings), and the corresponding offset string. - """ - if isinstance(self.norm, colors.NoNorm) and self.boundaries is None: - intv = self._values[0], self._values[-1] - else: - intv = self.vmin, self.vmax - locator.create_dummy_axis(minpos=intv[0]) - locator.axis.set_view_interval(*intv) - locator.axis.set_data_interval(*intv) - formatter.set_axis(locator.axis) - - b = np.array(locator()) - if isinstance(locator, ticker.LogLocator): - eps = 1e-10 - b = b[(b <= intv[1] * (1 + eps)) & (b >= intv[0] * (1 - eps))] - else: - eps = (intv[1] - intv[0]) * 1e-10 - b = b[(b <= intv[1] + eps) & (b >= intv[0] - eps)] - ticks = self._locate(b) - ticklabels = formatter.format_ticks(b) - offset_string = formatter.get_offset() - return ticks, ticklabels, offset_string - def _process_values(self): """ Set `_boundaries` and `_values` based on the self.boundaries and @@ -1155,24 +1122,24 @@ def _mesh(self): These are scaled between vmin and vmax, and already handle colorbar orientation. """ - # copy the norm and change the vmin and vmax to the vmin and - # vmax of the colorbar, not the norm. This allows the situation - # where the colormap has a narrower range than the colorbar, to - # accommodate extra contours: - norm = copy.deepcopy(self.norm) - norm.vmin = self.vmin - norm.vmax = self.vmax y, _ = self._proportional_y() # Use the vmin and vmax of the colorbar, which may not be the same # as the norm. There are situations where the colormap has a # narrower range than the colorbar and we want to accommodate the # extra contours. - if (isinstance(norm, (colors.BoundaryNorm, colors.NoNorm)) + if (isinstance(self.norm, (colors.BoundaryNorm, colors.NoNorm)) or self.boundaries is not None): # not using a norm. y = y * (self.vmax - self.vmin) + self.vmin else: - y = norm.inverse(y) + # Update the norm values in a context manager as it is only + # a temporary change and we don't want to propagate any signals + # attached to the norm (callbacks.blocked). + with self.norm.callbacks.blocked(), \ + cbook._setattr_cm(self.norm, + vmin=self.vmin, + vmax=self.vmax): + y = self.norm.inverse(y) self._y = y X, Y = np.meshgrid([0., 1.], y) if self.orientation == 'vertical': @@ -1204,10 +1171,15 @@ def _reset_locator_formatter_scale(self): the mappable normal gets changed: Colorbar.update_normal) """ self._process_values() - self.locator = None - self.minorlocator = None - self.formatter = None - if (self.boundaries is not None or + self._locator = None + self._minorlocator = None + self._formatter = None + self._minorformatter = None + if (isinstance(self.mappable, contour.ContourSet) and + isinstance(self.norm, colors.LogNorm)): + # if contours have lognorm, give them a log scale... + self._set_scale('log') + elif (self.boundaries is not None or isinstance(self.norm, colors.BoundaryNorm)): if self.spacing == 'uniform': funcs = (self._forward_boundaries, self._inverse_boundaries) @@ -1381,28 +1353,39 @@ def drag_pan(self, button, key, x, y): def _normalize_location_orientation(location, orientation): if location is None: - location = _api.check_getitem( - {None: "right", "vertical": "right", "horizontal": "bottom"}, - orientation=orientation) + location = _get_ticklocation_from_orientation(orientation) loc_settings = _api.check_getitem({ - "left": {"location": "left", "orientation": "vertical", - "anchor": (1.0, 0.5), "panchor": (0.0, 0.5), "pad": 0.10}, - "right": {"location": "right", "orientation": "vertical", - "anchor": (0.0, 0.5), "panchor": (1.0, 0.5), "pad": 0.05}, - "top": {"location": "top", "orientation": "horizontal", - "anchor": (0.5, 0.0), "panchor": (0.5, 1.0), "pad": 0.05}, - "bottom": {"location": "bottom", "orientation": "horizontal", - "anchor": (0.5, 1.0), "panchor": (0.5, 0.0), "pad": 0.15}, + "left": {"location": "left", "anchor": (1.0, 0.5), + "panchor": (0.0, 0.5), "pad": 0.10}, + "right": {"location": "right", "anchor": (0.0, 0.5), + "panchor": (1.0, 0.5), "pad": 0.05}, + "top": {"location": "top", "anchor": (0.5, 0.0), + "panchor": (0.5, 1.0), "pad": 0.05}, + "bottom": {"location": "bottom", "anchor": (0.5, 1.0), + "panchor": (0.5, 0.0), "pad": 0.15}, }, location=location) + loc_settings["orientation"] = _get_orientation_from_location(location) if orientation is not None and orientation != loc_settings["orientation"]: # Allow the user to pass both if they are consistent. raise TypeError("location and orientation are mutually exclusive") return loc_settings -@docstring.Substitution(_make_axes_param_doc, _make_axes_other_param_doc) +def _get_orientation_from_location(location): + return _api.check_getitem( + {None: None, "left": "vertical", "right": "vertical", + "top": "horizontal", "bottom": "horizontal"}, location=location) + + +def _get_ticklocation_from_orientation(orientation): + return _api.check_getitem( + {None: "right", "vertical": "right", "horizontal": "bottom"}, + orientation=orientation) + + +@_docstring.interpd def make_axes(parents, location=None, orientation=None, fraction=0.15, - shrink=1.0, aspect=20, **kw): + shrink=1.0, aspect=20, **kwargs): """ Create an `~.axes.Axes` suitable for a colorbar. @@ -1411,42 +1394,41 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, Parameters ---------- - parents : `~.axes.Axes` or list of `~.axes.Axes` + parents : `~.axes.Axes` or iterable or `numpy.ndarray` of `~.axes.Axes` The Axes to use as parents for placing the colorbar. - %s + %(_make_axes_kw_doc)s Returns ------- cax : `~.axes.Axes` The child axes. - kw : dict + kwargs : dict The reduced keyword dictionary to be passed when creating the colorbar instance. - - Other Parameters - ---------------- - %s """ loc_settings = _normalize_location_orientation(location, orientation) - # put appropriate values into the kw dict for passing back to + # put appropriate values into the kwargs dict for passing back to # the Colorbar class - kw['orientation'] = loc_settings['orientation'] - location = kw['ticklocation'] = loc_settings['location'] + kwargs['orientation'] = loc_settings['orientation'] + location = kwargs['ticklocation'] = loc_settings['location'] - anchor = kw.pop('anchor', loc_settings['anchor']) - panchor = kw.pop('panchor', loc_settings['panchor']) + anchor = kwargs.pop('anchor', loc_settings['anchor']) + panchor = kwargs.pop('panchor', loc_settings['panchor']) aspect0 = aspect # turn parents into a list if it is not already. Note we cannot # use .flatten or .ravel as these copy the references rather than # reuse them, leading to a memory leak if isinstance(parents, np.ndarray): parents = list(parents.flat) - elif not isinstance(parents, list): + elif np.iterable(parents): + parents = list(parents) + else: parents = [parents] + fig = parents[0].get_figure() pad0 = 0.05 if fig.get_constrained_layout() else loc_settings['pad'] - pad = kw.pop('pad', pad0) + pad = kwargs.pop('pad', pad0) if not all(fig is ax.get_figure() for ax in parents): raise ValueError('Unable to create a colorbar axes as not all ' @@ -1503,58 +1485,50 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, cax.set_box_aspect(aspect) cax.set_aspect('auto') - return cax, kw + return cax, kwargs -@docstring.Substitution(_make_axes_param_doc, _make_axes_other_param_doc) +@_docstring.interpd def make_axes_gridspec(parent, *, location=None, orientation=None, - fraction=0.15, shrink=1.0, aspect=20, **kw): + fraction=0.15, shrink=1.0, aspect=20, **kwargs): """ - Create a `.SubplotBase` suitable for a colorbar. + Create an `~.axes.Axes` suitable for a colorbar. The axes is placed in the figure of the *parent* axes, by resizing and repositioning *parent*. - This function is similar to `.make_axes`. Primary differences are - - - `.make_axes_gridspec` should only be used with a `.SubplotBase` parent. - - - `.make_axes` creates an `~.axes.Axes`; `.make_axes_gridspec` creates a - `.SubplotBase`. + This function is similar to `.make_axes` and mostly compatible with it. + Primary differences are + - `.make_axes_gridspec` requires the *parent* to have a subplotspec. + - `.make_axes` positions the axes in figure coordinates; + `.make_axes_gridspec` positions it using a subplotspec. - `.make_axes` updates the position of the parent. `.make_axes_gridspec` - replaces the ``grid_spec`` attribute of the parent with a new one. - - While this function is meant to be compatible with `.make_axes`, - there could be some minor differences. + replaces the parent gridspec with a new one. Parameters ---------- parent : `~.axes.Axes` The Axes to use as parent for placing the colorbar. - %s + %(_make_axes_kw_doc)s Returns ------- - cax : `~.axes.SubplotBase` + cax : `~.axes.Axes` The child axes. - kw : dict + kwargs : dict The reduced keyword dictionary to be passed when creating the colorbar instance. - - Other Parameters - ---------------- - %s """ loc_settings = _normalize_location_orientation(location, orientation) - kw['orientation'] = loc_settings['orientation'] - location = kw['ticklocation'] = loc_settings['location'] + kwargs['orientation'] = loc_settings['orientation'] + location = kwargs['ticklocation'] = loc_settings['location'] aspect0 = aspect - anchor = kw.pop('anchor', loc_settings['anchor']) - panchor = kw.pop('panchor', loc_settings['panchor']) - pad = kw.pop('pad', loc_settings["pad"]) + anchor = kwargs.pop('anchor', loc_settings['anchor']) + panchor = kwargs.pop('panchor', loc_settings['panchor']) + pad = kwargs.pop('pad', loc_settings["pad"]) wh_space = 2 * pad / (1 - pad) if location in ('left', 'right'): @@ -1599,7 +1573,8 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, aspect = 1 / aspect parent.set_subplotspec(ss_main) - parent.set_anchor(loc_settings["panchor"]) + if panchor is not False: + parent.set_anchor(panchor) fig = parent.get_figure() cax = fig.add_subplot(ss_cb, label="") @@ -1615,37 +1590,5 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, fraction=fraction, aspect=aspect0, pad=pad) - return cax, kw - -@_api.deprecated("3.4", alternative="Colorbar") -class ColorbarPatch(Colorbar): - pass - - -@_api.deprecated("3.4", alternative="Colorbar") -def colorbar_factory(cax, mappable, **kwargs): - """ - Create a colorbar on the given axes for the given mappable. - - .. note:: - This is a low-level function to turn an existing axes into a colorbar - axes. Typically, you'll want to use `~.Figure.colorbar` instead, which - automatically handles creation and placement of a suitable axes as - well. - - Parameters - ---------- - cax : `~matplotlib.axes.Axes` - The `~.axes.Axes` to turn into a colorbar. - mappable : `~matplotlib.cm.ScalarMappable` - The mappable to be described by the colorbar. - **kwargs - Keyword arguments are passed to the respective colorbar class. - - Returns - ------- - `.Colorbar` - The created colorbar instance. - """ - return Colorbar(cax, mappable, **kwargs) + return cax, kwargs diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index c171ff6b99b3..9c0725c8888a 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -40,9 +40,9 @@ """ import base64 -from collections.abc import Sized, Sequence -import copy +from collections.abc import Sized, Sequence, Mapping import functools +import importlib import inspect import io import itertools @@ -53,7 +53,7 @@ import matplotlib as mpl import numpy as np -from matplotlib import _api, cbook, scale +from matplotlib import _api, _cm, cbook, scale from ._color_data import BASE_COLORS, TABLEAU_COLORS, CSS4_COLORS, XKCD_COLORS @@ -93,6 +93,113 @@ def get_named_colors_mapping(): return _colors_full_map +class ColorSequenceRegistry(Mapping): + r""" + Container for sequences of colors that are known to Matplotlib by name. + + The universal registry instance is `matplotlib.color_sequences`. There + should be no need for users to instantiate `.ColorSequenceRegistry` + themselves. + + Read access uses a dict-like interface mapping names to lists of colors:: + + import matplotlib as mpl + cmap = mpl.color_sequences['tab10'] + + The returned lists are copies, so that their modification does not change + the global definition of the color sequence. + + Additional color sequences can be added via + `.ColorSequenceRegistry.register`:: + + mpl.color_sequences.register('rgb', ['r', 'g', 'b']) + """ + + _BUILTIN_COLOR_SEQUENCES = { + 'tab10': _cm._tab10_data, + 'tab20': _cm._tab20_data, + 'tab20b': _cm._tab20b_data, + 'tab20c': _cm._tab20c_data, + 'Pastel1': _cm._Pastel1_data, + 'Pastel2': _cm._Pastel2_data, + 'Paired': _cm._Paired_data, + 'Accent': _cm._Accent_data, + 'Dark2': _cm._Dark2_data, + 'Set1': _cm._Set1_data, + 'Set2': _cm._Set1_data, + 'Set3': _cm._Set1_data, + } + + def __init__(self): + self._color_sequences = {**self._BUILTIN_COLOR_SEQUENCES} + + def __getitem__(self, item): + try: + return list(self._color_sequences[item]) + except KeyError: + raise KeyError(f"{item!r} is not a known color sequence name") + + def __iter__(self): + return iter(self._color_sequences) + + def __len__(self): + return len(self._color_sequences) + + def __str__(self): + return ('ColorSequenceRegistry; available colormaps:\n' + + ', '.join(f"'{name}'" for name in self)) + + def register(self, name, color_list): + """ + Register a new color sequence. + + The color sequence registry stores a copy of the given *color_list*, so + that future changes to the original list do not affect the registered + color sequence. Think of this as the registry taking a snapshot + of *color_list* at registration. + + Parameters + ---------- + name : str + The name for the color sequence. + + color_list : list of colors + An iterable returning valid Matplotlib colors when iterating over. + Note however that the returned color sequence will always be a + list regardless of the input type. + + """ + if name in self._BUILTIN_COLOR_SEQUENCES: + raise ValueError(f"{name!r} is a reserved name for a builtin " + "color sequence") + + color_list = list(color_list) # force copy and coerce type to list + for color in color_list: + try: + to_rgba(color) + except ValueError: + raise ValueError( + f"{color!r} is not a valid color specification") + + self._color_sequences[name] = color_list + + def unregister(self, name): + """ + Remove a sequence from the registry. + + You cannot remove built-in color sequences. + + If the name is not registered, returns with no error. + """ + if name in self._BUILTIN_COLOR_SEQUENCES: + raise ValueError( + f"Cannot unregister builtin color sequence {name!r}") + self._color_sequences.pop(name, None) + + +_color_sequences = ColorSequenceRegistry() + + def _sanitize_extrema(ex): if ex is None: return ex @@ -121,6 +228,12 @@ def is_color_like(c): return True +def _has_alpha_channel(c): + """Return whether *c* is a color with an alpha channel.""" + # 4-element sequences are interpreted as r, g, b, a + return not isinstance(c, str) and len(c) == 4 + + def _check_color_like(**kwargs): """ For each *key, value* pair in *kwargs*, check that *value* is color-like. @@ -175,8 +288,7 @@ def to_rgba(c, alpha=None): """ # Special-case nth color syntax because it should not be cached. if _is_nth_color(c): - from matplotlib import rcParams - prop_cycler = rcParams['axes.prop_cycle'] + prop_cycler = mpl.rcParams['axes.prop_cycle'] colors = prop_cycler.by_key().get('color', ['k']) c = colors[int(c[1:]) % len(colors)] try: @@ -291,8 +403,8 @@ def to_rgba_array(c, alpha=None): Parameters ---------- c : Matplotlib color or array of colors - If *c* is a masked array, an ndarray is returned with a (0, 0, 0, 0) - row for each masked value or row in *c*. + If *c* is a masked array, an `~numpy.ndarray` is returned with a + (0, 0, 0, 0) row for each masked value or row in *c*. alpha : float or sequence of floats, optional If *alpha* is given, force the alpha value of the returned RGBA tuple @@ -356,9 +468,7 @@ def to_rgba_array(c, alpha=None): pass if isinstance(c, str): - raise ValueError("Using a string of single character colors as " - "a color sequence is not supported. The colors can " - "be passed as an explicit list instead.") + raise ValueError(f"{c!r} is not a valid color value.") if len(c) == 0: return np.zeros((0, 4), float) @@ -394,7 +504,7 @@ def to_hex(c, keep_alpha=False): ---------- c : :doc:`color ` or `numpy.ma.masked` - keep_alpha: bool, default: False + keep_alpha : bool, default: False If False, use the ``#rrggbb`` format, otherwise use ``#rrggbbaa``. Returns @@ -405,7 +515,7 @@ def to_hex(c, keep_alpha=False): c = to_rgba(c) if not keep_alpha: c = c[:3] - return "#" + "".join(format(int(round(val * 255)), "02x") for val in c) + return "#" + "".join(format(round(val * 255), "02x") for val in c) ### Backwards-compatible color-conversion API @@ -504,9 +614,7 @@ def _create_lookup_table(N, data, gamma=1.0): adata = np.array(data) except Exception as err: raise TypeError("data must be convertible to an array") from err - shape = adata.shape - if len(shape) != 2 or shape[1] != 3: - raise ValueError("data must be nx3 format") + _api.check_shape((None, 3), data=adata) x = adata[:, 0] y0 = adata[:, 1] @@ -536,20 +644,6 @@ def _create_lookup_table(N, data, gamma=1.0): return np.clip(lut, 0.0, 1.0) -def _warn_if_global_cmap_modified(cmap): - if getattr(cmap, '_global', False): - _api.warn_deprecated( - "3.3", - removal="3.6", - message="You are modifying the state of a globally registered " - "colormap. This has been deprecated since %(since)s and " - "%(removal)s, you will not be able to modify a " - "registered colormap in-place. To remove this warning, " - "you can make a copy of the colormap first. " - f'cmap = mpl.cm.get_cmap("{cmap.name}").copy()' - ) - - class Colormap: """ Baseclass for all scalar to RGBA mappings. @@ -569,7 +663,7 @@ def __init__(self, name, N=256): name : str The name of the colormap. N : int - The number of rgb quantization levels. + The number of RGB quantization levels. """ self.name = name self.N = int(N) # ensure that N is always int @@ -590,11 +684,11 @@ def __call__(self, X, alpha=None, bytes=False): """ Parameters ---------- - X : float or int, ndarray or scalar + X : float or int, `~numpy.ndarray` or scalar The data value(s) to convert to RGBA. - For floats, X should be in the interval ``[0.0, 1.0]`` to + For floats, *X* should be in the interval ``[0.0, 1.0]`` to return the RGBA values ``X*100`` percent along the Colormap line. - For integers, X should be in the interval ``[0, Colormap.N)`` to + For integers, *X* should be in the interval ``[0, Colormap.N)`` to return RGBA values *indexed* from the Colormap with index ``X``. alpha : float or array-like or None Alpha must be a scalar between 0 and 1, a sequence of such @@ -612,45 +706,46 @@ def __call__(self, X, alpha=None, bytes=False): if not self._isinit: self._init() - mask_bad = X.mask if np.ma.is_masked(X) else np.isnan(X) # Mask nan's. + # Take the bad mask from a masked array, or in all other cases defer + # np.isnan() to after we have converted to an array. + mask_bad = X.mask if np.ma.is_masked(X) else None xa = np.array(X, copy=True) + if mask_bad is None: + mask_bad = np.isnan(xa) if not xa.dtype.isnative: xa = xa.byteswap().newbyteorder() # Native byteorder is faster. if xa.dtype.kind == "f": - with np.errstate(invalid="ignore"): - xa *= self.N - # Negative values are out of range, but astype(int) would - # truncate them towards zero. - xa[xa < 0] = -1 - # xa == 1 (== N after multiplication) is not out of range. - xa[xa == self.N] = self.N - 1 - # Avoid converting large positive values to negative integers. - np.clip(xa, -1, self.N, out=xa) - xa = xa.astype(int) + xa *= self.N + # Negative values are out of range, but astype(int) would + # truncate them towards zero. + xa[xa < 0] = -1 + # xa == 1 (== N after multiplication) is not out of range. + xa[xa == self.N] = self.N - 1 + # Avoid converting large positive values to negative integers. + np.clip(xa, -1, self.N, out=xa) + with np.errstate(invalid="ignore"): + # We need this cast for unsigned ints as well as floats + xa = xa.astype(int) # Set the over-range indices before the under-range; # otherwise the under-range values get converted to over-range. xa[xa > self.N - 1] = self._i_over xa[xa < 0] = self._i_under xa[mask_bad] = self._i_bad + lut = self._lut if bytes: - lut = (self._lut * 255).astype(np.uint8) - else: - lut = self._lut.copy() # Don't let alpha modify original _lut. + lut = (lut * 255).astype(np.uint8) - rgba = np.empty(shape=xa.shape + (4,), dtype=lut.dtype) - lut.take(xa, axis=0, mode='clip', out=rgba) + rgba = lut.take(xa, axis=0, mode='clip') if alpha is not None: - if np.iterable(alpha): - alpha = np.asarray(alpha) - if alpha.shape != xa.shape: - raise ValueError("alpha is array-like but its shape" - " %s doesn't match that of X %s" % - (alpha.shape, xa.shape)) alpha = np.clip(alpha, 0, 1) if bytes: - alpha = (alpha * 255).astype(np.uint8) + alpha *= 255 # Will be cast to uint8 upon assignment. + if alpha.shape not in [(), xa.shape]: + raise ValueError( + f"alpha is array-like but its shape {alpha.shape} does " + f"not match that of X {xa.shape}") rgba[..., -1] = alpha # If the "bad" color is all zeros, then ignore alpha input. @@ -670,7 +765,6 @@ def __copy__(self): cmapobject.__dict__.update(self.__dict__) if self._isinit: cmapobject._lut = np.copy(self._lut) - cmapobject._global = False return cmapobject def __eq__(self, other): @@ -692,7 +786,6 @@ def get_bad(self): def set_bad(self, color='k', alpha=None): """Set the color for masked values.""" - _warn_if_global_cmap_modified(self) self._rgba_bad = to_rgba(color, alpha) if self._isinit: self._set_extremes() @@ -705,7 +798,6 @@ def get_under(self): def set_under(self, color='k', alpha=None): """Set the color for low out-of-range values.""" - _warn_if_global_cmap_modified(self) self._rgba_under = to_rgba(color, alpha) if self._isinit: self._set_extremes() @@ -718,7 +810,6 @@ def get_over(self): def set_over(self, color='k', alpha=None): """Set the color for high out-of-range values.""" - _warn_if_global_cmap_modified(self) self._rgba_over = to_rgba(color, alpha) if self._isinit: self._set_extremes() @@ -741,7 +832,7 @@ def with_extremes(self, *, bad=None, under=None, over=None): values and, when ``norm.clip = False``, low (*under*) and high (*over*) out-of-range values, have been set accordingly. """ - new_cm = copy.copy(self) + new_cm = self.copy() new_cm.set_extremes(bad=bad, under=under, over=over) return new_cm @@ -767,21 +858,30 @@ def is_gray(self): return (np.all(self._lut[:, 0] == self._lut[:, 1]) and np.all(self._lut[:, 0] == self._lut[:, 2])) - def _resample(self, lutsize): + def resampled(self, lutsize): """Return a new colormap with *lutsize* entries.""" + if hasattr(self, '_resample'): + _api.warn_external( + "The ability to resample a color map is now public API " + f"However the class {type(self)} still only implements " + "the previous private _resample method. Please update " + "your class." + ) + return self._resample(lutsize) + raise NotImplementedError() def reversed(self, name=None): """ Return a reversed instance of the Colormap. - .. note:: This function is not implemented for base class. + .. note:: This function is not implemented for the base class. Parameters ---------- name : str, optional - The name for the reversed colormap. If it's None the - name will be the name of the parent colormap + "_r". + The name for the reversed colormap. If None, the + name is set to ``self.name + "_r"``. See Also -------- @@ -940,7 +1040,7 @@ def from_list(name, colors, N=256, gamma=1.0): If (value, color) pairs are given, the mapping is from *value* to *color*. This can be used to divide the range unevenly. N : int - The number of rgb quantization levels. + The number of RGB quantization levels. gamma : float """ if not np.iterable(colors): @@ -963,7 +1063,7 @@ def from_list(name, colors, N=256, gamma=1.0): return LinearSegmentedColormap(name, cdict, N, gamma) - def _resample(self, lutsize): + def resampled(self, lutsize): """Return a new colormap with *lutsize* entries.""" new_cmap = LinearSegmentedColormap(self.name, self._segmentdata, lutsize) @@ -984,8 +1084,8 @@ def reversed(self, name=None): Parameters ---------- name : str, optional - The name for the reversed colormap. If it's None the - name will be the name of the parent colormap + "_r". + The name for the reversed colormap. If None, the + name is set to ``self.name + "_r"``. Returns ------- @@ -1021,7 +1121,7 @@ class ListedColormap(Colormap): ---------- colors : list, array List of Matplotlib color specifications, or an equivalent Nx3 or Nx4 - floating point array (*N* rgb or rgba values). + floating point array (*N* RGB or RGBA values). name : str, optional String to identify the colormap. N : int, optional @@ -1067,7 +1167,7 @@ def _init(self): self._isinit = True self._set_extremes() - def _resample(self, lutsize): + def resampled(self, lutsize): """Return a new colormap with *lutsize* entries.""" colors = self(np.linspace(0, 1, lutsize)) new_cmap = ListedColormap(colors, name=self.name) @@ -1084,8 +1184,8 @@ def reversed(self, name=None): Parameters ---------- name : str, optional - The name for the reversed colormap. If it's None the - name will be the name of the parent colormap + "_r". + The name for the reversed colormap. If None, the + name is set to ``self.name + "_r"``. Returns ------- @@ -1136,7 +1236,7 @@ def __init__(self, vmin=None, vmax=None, clip=False): self._vmax = _sanitize_extrema(vmax) self._clip = clip self._scale = None - self.callbacks = cbook.CallbackRegistry() + self.callbacks = cbook.CallbackRegistry(signals=["changed"]) @property def vmin(self): @@ -1235,12 +1335,13 @@ def __call__(self, value, clip=None): result, is_scalar = self.process_value(value) - self.autoscale_None(result) + if self.vmin is None or self.vmax is None: + self.autoscale_None(result) # Convert at least to float, without losing precision. (vmin,), _ = self.process_value(self.vmin) (vmax,), _ = self.process_value(self.vmax) if vmin == vmax: - result.fill(0) # Or should it be all masked? Or 0.5? + result.fill(0) # Or should it be all masked? Or 0.5? elif vmin > vmax: raise ValueError("minvalue must be less than or equal to maxvalue") else: @@ -1271,9 +1372,12 @@ def inverse(self, value): def autoscale(self, A): """Set *vmin*, *vmax* to min, max of *A*.""" - A = np.asanyarray(A) - self.vmin = A.min() - self.vmax = A.max() + with self.callbacks.blocked(): + # Pause callbacks while we are updating so we only get + # a single update signal at the end + self.vmin = self.vmax = None + self.autoscale_None(A) + self._changed() def autoscale_None(self, A): """If vmin or vmax are not set, use the min/max of *A* to set them.""" @@ -1418,28 +1522,44 @@ def __init__(self, vcenter=0, halfrange=None, clip=False): # calling the halfrange setter to set vmin and vmax self.halfrange = halfrange - def _set_vmin_vmax(self): - """ - Set *vmin* and *vmax* based on *vcenter* and *halfrange*. - """ - self.vmax = self._vcenter + self._halfrange - self.vmin = self._vcenter - self._halfrange - def autoscale(self, A): """ Set *halfrange* to ``max(abs(A-vcenter))``, then set *vmin* and *vmax*. """ A = np.asanyarray(A) - self._halfrange = max(self._vcenter-A.min(), - A.max()-self._vcenter) - self._set_vmin_vmax() + self.halfrange = max(self._vcenter-A.min(), + A.max()-self._vcenter) def autoscale_None(self, A): """Set *vmin* and *vmax*.""" A = np.asanyarray(A) - if self._halfrange is None and A.size: + if self.halfrange is None and A.size: self.autoscale(A) + @property + def vmin(self): + return self._vmin + + @vmin.setter + def vmin(self, value): + value = _sanitize_extrema(value) + if value != self._vmin: + self._vmin = value + self._vmax = 2*self.vcenter - value + self._changed() + + @property + def vmax(self): + return self._vmax + + @vmax.setter + def vmax(self, value): + value = _sanitize_extrema(value) + if value != self._vmax: + self._vmax = value + self._vmin = 2*self.vcenter - value + self._changed() + @property def vcenter(self): return self._vcenter @@ -1448,32 +1568,24 @@ def vcenter(self): def vcenter(self, vcenter): if vcenter != self._vcenter: self._vcenter = vcenter + # Trigger an update of the vmin/vmax values through the setter + self.halfrange = self.halfrange self._changed() - if self.vmax is not None: - # recompute halfrange assuming vmin and vmax represent - # min and max of data - self._halfrange = max(self._vcenter-self.vmin, - self.vmax-self._vcenter) - self._set_vmin_vmax() @property def halfrange(self): - return self._halfrange + if self.vmin is None or self.vmax is None: + return None + return (self.vmax - self.vmin) / 2 @halfrange.setter def halfrange(self, halfrange): if halfrange is None: - self._halfrange = None self.vmin = None self.vmax = None else: - self._halfrange = abs(halfrange) - - def __call__(self, value, clip=None): - if self._halfrange is not None: - # enforce symmetry, reset vmin and vmax - self._set_vmin_vmax() - return super().__call__(value, clip=clip) + self.vmin = self.vcenter - abs(halfrange) + self.vmax = self.vcenter + abs(halfrange) def make_norm_from_scale(scale_cls, base_norm_cls=None, *, init=None): @@ -1510,23 +1622,77 @@ class norm_cls(Normalize): if base_norm_cls is None: return functools.partial(make_norm_from_scale, scale_cls, init=init) + if isinstance(scale_cls, functools.partial): + scale_args = scale_cls.args + scale_kwargs_items = tuple(scale_cls.keywords.items()) + scale_cls = scale_cls.func + else: + scale_args = scale_kwargs_items = () + if init is None: def init(vmin=None, vmax=None, clip=False): pass - bound_init_signature = inspect.signature(init) + + return _make_norm_from_scale( + scale_cls, scale_args, scale_kwargs_items, + base_norm_cls, inspect.signature(init)) + + +@functools.lru_cache(None) +def _make_norm_from_scale( + scale_cls, scale_args, scale_kwargs_items, + base_norm_cls, bound_init_signature, +): + """ + Helper for `make_norm_from_scale`. + + This function is split out to enable caching (in particular so that + different unpickles reuse the same class). In order to do so, + + - ``functools.partial`` *scale_cls* is expanded into ``func, args, kwargs`` + to allow memoizing returned norms (partial instances always compare + unequal, but we can check identity based on ``func, args, kwargs``; + - *init* is replaced by *init_signature*, as signatures are picklable, + unlike to arbitrary lambdas. + """ class Norm(base_norm_cls): + def __reduce__(self): + cls = type(self) + # If the class is toplevel-accessible, it is possible to directly + # pickle it "by name". This is required to support norm classes + # defined at a module's toplevel, as the inner base_norm_cls is + # otherwise unpicklable (as it gets shadowed by the generated norm + # class). If either import or attribute access fails, fall back to + # the general path. + try: + if cls is getattr(importlib.import_module(cls.__module__), + cls.__qualname__): + return (_create_empty_object_of_class, (cls,), vars(self)) + except (ImportError, AttributeError): + pass + return (_picklable_norm_constructor, + (scale_cls, scale_args, scale_kwargs_items, + base_norm_cls, bound_init_signature), + vars(self)) def __init__(self, *args, **kwargs): ba = bound_init_signature.bind(*args, **kwargs) ba.apply_defaults() super().__init__( **{k: ba.arguments.pop(k) for k in ["vmin", "vmax", "clip"]}) - self._scale = scale_cls(axis=None, **ba.arguments) + self._scale = functools.partial( + scale_cls, *scale_args, **dict(scale_kwargs_items))( + axis=None, **ba.arguments) self._trf = self._scale.get_transform() + __init__.__signature__ = bound_init_signature.replace(parameters=[ + inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD), + *bound_init_signature.parameters.values()]) + def __call__(self, value, clip=None): value, is_scalar = self.process_value(value) - self.autoscale_None(value) + if self.vmin is None or self.vmax is None: + self.autoscale_None(value) if self.vmin > self.vmax: raise ValueError("vmin must be less or equal to vmax") if self.vmin == self.vmax: @@ -1561,17 +1727,33 @@ def inverse(self, value): .reshape(np.shape(value))) return value[0] if is_scalar else value - Norm.__name__ = (f"{scale_cls.__name__}Norm" if base_norm_cls is Normalize - else base_norm_cls.__name__) - Norm.__qualname__ = base_norm_cls.__qualname__ + def autoscale_None(self, A): + # i.e. A[np.isfinite(...)], but also for non-array A's + in_trf_domain = np.extract(np.isfinite(self._trf.transform(A)), A) + if in_trf_domain.size == 0: + in_trf_domain = np.ma.masked + return super().autoscale_None(in_trf_domain) + + if base_norm_cls is Normalize: + Norm.__name__ = f"{scale_cls.__name__}Norm" + Norm.__qualname__ = f"{scale_cls.__qualname__}Norm" + else: + Norm.__name__ = base_norm_cls.__name__ + Norm.__qualname__ = base_norm_cls.__qualname__ Norm.__module__ = base_norm_cls.__module__ Norm.__doc__ = base_norm_cls.__doc__ - Norm.__init__.__signature__ = bound_init_signature.replace(parameters=[ - inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD), - *bound_init_signature.parameters.values()]) + return Norm +def _create_empty_object_of_class(cls): + return cls.__new__(cls) + + +def _picklable_norm_constructor(*args): + return _create_empty_object_of_class(_make_norm_from_scale(*args)) + + @make_norm_from_scale( scale.FuncScale, init=lambda functions, vmin=None, vmax=None, clip=False: None) @@ -1605,17 +1787,10 @@ def forward(values: array-like) -> array-like """ -@make_norm_from_scale(functools.partial(scale.LogScale, nonpositive="mask")) -class LogNorm(Normalize): - """Normalize a given value to the 0-1 range on a log scale.""" - - def autoscale(self, A): - # docstring inherited. - super().autoscale(np.ma.array(A, mask=(A <= 0))) - - def autoscale_None(self, A): - # docstring inherited. - super().autoscale_None(np.ma.array(A, mask=(A <= 0))) +LogNorm = make_norm_from_scale( + functools.partial(scale.LogScale, nonpositive="mask"))(Normalize) +LogNorm.__name__ = LogNorm.__qualname__ = "LogNorm" +LogNorm.__doc__ = "Normalize a given value to the 0-1 range on a log scale." @make_norm_from_scale( @@ -1656,6 +1831,38 @@ def linthresh(self, value): self._scale.linthresh = value +@make_norm_from_scale( + scale.AsinhScale, + init=lambda linear_width=1, vmin=None, vmax=None, clip=False: None) +class AsinhNorm(Normalize): + """ + The inverse hyperbolic sine scale is approximately linear near + the origin, but becomes logarithmic for larger positive + or negative values. Unlike the `SymLogNorm`, the transition between + these linear and logarithmic regions is smooth, which may reduce + the risk of visual artifacts. + + .. note:: + + This API is provisional and may be revised in the future + based on early user feedback. + + Parameters + ---------- + linear_width : float, default: 1 + The effective width of the linear region, beyond which + the transformation becomes asymptotically logarithmic + """ + + @property + def linear_width(self): + return self._scale.linear_width + + @linear_width.setter + def linear_width(self, value): + self._scale.linear_width = value + + class PowerNorm(Normalize): """ Linearly map a given value to the 0-1 range and then apply @@ -1713,19 +1920,23 @@ class BoundaryNorm(Normalize): Unlike `Normalize` or `LogNorm`, `BoundaryNorm` maps values to integers instead of to the interval 0-1. - - Mapping to the 0-1 interval could have been done via piece-wise linear - interpolation, but using integers seems simpler, and reduces the number of - conversions back and forth between integer and floating point. """ + + # Mapping to the 0-1 interval could have been done via piece-wise linear + # interpolation, but using integers seems simpler, and reduces the number + # of conversions back and forth between int and float. + def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'): """ Parameters ---------- boundaries : array-like - Monotonically increasing sequence of at least 2 boundaries. + Monotonically increasing sequence of at least 2 bin edges: data + falling in the n-th bin will be mapped to the n-th color. + ncolors : int Number of colors in the colormap to be used. + clip : bool, optional If clip is ``True``, out of range values are mapped to 0 if they are below ``boundaries[0]`` or mapped to ``ncolors - 1`` if they @@ -1735,6 +1946,7 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'): they are below ``boundaries[0]`` or mapped to *ncolors* if they are above ``boundaries[-1]``. These are then converted to valid indices by `Colormap.__call__`. + extend : {'neither', 'both', 'min', 'max'}, default: 'neither' Extend the number of bins to include one or both of the regions beyond the boundaries. For example, if ``extend`` @@ -1744,18 +1956,12 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'): `~matplotlib.colorbar.Colorbar` will be drawn with the triangle extension on the left or lower end. - Returns - ------- - int16 scalar or array - Notes ----- - *boundaries* defines the edges of bins, and data falling within a bin - is mapped to the color with the same index. - - If the number of bins, including any extensions, is less than - *ncolors*, the color index is chosen by linear interpolation, mapping - the ``[0, nbins - 1]`` range onto the ``[0, ncolors - 1]`` range. + If there are fewer bins (including extensions) than colors, then the + color index is chosen by linearly interpolating the ``[0, nbins - 1]`` + range onto the ``[0, ncolors - 1]`` range, effectively skipping some + colors in the middle of the colormap. """ if clip and extend != 'neither': raise ValueError("'clip=True' is not compatible with 'extend'") @@ -1784,6 +1990,10 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'): "number of bins") def __call__(self, value, clip=None): + """ + This method behaves similarly to `.Normalize.__call__`, except that it + returns integers or arrays of int16. + """ if clip is None: clip = self.clip @@ -1846,7 +2056,7 @@ def inverse(self, value): def rgb_to_hsv(arr): """ - Convert float rgb values (in the range [0, 1]), in a numpy array to hsv + Convert float RGB values (in the range [0, 1]), in a numpy array to HSV values. Parameters @@ -1856,8 +2066,8 @@ def rgb_to_hsv(arr): Returns ------- - (..., 3) ndarray - Colors converted to hsv values in range [0, 1] + (..., 3) `~numpy.ndarray` + Colors converted to HSV values in range [0, 1] """ arr = np.asarray(arr) @@ -1898,7 +2108,7 @@ def rgb_to_hsv(arr): def hsv_to_rgb(hsv): """ - Convert hsv values to rgb. + Convert HSV values to RGB. Parameters ---------- @@ -1907,7 +2117,7 @@ def hsv_to_rgb(hsv): Returns ------- - (..., 3) ndarray + (..., 3) `~numpy.ndarray` Colors converted to RGB values in range [0, 1] """ hsv = np.asarray(hsv) @@ -1994,8 +2204,8 @@ class LightSource: Angles are in degrees, with the azimuth measured clockwise from north and elevation up from the zero plane of the surface. - `shade` is used to produce "shaded" rgb values for a data array. - `shade_rgb` can be used to combine an rgb image with an elevation map. + `shade` is used to produce "shaded" RGB values for a data array. + `shade_rgb` can be used to combine an RGB image with an elevation map. `hillshade` produces an illumination map of a surface. """ @@ -2074,7 +2284,7 @@ def hillshade(self, elevation, vert_exag=1, dx=1, dy=1, fraction=1.): Returns ------- - ndarray + `~numpy.ndarray` A 2D array of illumination values between 0-1, where 0 is completely in shadow and 1 is completely illuminated. """ @@ -2117,7 +2327,7 @@ def shade_normals(self, normals, fraction=1.): Returns ------- - ndarray + `~numpy.ndarray` A 2D array of illumination values between 0-1, where 0 is completely in shadow and 1 is completely illuminated. """ @@ -2199,7 +2409,7 @@ def shade(self, data, cmap, norm=None, blend_mode='overlay', vmin=None, Returns ------- - ndarray + `~numpy.ndarray` An MxNx4 array of floats ranging between 0-1. """ if vmin is None: @@ -2260,7 +2470,7 @@ def shade_rgb(self, rgb, elevation, fraction=1., blend_mode='hsv', Returns ------- - ndarray + `~numpy.ndarray` An (m, n, 3) array of floats ranging between 0-1. """ # Calculate the "hillshade" intensity. @@ -2299,7 +2509,7 @@ def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None, which can then be used to plot the shaded image with imshow. The color of the resulting image will be darkened by moving the (s, v) - values (in hsv colorspace) toward (hsv_min_sat, hsv_min_val) in the + values (in HSV colorspace) toward (hsv_min_sat, hsv_min_val) in the shaded regions, or lightened by sliding (s, v) toward (hsv_max_sat, hsv_max_val) in regions that are illuminated. The default extremes are chose so that completely shaded points are nearly black (s = 1, v = 0) @@ -2307,9 +2517,9 @@ def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None, Parameters ---------- - rgb : ndarray + rgb : `~numpy.ndarray` An MxNx3 RGB array of floats ranging from 0 to 1 (color image). - intensity : ndarray + intensity : `~numpy.ndarray` An MxNx1 array of floats ranging from 0 to 1 (grayscale image). hsv_max_sat : number, default: 1 The maximum saturation value that the *intensity* map can shift the @@ -2326,7 +2536,7 @@ def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None, Returns ------- - ndarray + `~numpy.ndarray` An MxNx3 RGB array representing the combined images. """ # Backward compatibility... @@ -2364,32 +2574,32 @@ def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None, def blend_soft_light(self, rgb, intensity): """ - Combine an rgb image with an intensity map using "soft light" blending, + Combine an RGB image with an intensity map using "soft light" blending, using the "pegtop" formula. Parameters ---------- - rgb : ndarray + rgb : `~numpy.ndarray` An MxNx3 RGB array of floats ranging from 0 to 1 (color image). - intensity : ndarray + intensity : `~numpy.ndarray` An MxNx1 array of floats ranging from 0 to 1 (grayscale image). Returns ------- - ndarray + `~numpy.ndarray` An MxNx3 RGB array representing the combined images. """ return 2 * intensity * rgb + (1 - 2 * intensity) * rgb**2 def blend_overlay(self, rgb, intensity): """ - Combine an rgb image with an intensity map using "overlay" blending. + Combine an RGB image with an intensity map using "overlay" blending. Parameters ---------- - rgb : ndarray + rgb : `~numpy.ndarray` An MxNx3 RGB array of floats ranging from 0 to 1 (color image). - intensity : ndarray + intensity : `~numpy.ndarray` An MxNx1 array of floats ranging from 0 to 1 (grayscale image). Returns diff --git a/lib/matplotlib/container.py b/lib/matplotlib/container.py index c53cf9fccff9..a58e55ca196c 100644 --- a/lib/matplotlib/container.py +++ b/lib/matplotlib/container.py @@ -1,5 +1,5 @@ +from matplotlib import cbook from matplotlib.artist import Artist -import matplotlib.cbook as cbook class Container(tuple): @@ -18,7 +18,7 @@ def __new__(cls, *args, **kwargs): return tuple.__new__(cls, args[0]) def __init__(self, kl, label=None): - self._callbacks = cbook.CallbackRegistry() + self._callbacks = cbook.CallbackRegistry(signals=["pchanged"]) self._remove_method = None self.set_label(label) diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index e1b5fc3cf728..42096958bb93 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -9,15 +9,15 @@ from numpy import ma import matplotlib as mpl -from matplotlib import _api, docstring +from matplotlib import _api, _docstring from matplotlib.backend_bases import MouseButton +from matplotlib.text import Text import matplotlib.path as mpath import matplotlib.ticker as ticker import matplotlib.cm as cm import matplotlib.colors as mcolors import matplotlib.collections as mcoll import matplotlib.font_manager as font_manager -import matplotlib.text as text import matplotlib.cbook as cbook import matplotlib.patches as mpatches import matplotlib.transforms as mtransforms @@ -31,7 +31,8 @@ # per level. -class ClabelText(text.Text): +@_api.deprecated("3.7", alternative="Text.set_transform_rotates_text") +class ClabelText(Text): """ Unlike the ordinary text, the get_rotation returns an updated angle in the pixel coordinate assuming that the input rotation is @@ -150,10 +151,8 @@ def clabel(self, levels=None, *, or minus 90 degrees from level. use_clabeltext : bool, default: False - If ``True``, `.ClabelText` class (instead of `.Text`) is used to - create labels. `ClabelText` recalculates rotation angles - of texts during the drawing time, therefore this can be used if - aspect of the axes changes. + If ``True``, use `.Text.set_transform_rotates_text` to ensure that + label rotation is updated whenever the axes aspect changes. zorder : float or None, default: ``(2 + contour.get_zorder())`` zorder of the contour labels. @@ -202,10 +201,7 @@ def clabel(self, levels=None, *, self.labelLevelList = levels self.labelIndiceList = indices - self.labelFontProps = font_manager.FontProperties() - self.labelFontProps.set_size(fontsize) - font_size_pts = self.labelFontProps.get_size_in_points() - self.labelFontSizeList = [font_size_pts] * len(levels) + self._label_font_props = font_manager.FontProperties(size=fontsize) if colors is None: self.labelMappable = self @@ -218,10 +214,10 @@ def clabel(self, levels=None, *, self.labelXYs = [] - if np.iterable(self.labelManual): - for x, y in self.labelManual: + if np.iterable(manual): + for x, y in manual: self.add_label_near(x, y, inline, inline_spacing) - elif self.labelManual: + elif manual: print('Select label locations manually using first mouse button.') print('End manual selection with second mouse button.') if not inline: @@ -234,8 +230,23 @@ def clabel(self, levels=None, *, else: self.labels(inline, inline_spacing) - self.labelTextsList = cbook.silent_list('text.Text', self.labelTexts) - return self.labelTextsList + return cbook.silent_list('text.Text', self.labelTexts) + + @_api.deprecated("3.7", alternative="cs.labelTexts[0].get_font()") + @property + def labelFontProps(self): + return self._label_font_props + + @_api.deprecated("3.7", alternative=( + "[cs.labelTexts[0].get_font().get_size()] * len(cs.labelLevelList)")) + @property + def labelFontSizeList(self): + return [self._label_font_props.get_size()] * len(self.labelLevelList) + + @_api.deprecated("3.7", alternative="cs.labelTexts") + @property + def labelTextsList(self): + return cbook.silent_list('text.Text', self.labelTexts) def print_label(self, linecontour, labelwidth): """Return whether a contour is long enough to hold a label.""" @@ -248,59 +259,21 @@ def too_close(self, x, y, lw): return any((x - loc[0]) ** 2 + (y - loc[1]) ** 2 < thresh for loc in self.labelXYs) - @_api.deprecated("3.4") - def get_label_coords(self, distances, XX, YY, ysize, lw): - """ - Return x, y, and the index of a label location. - - Labels are plotted at a location with the smallest - deviation of the contour from a straight line - unless there is another label nearby, in which case - the next best place on the contour is picked up. - If all such candidates are rejected, the beginning - of the contour is chosen. - """ - hysize = int(ysize / 2) - adist = np.argsort(distances) - - for ind in adist: - x, y = XX[ind][hysize], YY[ind][hysize] - if self.too_close(x, y, lw): - continue - return x, y, ind - - ind = adist[0] - x, y = XX[ind][hysize], YY[ind][hysize] - return x, y, ind - def _get_nth_label_width(self, nth): """Return the width of the *nth* label, in pixels.""" fig = self.axes.figure - return ( - text.Text(0, 0, - self.get_text(self.labelLevelList[nth], self.labelFmt), - figure=fig, - size=self.labelFontSizeList[nth], - fontproperties=self.labelFontProps) - .get_window_extent(mpl.tight_layout.get_renderer(fig)).width) - - @_api.deprecated("3.5") - def get_label_width(self, lev, fmt, fsize): - """Return the width of the label in points.""" - if not isinstance(lev, str): - lev = self.get_text(lev, fmt) - fig = self.axes.figure - width = (text.Text(0, 0, lev, figure=fig, - size=fsize, fontproperties=self.labelFontProps) - .get_window_extent(mpl.tight_layout.get_renderer(fig)).width) - width *= 72 / fig.dpi - return width + renderer = fig._get_renderer() + return (Text(0, 0, + self.get_text(self.labelLevelList[nth], self.labelFmt), + figure=fig, fontproperties=self._label_font_props) + .get_window_extent(renderer).width) + @_api.deprecated("3.7", alternative="Artist.set") def set_label_props(self, label, text, color): """Set the label properties - color, fontsize, text.""" label.set_text(text) label.set_color(color) - label.set_fontproperties(self.labelFontProps) + label.set_fontproperties(self._label_font_props) label.set_clip_box(self.axes.bbox) def get_text(self, lev, fmt): @@ -440,57 +413,32 @@ def calc_label_rot_and_inline(self, slc, ind, lw, lc=None, spacing=5): return rotation, nlc - def _get_label_text(self, x, y, rotation): - dx, dy = self.axes.transData.inverted().transform((x, y)) - t = text.Text(dx, dy, rotation=rotation, - horizontalalignment='center', - verticalalignment='center', zorder=self._clabel_zorder) - return t - - def _get_label_clabeltext(self, x, y, rotation): - # x, y, rotation is given in pixel coordinate. Convert them to - # the data coordinate and create a label using ClabelText - # class. This way, the rotation of the clabel is along the - # contour line always. - transDataInv = self.axes.transData.inverted() - dx, dy = transDataInv.transform((x, y)) - drotation = transDataInv.transform_angles(np.array([rotation]), - np.array([[x, y]])) - t = ClabelText(dx, dy, rotation=drotation[0], - horizontalalignment='center', - verticalalignment='center', zorder=self._clabel_zorder) - - return t - - def _add_label(self, t, x, y, lev, cvalue): - color = self.labelMappable.to_rgba(cvalue, alpha=self.alpha) - - _text = self.get_text(lev, self.labelFmt) - self.set_label_props(t, _text, color) + def add_label(self, x, y, rotation, lev, cvalue): + """Add contour label without `.Text.set_transform_rotates_text`.""" + data_x, data_y = self.axes.transData.inverted().transform((x, y)) + t = Text( + data_x, data_y, + text=self.get_text(lev, self.labelFmt), + rotation=rotation, + horizontalalignment='center', verticalalignment='center', + zorder=self._clabel_zorder, + color=self.labelMappable.to_rgba(cvalue, alpha=self.alpha), + fontproperties=self._label_font_props, + clip_box=self.axes.bbox) self.labelTexts.append(t) self.labelCValues.append(cvalue) self.labelXYs.append((x, y)) - # Add label to plot here - useful for manual mode label selection self.axes.add_artist(t) - def add_label(self, x, y, rotation, lev, cvalue): - """ - Add contour label using :class:`~matplotlib.text.Text` class. - """ - t = self._get_label_text(x, y, rotation) - self._add_label(t, x, y, lev, cvalue) - def add_label_clabeltext(self, x, y, rotation, lev, cvalue): - """ - Add contour label using :class:`ClabelText` class. - """ - # x, y, rotation is given in pixel coordinate. Convert them to - # the data coordinate and create a label using ClabelText - # class. This way, the rotation of the clabel is along the - # contour line always. - t = self._get_label_clabeltext(x, y, rotation) - self._add_label(t, x, y, lev, cvalue) + """Add contour label with `.Text.set_transform_rotates_text`.""" + self.add_label(x, y, rotation, lev, cvalue) + # Grab the last added text, and reconfigure its rotation. + t = self.labelTexts[-1] + data_rotation, = self.axes.transData.inverted().transform_angles( + [rotation], [[x, y]]) + t.set(rotation=data_rotation, transform_rotates_text=True) def add_label_near(self, x, y, inline=True, inline_spacing=5, transform=None): @@ -554,9 +502,7 @@ def add_label_near(self, x, y, inline=True, inline_spacing=5, paths.pop(segmin) # Add paths if not empty or single point - for n in nlc: - if len(n) > 1: - paths.append(mpath.Path(n)) + paths.extend([mpath.Path(n) for n in nlc if len(n) > 1]) def pop_label(self, index=-1): """Defaults to removing last label, but any index can be supplied""" @@ -610,6 +556,10 @@ def labels(self, inline, inline_spacing): if inline: paths[:] = additions + def remove(self): + for text in self.labelTexts: + text.remove() + def _is_closed_polygon(X): """ @@ -637,7 +587,7 @@ def _find_closest_point_on_path(xys, p): Projection of *p* onto *xys*. imin : (int, int) Consecutive indices of vertices of segment in *xys* where *proj* is. - Segments are considered as including their end-points; i.e if the + Segments are considered as including their end-points; i.e. if the closest point on the path is a node in *xys* with index *i*, this returns ``(i-1, i)``. For the special case where *xys* is a single point, this returns ``(0, 0)``. @@ -656,7 +606,7 @@ def _find_closest_point_on_path(xys, p): return (d2s[imin], projs[imin], (imin, imin+1)) -docstring.interpd.update(contour_set_attributes=r""" +_docstring.interpd.update(contour_set_attributes=r""" Attributes ---------- ax : `~matplotlib.axes.Axes` @@ -675,7 +625,7 @@ def _find_closest_point_on_path(xys, p): """) -@docstring.dedent_interpd +@_docstring.dedent_interpd class ContourSet(cm.ScalarMappable, ContourLabeler): """ Store a set of contour lines or filled regions. @@ -724,7 +674,7 @@ def __init__(self, ax, *args, hatches=(None,), alpha=None, origin=None, extent=None, cmap=None, colors=None, norm=None, vmin=None, vmax=None, extend='neither', antialiased=None, nchunk=0, locator=None, - transform=None, + transform=None, negative_linestyles=None, **kwargs): """ Draw contour lines or filled regions, depending on @@ -809,6 +759,13 @@ def __init__(self, ax, *args, self._transform = transform + self.negative_linestyles = negative_linestyles + # If negative_linestyles was not defined as a keyword argument, define + # negative_linestyles with rcParams + if self.negative_linestyles is None: + self.negative_linestyles = \ + mpl.rcParams['contour.negative_linestyle'] + kwargs = self._process_args(*args, **kwargs) self._process_levels() @@ -928,10 +885,7 @@ def __init__(self, ax, *args, ) def get_transform(self): - """ - Return the :class:`~matplotlib.transforms.Transform` - instance used by this ContourSet. - """ + """Return the `.Transform` instance used by this ContourSet.""" if self._transform is None: self._transform = self.axes.transData elif (not isinstance(self._transform, mtransforms.Transform) @@ -1078,13 +1032,13 @@ def _get_lowers_and_uppers(self): def _make_paths(self, segs, kinds): """ Create and return Path objects for the specified segments and optional - kind codes. segs is a list of numpy arrays, each array is either a + kind codes. *segs* is a list of numpy arrays, each array is either a closed line loop or open line strip of 2D points with a shape of - (npoints, 2). kinds is either None or a list (with the same length as - segs) of numpy arrays, each array is of shape (npoints,) and contains - the kinds codes for the corresponding line in segs. If kinds is None - then the Path constructor creates the kind codes assuming that the line - is an open strip. + (npoints, 2). *kinds* is either None or a list (with the same length + as *segs*) of numpy arrays, each array is of shape (npoints,) and + contains the kind codes for the corresponding line in *segs*. If + *kinds* is None then the Path constructor creates the kind codes + assuming that the line is an open strip. """ if kinds is None: return [mpath.Path(seg) for seg in segs] @@ -1163,33 +1117,28 @@ def _autolev(self, N): return lev[i0:i1] - def _process_contour_level_args(self, args): + def _process_contour_level_args(self, args, z_dtype): """ Determine the contour levels and store in self.levels. """ if self.levels is None: - if len(args) == 0: - levels_arg = 7 # Default, hard-wired. - else: + if args: levels_arg = args[0] + elif np.issubdtype(z_dtype, bool): + if self.filled: + levels_arg = [0, .5, 1] + else: + levels_arg = [.5] + else: + levels_arg = 7 # Default, hard-wired. else: levels_arg = self.levels if isinstance(levels_arg, Integral): self.levels = self._autolev(levels_arg) else: - self.levels = np.asarray(levels_arg).astype(np.float64) - - if not self.filled: - inside = (self.levels > self.zmin) & (self.levels < self.zmax) - levels_in = self.levels[inside] - if len(levels_in) == 0: - self.levels = [self.zmin] - _api.warn_external( - "No contour levels were found within the data range.") - + self.levels = np.asarray(levels_arg, np.float64) if self.filled and len(self.levels) < 2: raise ValueError("Filled contours require at least 2 levels.") - if len(self.levels) > 1 and np.min(np.diff(self.levels)) <= 0.0: raise ValueError("Contour levels must be increasing") @@ -1302,11 +1251,10 @@ def _process_linestyles(self): if linestyles is None: tlinestyles = ['solid'] * Nlev if self.monochrome: - neg_ls = mpl.rcParams['contour.negative_linestyle'] eps = - (self.zmax - self.zmin) * 1e-15 for i, lev in enumerate(self.levels): if lev < eps: - tlinestyles[i] = neg_ls + tlinestyles[i] = self.negative_linestyles else: if isinstance(linestyles, str): tlinestyles = [linestyles] * Nlev @@ -1384,6 +1332,7 @@ def find_nearest_contour(self, x, y, indices=None, pixel=True): d2min = np.inf conmin = None segmin = None + imin = None xmin = None ymin = None @@ -1411,8 +1360,13 @@ def find_nearest_contour(self, x, y, indices=None, pixel=True): return (conmin, segmin, imin, xmin, ymin, d2min) + def remove(self): + super().remove() + for coll in self.collections: + coll.remove() + -@docstring.dedent_interpd +@_docstring.dedent_interpd class QuadContourSet(ContourSet): """ Create and store a set of contour lines or filled regions. @@ -1423,7 +1377,7 @@ class QuadContourSet(ContourSet): %(contour_set_attributes)s """ - def _process_args(self, *args, corner_mask=None, **kwargs): + def _process_args(self, *args, corner_mask=None, algorithm=None, **kwargs): """ Process args and kwargs. """ @@ -1436,21 +1390,31 @@ def _process_args(self, *args, corner_mask=None, **kwargs): contour_generator = args[0]._contour_generator self._mins = args[0]._mins self._maxs = args[0]._maxs + self._algorithm = args[0]._algorithm else: - import matplotlib._contour as _contour + import contourpy + + if algorithm is None: + algorithm = mpl.rcParams['contour.algorithm'] + mpl.rcParams.validate["contour.algorithm"](algorithm) + self._algorithm = algorithm if corner_mask is None: - corner_mask = mpl.rcParams['contour.corner_mask'] + if self._algorithm == "mpl2005": + # mpl2005 does not support corner_mask=True so if not + # specifically requested then disable it. + corner_mask = False + else: + corner_mask = mpl.rcParams['contour.corner_mask'] self._corner_mask = corner_mask x, y, z = self._contour_args(args, kwargs) - _mask = ma.getmask(z) - if _mask is ma.nomask or not _mask.any(): - _mask = None - - contour_generator = _contour.QuadContourGenerator( - x, y, z.filled(), _mask, self._corner_mask, self.nchunk) + contour_generator = contourpy.contour_generator( + x, y, z, name=self._algorithm, corner_mask=self._corner_mask, + line_type=contourpy.LineType.SeparateCode, + fill_type=contourpy.FillType.OuterCode, + chunk_size=self.nchunk) t = self.get_transform() @@ -1476,17 +1440,16 @@ def _contour_args(self, args, kwargs): fn = 'contourf' else: fn = 'contour' - Nargs = len(args) - if Nargs <= 2: - z = ma.asarray(args[0], dtype=np.float64) + nargs = len(args) + if nargs <= 2: + z, *args = args + z = ma.asarray(z) x, y = self._initialize_x_y(z) - args = args[1:] - elif Nargs <= 4: - x, y, z = self._check_xyz(args[:3], kwargs) - args = args[3:] + elif nargs <= 4: + x, y, z_orig, *args = args + x, y, z = self._check_xyz(x, y, z_orig, kwargs) else: - raise TypeError("Too many arguments to %s; see help(%s)" % - (fn, fn)) + raise _api.nargs_error(fn, takes="from 1 to 4", given=nargs) z = ma.masked_invalid(z, copy=False) self.zmax = float(z.max()) self.zmin = float(z.min()) @@ -1494,20 +1457,19 @@ def _contour_args(self, args, kwargs): z = ma.masked_where(z <= 0, z) _api.warn_external('Log scale: values of z <= 0 have been masked') self.zmin = float(z.min()) - self._process_contour_level_args(args) + self._process_contour_level_args(args, z.dtype) return (x, y, z) - def _check_xyz(self, args, kwargs): + def _check_xyz(self, x, y, z, kwargs): """ Check that the shapes of the input arrays match; if x and y are 1D, convert them to 2D using meshgrid. """ - x, y = args[:2] x, y = self.axes._process_unit_info([("x", x), ("y", y)], kwargs) x = np.asarray(x, dtype=np.float64) y = np.asarray(y, dtype=np.float64) - z = ma.asarray(args[2], dtype=np.float64) + z = ma.asarray(z) if z.ndim != 2: raise TypeError(f"Input z must be 2D, not {z.ndim}D") @@ -1583,7 +1545,7 @@ def _initialize_x_y(self, z): return np.meshgrid(x, y) -docstring.interpd.update(contour_doc=""" +_docstring.interpd.update(contour_doc=""" `.contour` and `.contourf` draw contour lines and filled contours, respectively. Except as noted, function signatures and return values are the same for both versions. @@ -1604,14 +1566,15 @@ def _initialize_x_y(self, z): ``X = range(N)``, ``Y = range(M)``. Z : (M, N) array-like - The height values over which the contour is drawn. + The height values over which the contour is drawn. Color-mapping is + controlled by *cmap*, *norm*, *vmin*, and *vmax*. levels : int or array-like, optional Determines the number and positions of the contour lines / regions. If an int *n*, use `~matplotlib.ticker.MaxNLocator`, which tries to automatically choose no more than *n+1* "nice" contour levels - between *vmin* and *vmax*. + between minimum and maximum numeric values of *Z*. If array-like, draw contour lines at the specified levels. The values must be in increasing order. @@ -1647,21 +1610,20 @@ def _initialize_x_y(self, z): alpha : float, default: 1 The alpha blending value, between 0 (transparent) and 1 (opaque). -cmap : str or `.Colormap`, default: :rc:`image.cmap` - A `.Colormap` instance or registered colormap name. The colormap - maps the level values to colors. +%(cmap_doc)s - If both *colors* and *cmap* are given, an error is raised. + This parameter is ignored if *colors* is set. -norm : `~matplotlib.colors.Normalize`, optional - If a colormap is used, the `.Normalize` instance scales the level - values to the canonical colormap range [0, 1] for mapping to - colors. If not given, the default linear scaling is used. +%(norm_doc)s -vmin, vmax : float, optional - If not *None*, either or both of these values will be supplied to - the `.Normalize` instance, overriding the default color scaling - based on *levels*. + This parameter is ignored if *colors* is set. + +%(vmin_vmax_doc)s + + If *vmin* or *vmax* are not given, the default color scaling is based on + *levels*. + + This parameter is ignored if *colors* is set. origin : {*None*, 'upper', 'lower', 'image'}, default: None Determines the orientation and exact position of *Z* by specifying @@ -1758,14 +1720,27 @@ def _initialize_x_y(self, z): linestyles : {*None*, 'solid', 'dashed', 'dashdot', 'dotted'}, optional *Only applies to* `.contour`. - If *linestyles* is *None*, the default is 'solid' unless the lines - are monochrome. In that case, negative contours will take their - linestyle from :rc:`contour.negative_linestyle` setting. + If *linestyles* is *None*, the default is 'solid' unless the lines are + monochrome. In that case, negative contours will instead take their + linestyle from the *negative_linestyles* argument. + + *linestyles* can also be an iterable of the above strings specifying a set + of linestyles to be used. If this iterable is shorter than the number of + contour levels it will be repeated as necessary. + +negative_linestyles : {*None*, 'solid', 'dashed', 'dashdot', 'dotted'}, \ + optional + *Only applies to* `.contour`. - *linestyles* can also be an iterable of the above strings - specifying a set of linestyles to be used. If this - iterable is shorter than the number of contour levels - it will be repeated as necessary. + If *linestyles* is *None* and the lines are monochrome, this argument + specifies the line style for negative contours. + + If *negative_linestyles* is *None*, the default is taken from + :rc:`contour.negative_linestyles`. + + *negative_linestyles* can also be an iterable of the above strings + specifying a set of linestyles to be used. If this iterable is shorter than + the number of contour levels it will be repeated as necessary. hatches : list[str], optional *Only applies to* `.contourf`. @@ -1775,6 +1750,15 @@ def _initialize_x_y(self, z): Hatching is supported in the PostScript, PDF, SVG and Agg backends only. +algorithm : {'mpl2005', 'mpl2014', 'serial', 'threaded'}, optional + Which contouring algorithm to use to calculate the contour lines and + polygons. The algorithms are implemented in + `ContourPy `_, consult the + `ContourPy documentation `_ for + further information. + + The default is taken from :rc:`contour.algorithm`. + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -1795,5 +1779,5 @@ def _initialize_x_y(self, z): 3. `.contour` and `.contourf` use a `marching squares `_ algorithm to compute contour locations. More information can be found in - the source ``src/_contour.h``. -""") + `ContourPy documentation `_. +""" % _docstring.interpd.params) diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index 729d71d4a368..2c2293e03986 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -1,6 +1,6 @@ """ Matplotlib provides sophisticated date plotting capabilities, standing on the -shoulders of python :mod:`datetime` and the add-on module :mod:`dateutil`. +shoulders of python :mod:`datetime` and the add-on module dateutil_. By default, Matplotlib uses the units machinery described in `~matplotlib.units` to convert `datetime.datetime`, and `numpy.datetime64` @@ -83,10 +83,11 @@ Out[1]: 732401 All the Matplotlib date converters, tickers and formatters are timezone aware. -If no explicit timezone is provided, :rc:`timezone` is assumed. If you want to -use a custom time zone, pass a `datetime.tzinfo` instance with the tz keyword -argument to `num2date`, `.Axis.axis_date`, and any custom date tickers or -locators you create. +If no explicit timezone is provided, :rc:`timezone` is assumed, provided as a +string. If you want to use a different timezone, pass the *tz* keyword +argument of `num2date` to any date tickers or locators you create. This can +be either a `datetime.tzinfo` instance or a string with the timezone name that +can be parsed by `~dateutil.tz.gettz`. A wide range of specific and general purpose date tick locators and formatters are provided in this module. See @@ -106,15 +107,15 @@ # import constants for the days of the week from matplotlib.dates import MO, TU, WE, TH, FR, SA, SU - # tick on mondays every week + # tick on Mondays every week loc = WeekdayLocator(byweekday=MO, tz=tz) - # tick on mondays and saturdays + # tick on Mondays and Saturdays loc = WeekdayLocator(byweekday=(MO, SA)) In addition, most of the constructors take an interval argument:: - # tick on mondays every second week + # tick on Mondays every second week loc = WeekdayLocator(byweekday=MO, interval=2) The rrule locator allows completely general date ticking:: @@ -141,17 +142,17 @@ * `YearLocator`: Locate years that are multiples of base. -* `RRuleLocator`: Locate using a `matplotlib.dates.rrulewrapper`. - `.rrulewrapper` is a simple wrapper around dateutil_'s `dateutil.rrule` which - allow almost arbitrary date tick specifications. See :doc:`rrule example - `. +* `RRuleLocator`: Locate using a `rrulewrapper`. + `rrulewrapper` is a simple wrapper around dateutil_'s `dateutil.rrule` + which allow almost arbitrary date tick specifications. + See :doc:`rrule example `. * `AutoDateLocator`: On autoscale, this class picks the best `DateLocator` (e.g., `RRuleLocator`) to set the view limits and the tick locations. If called with ``interval_multiples=True`` it will make ticks line up with - sensible multiples of the tick intervals. E.g. if the interval is 4 hours, - it will pick hours 0, 4, 8, etc as ticks. This behaviour is not guaranteed - by default. + sensible multiples of the tick intervals. For example, if the interval is + 4 hours, it will pick hours 0, 4, 8, etc. as ticks. This behaviour is not + guaranteed by default. Date formatters --------------- @@ -186,41 +187,56 @@ from matplotlib import _api, cbook, ticker, units __all__ = ('datestr2num', 'date2num', 'num2date', 'num2timedelta', 'drange', - 'epoch2num', 'num2epoch', 'set_epoch', 'get_epoch', 'DateFormatter', - 'ConciseDateFormatter', 'AutoDateFormatter', - 'DateLocator', 'RRuleLocator', 'AutoDateLocator', 'YearLocator', - 'MonthLocator', 'WeekdayLocator', + 'set_epoch', 'get_epoch', 'DateFormatter', 'ConciseDateFormatter', + 'AutoDateFormatter', 'DateLocator', 'RRuleLocator', + 'AutoDateLocator', 'YearLocator', 'MonthLocator', 'WeekdayLocator', 'DayLocator', 'HourLocator', 'MinuteLocator', 'SecondLocator', 'MicrosecondLocator', 'rrule', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU', 'YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY', 'MICROSECONDLY', 'relativedelta', - 'DateConverter', 'ConciseDateConverter') + 'DateConverter', 'ConciseDateConverter', 'rrulewrapper') _log = logging.getLogger(__name__) UTC = datetime.timezone.utc -def _get_rc_timezone(): - """Retrieve the preferred timezone from the rcParams dictionary.""" - s = mpl.rcParams['timezone'] - if s == 'UTC': - return UTC - return dateutil.tz.gettz(s) +@_api.caching_module_getattr +class __getattr__: + JULIAN_OFFSET = _api.deprecated("3.7")(property(lambda self: 1721424.5)) + # Julian date at 0000-12-31 + # note that the Julian day epoch is achievable w/ + # np.datetime64('-4713-11-24T12:00:00'); datetime64 is proleptic + # Gregorian and BC has a one-year offset. So + # np.datetime64('0000-12-31') - np.datetime64('-4713-11-24T12:00') = + # 1721424.5 + # Ref: https://en.wikipedia.org/wiki/Julian_day -""" -Time-related constants. -""" +def _get_tzinfo(tz=None): + """ + Generate `~datetime.tzinfo` from a string or return `~datetime.tzinfo`. + If None, retrieve the preferred timezone from the rcParams dictionary. + """ + if tz is None: + tz = mpl.rcParams['timezone'] + if tz == 'UTC': + return UTC + if isinstance(tz, str): + tzinfo = dateutil.tz.gettz(tz) + if tzinfo is None: + raise ValueError(f"{tz} is not a valid timezone as parsed by" + " dateutil.tz.gettz.") + return tzinfo + if isinstance(tz, datetime.tzinfo): + return tz + raise TypeError("tz must be string or tzinfo subclass.") + + +# Time-related constants. EPOCH_OFFSET = float(datetime.datetime(1970, 1, 1).toordinal()) # EPOCH_OFFSET is not used by matplotlib -JULIAN_OFFSET = 1721424.5 # Julian date at 0000-12-31 -# note that the Julian day epoch is achievable w/ -# np.datetime64('-4713-11-24T12:00:00'); datetime64 is proleptic -# Gregorian and BC has a one-year offset. So -# np.datetime64('0000-12-31') - np.datetime64('-4713-11-24T12:00') = 1721424.5 -# Ref: https://en.wikipedia.org/wiki/Julian_day MICROSECONDLY = SECONDLY + 1 HOURS_PER_DAY = 24. MIN_PER_HOUR = 60. @@ -304,10 +320,10 @@ def get_epoch(): def _dt64_to_ordinalf(d): """ - Convert `numpy.datetime64` or an ndarray of those types to Gregorian - date as UTC float relative to the epoch (see `.get_epoch`). Roundoff - is float64 precision. Practically: microseconds for dates between - 290301 BC, 294241 AD, milliseconds for larger dates + Convert `numpy.datetime64` or an `numpy.ndarray` of those types to + Gregorian date as UTC float relative to the epoch (see `.get_epoch`). + Roundoff is float64 precision. Practically: microseconds for dates + between 290301 BC, 294241 AD, milliseconds for larger dates (see `numpy.datetime64`). """ @@ -322,11 +338,7 @@ def _dt64_to_ordinalf(d): NaT_int = np.datetime64('NaT').astype(np.int64) d_int = d.astype(np.int64) - try: - dt[d_int == NaT_int] = np.nan - except TypeError: - if d_int == NaT_int: - dt = np.nan + dt[d_int == NaT_int] = np.nan return dt @@ -341,8 +353,7 @@ def _from_ordinalf(x, tz=None): :rc:`timezone`. """ - if tz is None: - tz = _get_rc_timezone() + tz = _get_tzinfo(tz) dt = (np.datetime64(get_epoch()) + np.timedelta64(int(np.round(x * MUSECONDS_PER_DAY)), 'us')) @@ -395,7 +406,9 @@ def datestr2num(d, default=None): return date2num(dt) else: if default is not None: - d = [dateutil.parser.parse(s, default=default) for s in d] + d = [date2num(dateutil.parser.parse(s, default=default)) + for s in d] + return np.asarray(d) d = np.asarray(d) if not d.size: return d @@ -431,7 +444,10 @@ def date2num(d): if not iterable: d = [d] + masked = np.ma.is_masked(d) + mask = np.ma.getmask(d) d = np.asarray(d) + # convert to datetime64 arrays, if not already: if not np.issubdtype(d.dtype, np.datetime64): # datetime arrays @@ -445,11 +461,13 @@ def date2num(d): d = np.asarray(d) d = d.astype('datetime64[us]') + d = np.ma.masked_array(d, mask=mask) if masked else d d = _dt64_to_ordinalf(d) return d if iterable else d[0] +@_api.deprecated("3.7") def julian2num(j): """ Convert a Julian date (or sequence) to a Matplotlib date (or sequence). @@ -469,10 +487,11 @@ def julian2num(j): ep0 = np.datetime64('0000-12-31T00:00:00', 'h').astype(float) / 24. # Julian offset defined above is relative to 0000-12-31, but we need # relative to our current epoch: - dt = JULIAN_OFFSET - ep0 + ep + dt = __getattr__("JULIAN_OFFSET") - ep0 + ep return np.subtract(j, dt) # Handles both scalar & nonscalar j. +@_api.deprecated("3.7") def num2julian(n): """ Convert a Matplotlib date (or sequence) to a Julian date (or sequence). @@ -491,7 +510,7 @@ def num2julian(n): ep0 = np.datetime64('0000-12-31T00:00:00', 'h').astype(float) / 24. # Julian offset defined above is relative to 0000-12-31, but we need # relative to our current epoch: - dt = JULIAN_OFFSET - ep0 + ep + dt = __getattr__("JULIAN_OFFSET") - ep0 + ep return np.add(n, dt) # Handles both scalar & nonscalar j. @@ -505,8 +524,8 @@ def num2date(x, tz=None): Number of days (fraction part represents hours, minutes, seconds) since the epoch. See `.get_epoch` for the epoch, which can be changed by :rc:`date.epoch` or `.set_epoch`. - tz : str, default: :rc:`timezone` - Timezone of *x*. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Timezone of *x*. If a string, *tz* is passed to `dateutil.tz`. Returns ------- @@ -518,12 +537,10 @@ def num2date(x, tz=None): Notes ----- - The addition of one here is a historical artifact. Also, note that the - Gregorian calendar is assumed; this is not universal practice. + The Gregorian calendar is assumed; this is not universal practice. For details, see the module docstring. """ - if tz is None: - tz = _get_rc_timezone() + tz = _get_tzinfo(tz) return _from_ordinalf_np_vectorized(x, tz).tolist() @@ -582,7 +599,8 @@ def drange(dstart, dend, delta): # ensure, that an half open interval will be generated [dstart, dend) if dinterval_end >= dend: - # if the endpoint is greater than dend, just subtract one delta + # if the endpoint is greater than or equal to dend, + # just subtract one delta dinterval_end -= delta num -= 1 @@ -618,16 +636,14 @@ def __init__(self, fmt, tz=None, *, usetex=None): ---------- fmt : str `~datetime.datetime.strftime` format string - tz : `datetime.tzinfo`, default: :rc:`timezone` - Ticks timezone. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. usetex : bool, default: :rc:`text.usetex` To enable/disable the use of TeX's math mode for rendering the results of the formatter. """ - if tz is None: - tz = _get_rc_timezone() + self.tz = _get_tzinfo(tz) self.fmt = fmt - self.tz = tz self._usetex = (usetex if usetex is not None else mpl.rcParams['text.usetex']) @@ -636,7 +652,7 @@ def __call__(self, x, pos=0): return _wrap_in_tex(result) if self._usetex else result def set_tzinfo(self, tz): - self.tz = tz + self.tz = _get_tzinfo(tz) class ConciseDateFormatter(ticker.Formatter): @@ -653,8 +669,8 @@ class ConciseDateFormatter(ticker.Formatter): locator : `.ticker.Locator` Locator that this axis is using. - tz : str, optional - Passed to `.dates.date2num`. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone, passed to `.dates.num2date`. formats : list of 6 strings, optional Format strings for 6 levels of tick labelling: mostly years, @@ -754,7 +770,7 @@ def __init__(self, locator, tz=None, formats=None, offset_formats=None, if offset_formats: if len(offset_formats) != 6: - raise ValueError('offsetfmts argument must be a list of ' + raise ValueError('offset_formats argument must be a list of ' '6 format strings (or None)') self.offset_formats = offset_formats else: @@ -870,13 +886,13 @@ class AutoDateFormatter(ticker.Formatter): defaults to :: self.scaled = { - DAYS_PER_YEAR: rcParams['date.autoformat.year'], - DAYS_PER_MONTH: rcParams['date.autoformat.month'], - 1: rcParams['date.autoformat.day'], - 1 / HOURS_PER_DAY: rcParams['date.autoformat.hour'], - 1 / MINUTES_PER_DAY: rcParams['date.autoformat.minute'], - 1 / SEC_PER_DAY: rcParams['date.autoformat.second'], - 1 / MUSECONDS_PER_DAY: rcParams['date.autoformat.microsecond'], + DAYS_PER_YEAR: rcParams['date.autoformatter.year'], + DAYS_PER_MONTH: rcParams['date.autoformatter.month'], + 1: rcParams['date.autoformatter.day'], + 1 / HOURS_PER_DAY: rcParams['date.autoformatter.hour'], + 1 / MINUTES_PER_DAY: rcParams['date.autoformatter.minute'], + 1 / SEC_PER_DAY: rcParams['date.autoformatter.second'], + 1 / MUSECONDS_PER_DAY: rcParams['date.autoformatter.microsecond'], } The formatter uses the format string corresponding to the lowest key in @@ -926,8 +942,8 @@ def __init__(self, locator, tz=None, defaultfmt='%Y-%m-%d', *, locator : `.ticker.Locator` Locator that this axis is using. - tz : str, optional - Passed to `.dates.date2num`. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. defaultfmt : str The default format to use if none of the values in ``self.scaled`` @@ -981,13 +997,29 @@ def __call__(self, x, pos=None): class rrulewrapper: + """ + A simple wrapper around a `dateutil.rrule` allowing flexible + date tick specifications. + """ def __init__(self, freq, tzinfo=None, **kwargs): + """ + Parameters + ---------- + freq : {YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY} + Tick frequency. These constants are defined in `dateutil.rrule`, + but they are accessible from `matplotlib.dates` as well. + tzinfo : `datetime.tzinfo`, optional + Time zone information. The default is None. + **kwargs + Additional keyword arguments are passed to the `dateutil.rrule`. + """ kwargs['freq'] = freq self._base_tzinfo = tzinfo self._update_rrule(**kwargs) def set(self, **kwargs): + """Set parameters for an existing wrapper.""" self._construct.update(kwargs) self._update_rrule(**self._construct) @@ -995,7 +1027,7 @@ def set(self, **kwargs): def _update_rrule(self, **kwargs): tzinfo = self._base_tzinfo - # rrule does not play nicely with time zones - especially pytz time + # rrule does not play nicely with timezones - especially pytz time # zones, it's best to use naive zones and attach timezones once the # datetimes are returned if 'dtstart' in kwargs: @@ -1098,17 +1130,21 @@ def __init__(self, tz=None): """ Parameters ---------- - tz : `datetime.tzinfo` + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ - if tz is None: - tz = _get_rc_timezone() - self.tz = tz + self.tz = _get_tzinfo(tz) def set_tzinfo(self, tz): """ - Set time zone info. + Set timezone info. + + Parameters + ---------- + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ - self.tz = tz + self.tz = _get_tzinfo(tz) def datalim_to_dt(self): """Convert axis data interval to datetime objects.""" @@ -1144,9 +1180,9 @@ def nonsingular(self, vmin, vmax): if it is too close to being singular (i.e. a range of ~0). """ if not np.isfinite(vmin) or not np.isfinite(vmax): - # Except if there is no data, then use 2000-2010 as default. - return (date2num(datetime.date(2000, 1, 1)), - date2num(datetime.date(2010, 1, 1))) + # Except if there is no data, then use 1970 as default. + return (date2num(datetime.date(1970, 1, 1)), + date2num(datetime.date(1970, 1, 2))) if vmax < vmin: vmin, vmax = vmax, vmin unit = self._get_unit() @@ -1227,7 +1263,7 @@ def get_unit_generic(freq): return 1.0 / SEC_PER_DAY else: # error - return -1 # or should this just return '1'? + return -1 # or should this just return '1'? def _get_interval(self): return self.rule._rrule._interval @@ -1278,8 +1314,8 @@ def __init__(self, tz=None, minticks=5, maxticks=None, """ Parameters ---------- - tz : `datetime.tzinfo` - Ticks timezone. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. minticks : int The minimum number of ticks desired; controls whether ticks occur yearly, monthly, etc. @@ -1298,7 +1334,7 @@ def __init__(self, tz=None, minticks=5, maxticks=None, the ticks to be at hours 0, 6, 12, 18 when hourly ticking is done at 6 hour intervals. """ - super().__init__(tz) + super().__init__(tz=tz) self._freq = YEARLY self._freqs = [YEARLY, MONTHLY, DAILY, HOURLY, MINUTELY, SECONDLY, MICROSECONDLY] @@ -1349,9 +1385,9 @@ def nonsingular(self, vmin, vmax): # whatever is thrown at us, we can scale the unit. # But default nonsingular date plots at an ~4 year period. if not np.isfinite(vmin) or not np.isfinite(vmax): - # Except if there is no data, then use 2000-2010 as default. - return (date2num(datetime.date(2000, 1, 1)), - date2num(datetime.date(2010, 1, 1))) + # Except if there is no data, then use 1970 as default. + return (date2num(datetime.date(1970, 1, 1)), + date2num(datetime.date(1970, 1, 2))) if vmax < vmin: vmin, vmax = vmax, vmin if vmin == vmax: @@ -1380,7 +1416,7 @@ def get_locator(self, dmin, dmax): # whenever possible. numYears = float(delta.years) numMonths = numYears * MONTHS_PER_YEAR + delta.months - numDays = tdelta.days # Avoids estimates of days/month, days/year + numDays = tdelta.days # Avoids estimates of days/month, days/year. numHours = numDays * HOURS_PER_DAY + delta.hours numMinutes = numHours * MIN_PER_HOUR + delta.minutes numSeconds = np.floor(tdelta.total_seconds()) @@ -1398,7 +1434,7 @@ def get_locator(self, dmin, dmax): # Loop over all the frequencies and try to find one that gives at # least a minticks tick positions. Once this is found, look for - # an interval from an list specific to that frequency that gives no + # an interval from a list specific to that frequency that gives no # more than maxticks tick positions. Also, set up some ranges # (bymonth, etc.) as appropriate to be passed to rrulewrapper. for i, (freq, num) in enumerate(zip(self._freqs, nums)): @@ -1452,7 +1488,7 @@ def get_locator(self, dmin, dmax): byhour=byhour, byminute=byminute, bysecond=bysecond) - locator = RRuleLocator(rrule, self.tz) + locator = RRuleLocator(rrule, tz=self.tz) else: locator = MicrosecondLocator(interval, tz=self.tz) if date2num(dmin) > 70 * 365 and interval < 1000: @@ -1480,12 +1516,21 @@ class YearLocator(RRuleLocator): """ def __init__(self, base=1, month=1, day=1, tz=None): """ - Mark years that are multiple of base on a given month and day - (default jan 1). + Parameters + ---------- + base : int, default: 1 + Mark ticks every *base* years. + month : int, default: 1 + The month on which to place the ticks, starting from 1. Default is + January. + day : int, default: 1 + The day on which to place the ticks. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ rule = rrulewrapper(YEARLY, interval=base, bymonth=month, bymonthday=day, **self.hms0d) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) self.base = ticker._Edge_integer(base, 0) def _create_rrule(self, vmin, vmax): @@ -1513,23 +1558,25 @@ class MonthLocator(RRuleLocator): """ def __init__(self, bymonth=None, bymonthday=1, interval=1, tz=None): """ - Mark every month in *bymonth*; *bymonth* can be an int or - sequence. Default is ``range(1, 13)``, i.e. every month. - - *interval* is the interval between each iteration. For - example, if ``interval=2``, mark every second occurrence. + Parameters + ---------- + bymonth : int or list of int, default: all months + Ticks will be placed on every month in *bymonth*. Default is + ``range(1, 13)``, i.e. every month. + bymonthday : int, default: 1 + The day on which to place the ticks. + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ if bymonth is None: bymonth = range(1, 13) - elif isinstance(bymonth, np.ndarray): - # This fixes a bug in dateutil <= 2.3 which prevents the use of - # numpy arrays in (among other things) the bymonthday, byweekday - # and bymonth parameters. - bymonth = [x.item() for x in bymonth.astype(int)] rule = rrulewrapper(MONTHLY, bymonth=bymonth, bymonthday=bymonthday, interval=interval, **self.hms0d) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) class WeekdayLocator(RRuleLocator): @@ -1539,25 +1586,24 @@ class WeekdayLocator(RRuleLocator): def __init__(self, byweekday=1, interval=1, tz=None): """ - Mark every weekday in *byweekday*; *byweekday* can be a number or - sequence. - - Elements of *byweekday* must be one of MO, TU, WE, TH, FR, SA, - SU, the constants from :mod:`dateutil.rrule`, which have been - imported into the :mod:`matplotlib.dates` namespace. - - *interval* specifies the number of weeks to skip. For example, - ``interval=2`` plots every second week. + Parameters + ---------- + byweekday : int or list of int, default: all days + Ticks will be placed on every weekday in *byweekday*. Default is + every day. + + Elements of *byweekday* must be one of MO, TU, WE, TH, FR, SA, + SU, the constants from :mod:`dateutil.rrule`, which have been + imported into the :mod:`matplotlib.dates` namespace. + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ - if isinstance(byweekday, np.ndarray): - # This fixes a bug in dateutil <= 2.3 which prevents the use of - # numpy arrays in (among other things) the bymonthday, byweekday - # and bymonth parameters. - [x.item() for x in byweekday.astype(int)] - rule = rrulewrapper(DAILY, byweekday=byweekday, interval=interval, **self.hms0d) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) class DayLocator(RRuleLocator): @@ -1567,23 +1613,25 @@ class DayLocator(RRuleLocator): """ def __init__(self, bymonthday=None, interval=1, tz=None): """ - Mark every day in *bymonthday*; *bymonthday* can be an int or sequence. - - Default is to tick every day of the month: ``bymonthday=range(1, 32)``. + Parameters + ---------- + bymonthday : int or list of int, default: all days + Ticks will be placed on every day in *bymonthday*. Default is + ``bymonthday=range(1, 32)``, i.e., every day of the month. + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ if interval != int(interval) or interval < 1: raise ValueError("interval must be an integer greater than 0") if bymonthday is None: bymonthday = range(1, 32) - elif isinstance(bymonthday, np.ndarray): - # This fixes a bug in dateutil <= 2.3 which prevents the use of - # numpy arrays in (among other things) the bymonthday, byweekday - # and bymonth parameters. - bymonthday = [x.item() for x in bymonthday.astype(int)] rule = rrulewrapper(DAILY, bymonthday=bymonthday, interval=interval, **self.hms0d) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) class HourLocator(RRuleLocator): @@ -1592,18 +1640,23 @@ class HourLocator(RRuleLocator): """ def __init__(self, byhour=None, interval=1, tz=None): """ - Mark every hour in *byhour*; *byhour* can be an int or sequence. - Default is to tick every hour: ``byhour=range(24)`` - - *interval* is the interval between each iteration. For - example, if ``interval=2``, mark every second occurrence. + Parameters + ---------- + byhour : int or list of int, default: all hours + Ticks will be placed on every hour in *byhour*. Default is + ``byhour=range(24)``, i.e., every hour. + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ if byhour is None: byhour = range(24) rule = rrulewrapper(HOURLY, byhour=byhour, interval=interval, byminute=0, bysecond=0) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) class MinuteLocator(RRuleLocator): @@ -1612,18 +1665,23 @@ class MinuteLocator(RRuleLocator): """ def __init__(self, byminute=None, interval=1, tz=None): """ - Mark every minute in *byminute*; *byminute* can be an int or - sequence. Default is to tick every minute: ``byminute=range(60)`` - - *interval* is the interval between each iteration. For - example, if ``interval=2``, mark every second occurrence. + Parameters + ---------- + byminute : int or list of int, default: all minutes + Ticks will be placed on every minute in *byminute*. Default is + ``byminute=range(60)``, i.e., every minute. + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ if byminute is None: byminute = range(60) rule = rrulewrapper(MINUTELY, byminute=byminute, interval=interval, bysecond=0) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) class SecondLocator(RRuleLocator): @@ -1632,18 +1690,22 @@ class SecondLocator(RRuleLocator): """ def __init__(self, bysecond=None, interval=1, tz=None): """ - Mark every second in *bysecond*; *bysecond* can be an int or - sequence. Default is to tick every second: ``bysecond = range(60)`` - - *interval* is the interval between each iteration. For - example, if ``interval=2``, mark every second occurrence. - + Parameters + ---------- + bysecond : int or list of int, default: all seconds + Ticks will be placed on every second in *bysecond*. Default is + ``bysecond = range(60)``, i.e., every second. + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ if bysecond is None: bysecond = range(60) rule = rrulewrapper(SECONDLY, bysecond=bysecond, interval=interval) - super().__init__(rule, tz) + super().__init__(rule, tz=tz) class MicrosecondLocator(DateLocator): @@ -1670,9 +1732,13 @@ class MicrosecondLocator(DateLocator): """ def __init__(self, interval=1, tz=None): """ - *interval* is the interval between each iteration. For - example, if ``interval=2``, mark every second microsecond. - + Parameters + ---------- + interval : int, default: 1 + The interval between each iteration. For example, if + ``interval=2``, mark every second occurrence. + tz : str or `~datetime.tzinfo`, default: :rc:`timezone` + Ticks timezone. If a string, *tz* is passed to `dateutil.tz`. """ super().__init__(tz=tz) self._interval = interval @@ -1682,16 +1748,6 @@ def set_axis(self, axis): self._wrapped_locator.set_axis(axis) return super().set_axis(axis) - @_api.deprecated("3.5", alternative="`.Axis.set_view_interval`") - def set_view_interval(self, vmin, vmax): - self._wrapped_locator.set_view_interval(vmin, vmax) - return super().set_view_interval(vmin, vmax) - - @_api.deprecated("3.5", alternative="`.Axis.set_data_interval`") - def set_data_interval(self, vmin, vmax): - self._wrapped_locator.set_data_interval(vmin, vmax) - return super().set_data_interval(vmin, vmax) - def __call__(self): # if no data have been set, this will tank with a ValueError try: @@ -1723,51 +1779,8 @@ def _get_interval(self): return self._interval -@_api.deprecated( - "3.5", - alternative="`date2num(datetime.utcfromtimestamp(e))<.date2num>`") -def epoch2num(e): - """ - Convert UNIX time to days since Matplotlib epoch. - - Parameters - ---------- - e : list of floats - Time in seconds since 1970-01-01. - - Returns - ------- - `numpy.array` - Time in days since Matplotlib epoch (see `~.dates.get_epoch()`). - """ - - dt = (np.datetime64('1970-01-01T00:00:00', 's') - - np.datetime64(get_epoch(), 's')).astype(float) - - return (dt + np.asarray(e)) / SEC_PER_DAY - - -@_api.deprecated("3.5", alternative="`num2date(e).timestamp()<.num2date>`") -def num2epoch(d): - """ - Convert days since Matplotlib epoch to UNIX time. - - Parameters - ---------- - d : list of floats - Time in days since Matplotlib epoch (see `~.dates.get_epoch()`). - - Returns - ------- - `numpy.array` - Time in seconds since 1970-01-01. - """ - dt = (np.datetime64('1970-01-01T00:00:00', 's') - - np.datetime64(get_epoch(), 's')).astype(float) - - return np.asarray(d) * SEC_PER_DAY - dt - - +@_api.deprecated("3.6", alternative="`AutoDateLocator` and `AutoDateFormatter`" + " or vendor the code") def date_ticker_factory(span, tz=None, numticks=5): """ Create a date locator with *numticks* (approx) and a date formatter @@ -1815,7 +1828,7 @@ class DateConverter(units.ConversionInterface): Converter for `datetime.date` and `datetime.datetime` data, or for date/time data represented as it would be converted by `date2num`. - The 'unit' tag for such data is None or a tzinfo instance. + The 'unit' tag for such data is None or a `~datetime.tzinfo` instance. """ def __init__(self, *, interval_multiples=True): @@ -1826,7 +1839,7 @@ def axisinfo(self, unit, axis): """ Return the `~matplotlib.units.AxisInfo` for *unit*. - *unit* is a tzinfo instance or None. + *unit* is a `~datetime.tzinfo` instance or None. The *axis* argument is required but not used. """ tz = unit @@ -1834,8 +1847,8 @@ def axisinfo(self, unit, axis): majloc = AutoDateLocator(tz=tz, interval_multiples=self._interval_multiples) majfmt = AutoDateFormatter(majloc, tz=tz) - datemin = datetime.date(2000, 1, 1) - datemax = datetime.date(2010, 1, 1) + datemin = datetime.date(1970, 1, 1) + datemax = datetime.date(1970, 1, 2) return units.AxisInfo(majloc=majloc, majfmt=majfmt, label='', default_limits=(datemin, datemax)) @@ -1853,13 +1866,14 @@ def convert(value, unit, axis): @staticmethod def default_units(x, axis): """ - Return the tzinfo instance of *x* or of its first element, or None + Return the `~datetime.tzinfo` instance of *x* or of its first element, + or None """ if isinstance(x, np.ndarray): x = x.ravel() try: - x = cbook.safe_first_element(x) + x = cbook._safe_first_finite(x) except (TypeError, StopIteration): pass @@ -1891,8 +1905,8 @@ def axisinfo(self, unit, axis): zero_formats=self._zero_formats, offset_formats=self._offset_formats, show_offset=self._show_offset) - datemin = datetime.date(2000, 1, 1) - datemax = datetime.date(2010, 1, 1) + datemin = datetime.date(1970, 1, 1) + datemax = datetime.date(1970, 1, 2) return units.AxisInfo(majloc=majloc, majfmt=majfmt, label='', default_limits=(datemin, datemax)) diff --git a/lib/matplotlib/docstring.py b/lib/matplotlib/docstring.py index e1eaca32349e..b6ddcf5acd10 100644 --- a/lib/matplotlib/docstring.py +++ b/lib/matplotlib/docstring.py @@ -1,101 +1,4 @@ -import inspect - - -class Substitution: - """ - A decorator that performs %-substitution on an object's docstring. - - This decorator should be robust even if ``obj.__doc__`` is None (for - example, if -OO was passed to the interpreter). - - Usage: construct a docstring.Substitution with a sequence or dictionary - suitable for performing substitution; then decorate a suitable function - with the constructed object, e.g.:: - - sub_author_name = Substitution(author='Jason') - - @sub_author_name - def some_function(x): - "%(author)s wrote this function" - - # note that some_function.__doc__ is now "Jason wrote this function" - - One can also use positional arguments:: - - sub_first_last_names = Substitution('Edgar Allen', 'Poe') - - @sub_first_last_names - def some_function(x): - "%s %s wrote the Raven" - """ - def __init__(self, *args, **kwargs): - if args and kwargs: - raise TypeError("Only positional or keyword args are allowed") - self.params = params = args or kwargs - - def __call__(self, func): - if func.__doc__: - func.__doc__ = inspect.cleandoc(func.__doc__) % self.params - return func - - def update(self, *args, **kwargs): - """ - Update ``self.params`` (which must be a dict) with the supplied args. - """ - self.params.update(*args, **kwargs) - - -def _recursive_subclasses(cls): - yield cls - for subcls in cls.__subclasses__(): - yield from _recursive_subclasses(subcls) - - -class _ArtistKwdocLoader(dict): - def __missing__(self, key): - if not key.endswith(":kwdoc"): - raise KeyError(key) - name = key[:-len(":kwdoc")] - from matplotlib.artist import Artist, kwdoc - try: - cls, = [cls for cls in _recursive_subclasses(Artist) - if cls.__name__ == name] - except ValueError as e: - raise KeyError(key) from e - return self.setdefault(key, kwdoc(cls)) - - -class _ArtistPropertiesSubstitution(Substitution): - """ - A `.Substitution` with two additional features: - - - Substitutions of the form ``%(classname:kwdoc)s`` (ending with the - literal ":kwdoc" suffix) trigger lookup of an Artist subclass with the - given *classname*, and are substituted with the `.kwdoc` of that class. - - Decorating a class triggers substitution both on the class docstring and - on the class' ``__init__`` docstring (which is a commonly required - pattern for Artist subclasses). - """ - - def __init__(self): - self.params = _ArtistKwdocLoader() - - def __call__(self, obj): - super().__call__(obj) - if isinstance(obj, type) and obj.__init__ != object.__init__: - self(obj.__init__) - return obj - - -def copy(source): - """Copy a docstring from another source function (if present).""" - def do_copy(target): - if source.__doc__: - target.__doc__ = source.__doc__ - return target - return do_copy - - -# Create a decorator that will house the various docstring snippets reused -# throughout Matplotlib. -dedent_interpd = interpd = _ArtistPropertiesSubstitution() +from matplotlib._docstring import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated( + "3.6", obj_type='module', name=f"{__name__}") diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 3207a01de8be..cbd3b542a003 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -58,10 +58,71 @@ # The marks on a page consist of text and boxes. A page also has dimensions. Page = namedtuple('Page', 'text boxes height width descent') -Text = namedtuple('Text', 'x y font glyph width') Box = namedtuple('Box', 'x y height width') +# Also a namedtuple, for backcompat. +class Text(namedtuple('Text', 'x y font glyph width')): + """ + A glyph in the dvi file. + + The *x* and *y* attributes directly position the glyph. The *font*, + *glyph*, and *width* attributes are kept public for back-compatibility, + but users wanting to draw the glyph themselves are encouraged to instead + load the font specified by `font_path` at `font_size`, warp it with the + effects specified by `font_effects`, and load the glyph specified by + `glyph_name_or_index`. + """ + + def _get_pdftexmap_entry(self): + return PsfontsMap(find_tex_file("pdftex.map"))[self.font.texname] + + @property + def font_path(self): + """The `~pathlib.Path` to the font for this glyph.""" + psfont = self._get_pdftexmap_entry() + if psfont.filename is None: + raise ValueError("No usable font file found for {} ({}); " + "the font may lack a Type-1 version" + .format(psfont.psname.decode("ascii"), + psfont.texname.decode("ascii"))) + return Path(psfont.filename) + + @property + def font_size(self): + """The font size.""" + return self.font.size + + @property + def font_effects(self): + """ + The "font effects" dict for this glyph. + + This dict contains the values for this glyph of SlantFont and + ExtendFont (if any), read off :file:`pdftex.map`. + """ + return self._get_pdftexmap_entry().effects + + @property + def glyph_name_or_index(self): + """ + Either the glyph name or the native charmap glyph index. + + If :file:`pdftex.map` specifies an encoding for this glyph's font, that + is a mapping of glyph indices to Adobe glyph names; use it to convert + dvi indices to glyph names. Callers can then convert glyph names to + glyph indices (with FT_Get_Name_Index/get_name_index), and load the + glyph using FT_Load_Glyph/load_glyph. + + If :file:`pdftex.map` specifies no encoding, the indices directly map + to the font's "native" charmap; glyphs should directly load using + FT_Load_Char/load_char after selecting the native charmap. + """ + entry = self._get_pdftexmap_entry() + return (_parse_enc(entry.encoding)[self.glyph] + if entry.encoding is not None else self.glyph) + + # Opcode argument parsing # # Each of the following functions takes a Dvi object and delta, @@ -206,8 +267,6 @@ def __init__(self, filename, dpi): self.fonts = {} self.state = _dvistate.pre - baseline = _api.deprecated("3.5")(property(lambda self: None)) - def __enter__(self): """Context manager enter method, does nothing.""" return self @@ -470,13 +529,12 @@ def _fnt_def_real(self, k, c, s, d, a, l): n = self.file.read(a + l) fontname = n[-l:].decode('ascii') tfm = _tfmfile(fontname) - if tfm is None: - raise FileNotFoundError("missing font metrics file: %s" % fontname) if c != 0 and tfm.checksum != 0 and c != tfm.checksum: raise ValueError('tfm checksum mismatch: %s' % n) - - vf = _vffile(fontname) - + try: + vf = _vffile(fontname) + except FileNotFoundError: + vf = None self.fonts[k] = DviFont(scale=s, tfm=tfm, texname=n, vf=vf) @_dispatch(247, state=_dvistate.pre, args=('u1', 'u4', 'u4', 'u4', 'u1')) @@ -938,9 +996,9 @@ def _parse_and_cache_line(self, line): if basename is None: basename = tfmname if encodingfile is not None: - encodingfile = find_tex_file(encodingfile) + encodingfile = _find_tex_file(encodingfile) if fontfile is not None: - fontfile = find_tex_file(fontfile) + fontfile = _find_tex_file(fontfile) self._parsed[tfmname] = PsFont( texname=tfmname, psname=basename, effects=effects, encoding=encodingfile, filename=fontfile) @@ -955,7 +1013,7 @@ def _parse_enc(path): Parameters ---------- - path : os.PathLike + path : `os.PathLike` Returns ------- @@ -992,76 +1050,86 @@ def search(self, filename): self._proc.stdin.write(os.fsencode(filename) + b"\n") self._proc.stdin.flush() out = self._proc.stdout.readline().rstrip() - return "" if out == b"nil" else os.fsdecode(out) + return None if out == b"nil" else os.fsdecode(out) @lru_cache() -@_api.delete_parameter("3.5", "format") -def find_tex_file(filename, format=None): +def _find_tex_file(filename): """ - Find a file in the texmf tree. + Find a file in the texmf tree using kpathsea_. - Calls :program:`kpsewhich` which is an interface to the kpathsea - library [1]_. Most existing TeX distributions on Unix-like systems use - kpathsea. It is also available as part of MikTeX, a popular - distribution on Windows. + The kpathsea library, provided by most existing TeX distributions, both + on Unix-like systems and on Windows (MikTeX), is invoked via a long-lived + luatex process if luatex is installed, or via kpsewhich otherwise. - *If the file is not found, an empty string is returned*. + .. _kpathsea: https://www.tug.org/kpathsea/ Parameters ---------- filename : str or path-like - format : str or bytes - Used as the value of the ``--format`` option to :program:`kpsewhich`. - Could be e.g. 'tfm' or 'vf' to limit the search to that type of files. - Deprecated. - References - ---------- - .. [1] `Kpathsea documentation `_ - The library that :program:`kpsewhich` is part of. + Raises + ------ + FileNotFoundError + If the file is not found. """ # we expect these to always be ascii encoded, but use utf-8 # out of caution if isinstance(filename, bytes): filename = filename.decode('utf-8', errors='replace') - if isinstance(format, bytes): - format = format.decode('utf-8', errors='replace') - if format is None: + try: + lk = _LuatexKpsewhich() + except FileNotFoundError: + lk = None # Fallback to directly calling kpsewhich, as below. + + if lk: + path = lk.search(filename) + else: + if os.name == 'nt': + # On Windows only, kpathsea can use utf-8 for cmd args and output. + # The `command_line_encoding` environment variable is set to force + # it to always use utf-8 encoding. See Matplotlib issue #11848. + kwargs = {'env': {**os.environ, 'command_line_encoding': 'utf-8'}, + 'encoding': 'utf-8'} + else: # On POSIX, run through the equivalent of os.fsdecode(). + kwargs = {'encoding': sys.getfilesystemencoding(), + 'errors': 'surrogateescape'} + try: - lk = _LuatexKpsewhich() - except FileNotFoundError: - pass # Fallback to directly calling kpsewhich, as below. - else: - return lk.search(filename) - - if os.name == 'nt': - # On Windows only, kpathsea can use utf-8 for cmd args and output. - # The `command_line_encoding` environment variable is set to force it - # to always use utf-8 encoding. See Matplotlib issue #11848. - kwargs = {'env': {**os.environ, 'command_line_encoding': 'utf-8'}, - 'encoding': 'utf-8'} - else: # On POSIX, run through the equivalent of os.fsdecode(). - kwargs = {'encoding': sys.getfilesystemencoding(), - 'errors': 'surrogatescape'} - - cmd = ['kpsewhich'] - if format is not None: - cmd += ['--format=' + format] - cmd += [filename] + path = (cbook._check_and_log_subprocess(['kpsewhich', filename], + _log, **kwargs) + .rstrip('\n')) + except (FileNotFoundError, RuntimeError): + path = None + + if path: + return path + else: + raise FileNotFoundError( + f"Matplotlib's TeX implementation searched for a file named " + f"{filename!r} in your texmf tree, but could not find it") + + +# After the deprecation period elapses, delete this shim and rename +# _find_tex_file to find_tex_file everywhere. +def find_tex_file(filename): try: - result = cbook._check_and_log_subprocess(cmd, _log, **kwargs) - except (FileNotFoundError, RuntimeError): - return '' - return result.rstrip('\n') + return _find_tex_file(filename) + except FileNotFoundError as exc: + _api.warn_deprecated( + "3.6", message=f"{exc.args[0]}; in the future, this will raise a " + f"FileNotFoundError.") + return "" + + +find_tex_file.__doc__ = _find_tex_file.__doc__ @lru_cache() def _fontfile(cls, suffix, texname): - filename = find_tex_file(texname + suffix) - return cls(filename) if filename else None + return cls(_find_tex_file(texname + suffix)) _tfmfile = partial(_fontfile, Tfm, ".tfm") @@ -1077,7 +1145,7 @@ def _fontfile(cls, suffix, texname): parser.add_argument("dpi", nargs="?", type=float, default=None) args = parser.parse_args() with Dvi(args.filename, args.dpi) as dvi: - fontmap = PsfontsMap(find_tex_file('pdftex.map')) + fontmap = PsfontsMap(_find_tex_file('pdftex.map')) for page in dvi: print(f"=== new page === " f"(w: {page.width}, h: {page.height}, d: {page.descent})") @@ -1092,6 +1160,6 @@ def _fontfile(cls, suffix, texname): else ".", text.width, sep="\t") if page.boxes: - print("x", "y", "w", "h", "", "(boxes)", sep="\t") - for x, y, w, h in page.boxes: - print(x, y, w, h, sep="\t") + print("x", "y", "h", "w", "", "(boxes)", sep="\t") + for box in page.boxes: + print(box.x, box.y, box.height, box.width, sep="\t") diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index de853bef0dff..27b16b17a86b 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -12,28 +12,36 @@ `SubplotParams` Control the default spacing between subplots. + +See :ref:`figure_explanation` for narrative on how figures are used in +Matplotlib. """ from contextlib import ExitStack import inspect +import itertools import logging from numbers import Integral import numpy as np import matplotlib as mpl -from matplotlib import _blocking_input, docstring, projections +from matplotlib import _blocking_input, backend_bases, _docstring, projections from matplotlib.artist import ( Artist, allow_rasterization, _finalize_rasterization) from matplotlib.backend_bases import ( - FigureCanvasBase, NonGuiException, MouseButton, _get_renderer) + DrawEvent, FigureCanvasBase, NonGuiException, MouseButton, _get_renderer) import matplotlib._api as _api import matplotlib.cbook as cbook import matplotlib.colorbar as cbar import matplotlib.image as mimage -from matplotlib.axes import Axes, SubplotBase, subplot_class_factory +from matplotlib.axes import Axes from matplotlib.gridspec import GridSpec +from matplotlib.layout_engine import ( + ConstrainedLayoutEngine, TightLayoutEngine, LayoutEngine, + PlaceHolderLayoutEngine +) import matplotlib.legend as mlegend from matplotlib.patches import Rectangle from matplotlib.text import Text @@ -48,69 +56,41 @@ def _stale_figure_callback(self, val): self.figure.stale = val -class _AxesStack(cbook.Stack): +class _AxesStack: """ - Specialization of Stack, to handle all tracking of Axes in a Figure. - - This stack stores ``ind, axes`` pairs, where ``ind`` is a serial index - tracking the order in which axes were added. + Helper class to track axes in a figure. - AxesStack is a callable; calling it returns the current axes. + Axes are tracked both in the order in which they have been added + (``self._axes`` insertion/iteration order) and in the separate "gca" stack + (which is the index to which they map in the ``self._axes`` dict). """ def __init__(self): - super().__init__() - self._ind = 0 + self._axes = {} # Mapping of axes to "gca" order. + self._counter = itertools.count() def as_list(self): - """ - Return a list of the Axes instances that have been added to the figure. - """ - return [a for i, a in sorted(self._elements)] - - def _entry_from_axes(self, e): - return next(((ind, a) for ind, a in self._elements if a == e), None) + """List the axes that have been added to the figure.""" + return [*self._axes] # This relies on dict preserving order. def remove(self, a): """Remove the axes from the stack.""" - super().remove(self._entry_from_axes(a)) + self._axes.pop(a) def bubble(self, a): - """ - Move the given axes, which must already exist in the stack, to the top. - """ - return super().bubble(self._entry_from_axes(a)) + """Move an axes, which must already exist in the stack, to the top.""" + if a not in self._axes: + raise ValueError("Axes has not been added yet") + self._axes[a] = next(self._counter) def add(self, a): - """ - Add Axes *a* to the stack. - - If *a* is already on the stack, don't add it again. - """ - # All the error checking may be unnecessary; but this method - # is called so seldom that the overhead is negligible. - _api.check_isinstance(Axes, a=a) - - if a in self: - return - - self._ind += 1 - super().push((self._ind, a)) - - def __call__(self): - """ - Return the active axes. - - If no axes exists on the stack, then returns None. - """ - if not len(self._elements): - return None - else: - index, axes = self._elements[self._pos] - return axes + """Add an axes to the stack, ignoring it if already present.""" + if a not in self._axes: + self._axes[a] = next(self._counter) - def __contains__(self, a): - return a in self.as_list() + def current(self): + """Return the active axes, or None if the stack is empty.""" + return max(self._axes, key=self._axes.__getitem__, default=None) class SubplotParams: @@ -144,26 +124,21 @@ def __init__(self, left=None, bottom=None, right=None, top=None, The height of the padding between subplots, as a fraction of the average Axes height. """ - self._validate = True for key in ["left", "bottom", "right", "top", "wspace", "hspace"]: setattr(self, key, mpl.rcParams[f"figure.subplot.{key}"]) self.update(left, bottom, right, top, wspace, hspace) - # Also remove _validate after deprecation elapses. - validate = _api.deprecate_privatize_attribute("3.5") - def update(self, left=None, bottom=None, right=None, top=None, wspace=None, hspace=None): """ Update the dimensions of the passed parameters. *None* means unchanged. """ - if self._validate: - if ((left if left is not None else self.left) - >= (right if right is not None else self.right)): - raise ValueError('left cannot be >= right') - if ((bottom if bottom is not None else self.bottom) - >= (top if top is not None else self.top)): - raise ValueError('bottom cannot be >= top') + if ((left if left is not None else self.left) + >= (right if right is not None else self.right)): + raise ValueError('left cannot be >= right') + if ((bottom if bottom is not None else self.bottom) + >= (top if top is not None else self.top)): + raise ValueError('bottom cannot be >= top') if left is not None: self.left = left if right is not None: @@ -180,13 +155,13 @@ def update(self, left=None, bottom=None, right=None, top=None, class FigureBase(Artist): """ - Base class for `.figure.Figure` and `.figure.SubFigure` containing the - methods that add artists to the figure or subfigure, create Axes, etc. + Base class for `.Figure` and `.SubFigure` containing the methods that add + artists to the figure or subfigure, create Axes, etc. """ def __init__(self, **kwargs): super().__init__() # remove the non-figure artist _axes property - # as it makes no sense for a figure to be _in_ an axes + # as it makes no sense for a figure to be _in_ an Axes # this is used by the property methods in the artist base class # which are over-ridden in this class del self._axes @@ -201,9 +176,7 @@ def __init__(self, **kwargs): self._align_label_groups = {"x": cbook.Grouper(), "y": cbook.Grouper()} self.figure = self - # list of child gridspecs for this figure - self._gridspecs = [] - self._localaxes = _AxesStack() # track all axes and current axes + self._localaxes = [] # track all axes self.artists = [] self.lines = [] self.patches = [] @@ -229,22 +202,15 @@ def _get_draw_artists(self, renderer): artists = sorted( (artist for artist in artists if not artist.get_animated()), key=lambda artist: artist.get_zorder()) - for ax in self._localaxes.as_list(): + for ax in self._localaxes: locator = ax.get_axes_locator() - if locator: - pos = locator(ax, renderer) - ax.apply_aspect(pos) - else: - ax.apply_aspect() + ax.apply_aspect(locator(ax, renderer) if locator else None) for child in ax.get_children(): if hasattr(child, 'apply_aspect'): locator = child.get_axes_locator() - if locator: - pos = locator(child, renderer) - child.apply_aspect(pos) - else: - child.apply_aspect() + child.apply_aspect( + locator(child, renderer) if locator else None) return artists def autofmt_xdate( @@ -269,7 +235,7 @@ def autofmt_xdate( Selects which ticklabels to rotate. """ _api.check_in_list(['major', 'minor', 'both'], which=which) - allsubplots = all(hasattr(ax, 'get_subplotspec') for ax in self.axes) + allsubplots = all(ax.get_subplotspec() for ax in self.axes) if len(self.axes) == 1: for label in self.axes[0].get_xticklabels(which=which): label.set_ha(ha) @@ -294,7 +260,7 @@ def get_children(self): """Get a list of artists contained in the figure.""" return [self.patch, *self.artists, - *self._localaxes.as_list(), + *self._localaxes, *self.lines, *self.patches, *self.texts, @@ -316,7 +282,9 @@ def contains(self, mouseevent): inside = self.bbox.contains(mouseevent.x, mouseevent.y) return inside, {} - def get_window_extent(self, *args, **kwargs): + @_api.delete_parameter("3.6", "args") + @_api.delete_parameter("3.6", "kwargs") + def get_window_extent(self, renderer=None, *args, **kwargs): # docstring inherited return self.bbox @@ -337,10 +305,10 @@ def _suplabels(self, t, info, **kwargs): verticalalignment, va : {'top', 'center', 'bottom', 'baseline'}, \ default: %(va)s The vertical alignment of the text relative to (*x*, *y*). - fontsize, size : default: :rc:`figure.titlesize` + fontsize, size : default: :rc:`figure.%(rc)ssize` The font size of the text. See `.Text.set_size` for possible values. - fontweight, weight : default: :rc:`figure.titleweight` + fontweight, weight : default: :rc:`figure.%(rc)sweight` The font weight of the text. See `.Text.set_weight` for possible values. @@ -354,8 +322,8 @@ def _suplabels(self, t, info, **kwargs): fontproperties : None or dict, optional A dict of font properties. If *fontproperties* is given the default values for font size and weight are taken from the - `.FontProperties` defaults. :rc:`figure.titlesize` and - :rc:`figure.titleweight` are ignored in this case. + `.FontProperties` defaults. :rc:`figure.%(rc)ssize` and + :rc:`figure.%(rc)sweight` are ignored in this case. **kwargs Additional kwargs are `matplotlib.text.Text` properties. @@ -383,9 +351,9 @@ def _suplabels(self, t, info, **kwargs): if 'fontproperties' not in kwargs: if 'fontsize' not in kwargs and 'size' not in kwargs: - kwargs['size'] = mpl.rcParams['figure.titlesize'] + kwargs['size'] = mpl.rcParams[info['size']] if 'fontweight' not in kwargs and 'weight' not in kwargs: - kwargs['weight'] = mpl.rcParams['figure.titleweight'] + kwargs['weight'] = mpl.rcParams[info['weight']] sup = self.text(x, y, t, **kwargs) if suplab is not None: @@ -400,32 +368,35 @@ def _suplabels(self, t, info, **kwargs): self.stale = True return suplab - @docstring.Substitution(x0=0.5, y0=0.98, name='suptitle', ha='center', - va='top') - @docstring.copy(_suplabels) + @_docstring.Substitution(x0=0.5, y0=0.98, name='suptitle', ha='center', + va='top', rc='title') + @_docstring.copy(_suplabels) def suptitle(self, t, **kwargs): # docstring from _suplabels... info = {'name': '_suptitle', 'x0': 0.5, 'y0': 0.98, - 'ha': 'center', 'va': 'top', 'rotation': 0} + 'ha': 'center', 'va': 'top', 'rotation': 0, + 'size': 'figure.titlesize', 'weight': 'figure.titleweight'} return self._suplabels(t, info, **kwargs) - @docstring.Substitution(x0=0.5, y0=0.01, name='supxlabel', ha='center', - va='bottom') - @docstring.copy(_suplabels) + @_docstring.Substitution(x0=0.5, y0=0.01, name='supxlabel', ha='center', + va='bottom', rc='label') + @_docstring.copy(_suplabels) def supxlabel(self, t, **kwargs): # docstring from _suplabels... info = {'name': '_supxlabel', 'x0': 0.5, 'y0': 0.01, - 'ha': 'center', 'va': 'bottom', 'rotation': 0} + 'ha': 'center', 'va': 'bottom', 'rotation': 0, + 'size': 'figure.labelsize', 'weight': 'figure.labelweight'} return self._suplabels(t, info, **kwargs) - @docstring.Substitution(x0=0.02, y0=0.5, name='supylabel', ha='left', - va='center') - @docstring.copy(_suplabels) + @_docstring.Substitution(x0=0.02, y0=0.5, name='supylabel', ha='left', + va='center', rc='label') + @_docstring.copy(_suplabels) def supylabel(self, t, **kwargs): # docstring from _suplabels... info = {'name': '_supylabel', 'x0': 0.02, 'y0': 0.5, 'ha': 'left', 'va': 'center', 'rotation': 'vertical', - 'rotation_mode': 'anchor'} + 'rotation_mode': 'anchor', 'size': 'figure.labelsize', + 'weight': 'figure.labelweight'} return self._suplabels(t, info, **kwargs) def get_edgecolor(self): @@ -499,9 +470,9 @@ def add_artist(self, artist, clip=False): """ Add an `.Artist` to the figure. - Usually artists are added to Axes objects using `.Axes.add_artist`; - this method can be used in the rare cases where one needs to add - artists directly to the figure instead. + Usually artists are added to `~.axes.Axes` objects using + `.Axes.add_artist`; this method can be used in the rare cases where + one needs to add artists directly to the figure instead. Parameters ---------- @@ -530,10 +501,10 @@ def add_artist(self, artist, clip=False): self.stale = True return artist - @docstring.dedent_interpd + @_docstring.dedent_interpd def add_axes(self, *args, **kwargs): """ - Add an Axes to the figure. + Add an `~.axes.Axes` to the figure. Call signatures:: @@ -542,9 +513,10 @@ def add_axes(self, *args, **kwargs): Parameters ---------- - rect : sequence of float - The dimensions [left, bottom, width, height] of the new Axes. All - quantities are in fractions of figure width and height. + rect : tuple (left, bottom, width, height) + The dimensions (left, bottom, width, height) of the new + `~.axes.Axes`. All quantities are in fractions of figure width and + height. projection : {None, 'aitoff', 'hammer', 'lambert', 'mollweide', \ 'polar', 'rectilinear', str}, optional @@ -643,7 +615,7 @@ def add_axes(self, *args, **kwargs): key = (projection_class, pkw) return self._add_axes_internal(a, key) - @docstring.dedent_interpd + @_docstring.dedent_interpd def add_subplot(self, *args, **kwargs): """ Add an `~.axes.Axes` to the figure as part of a subplot arrangement. @@ -702,13 +674,11 @@ def add_subplot(self, *args, **kwargs): Returns ------- - `.axes.SubplotBase`, or another subclass of `~.axes.Axes` + `~.axes.Axes` - The Axes of the subplot. The returned Axes base class depends on - the projection used. It is `~.axes.Axes` if rectilinear projection - is used and `.projections.polar.PolarAxes` if polar projection - is used. The returned Axes is then a subplot subclass of the - base class. + The Axes of the subplot. The returned Axes can actually be an + instance of a subclass, such as `.projections.polar.PolarAxes` for + polar projections. Other Parameters ---------------- @@ -749,14 +719,15 @@ def add_subplot(self, *args, **kwargs): if 'figure' in kwargs: # Axes itself allows for a 'figure' kwarg, but since we want to # bind the created Axes to self, it is not allowed here. - raise TypeError( - "add_subplot() got an unexpected keyword argument 'figure'") + raise _api.kwarg_error("add_subplot", "figure") - if len(args) == 1 and isinstance(args[0], SubplotBase): + if (len(args) == 1 + and isinstance(args[0], mpl.axes._base._AxesBase) + and args[0].get_subplotspec()): ax = args[0] key = ax._projection_init if ax.get_figure() is not self: - raise ValueError("The Subplot must have been created in " + raise ValueError("The Axes must have been created in " "the present figure") else: if not args: @@ -769,14 +740,15 @@ def add_subplot(self, *args, **kwargs): args = tuple(map(int, str(args[0]))) projection_class, pkw = self._process_projection_requirements( *args, **kwargs) - ax = subplot_class_factory(projection_class)(self, *args, **pkw) + ax = projection_class(self, *args, **pkw) key = (projection_class, pkw) return self._add_axes_internal(ax, key) def _add_axes_internal(self, ax, key): """Private helper for `add_axes` and `add_subplot`.""" self._axstack.add(ax) - self._localaxes.add(ax) + if ax not in self._localaxes: + self._localaxes.append(ax) self.sca(ax) ax._remove_method = self.delaxes # this is to support plt.subplot's re-selection logic @@ -786,7 +758,8 @@ def _add_axes_internal(self, ax, key): return ax def subplots(self, nrows=1, ncols=1, *, sharex=False, sharey=False, - squeeze=True, subplot_kw=None, gridspec_kw=None): + squeeze=True, width_ratios=None, height_ratios=None, + subplot_kw=None, gridspec_kw=None): """ Add a set of subplots to this figure. @@ -829,6 +802,18 @@ def subplots(self, nrows=1, ncols=1, *, sharex=False, sharey=False, is always a 2D array containing Axes instances, even if it ends up being 1x1. + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Equivalent + to ``gridspec_kw={'width_ratios': [...]}``. + + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Equivalent + to ``gridspec_kw={'height_ratios': [...]}``. + subplot_kw : dict, optional Dict with keywords passed to the `.Figure.add_subplot` call used to create each subplot. @@ -879,20 +864,30 @@ def subplots(self, nrows=1, ncols=1, *, sharex=False, sharey=False, axes[0, 0].plot(x, y) axes[1, 1].scatter(x, y) - # Share a X axis with each column of subplots + # Share an X-axis with each column of subplots fig.subplots(2, 2, sharex='col') - # Share a Y axis with each row of subplots + # Share a Y-axis with each row of subplots fig.subplots(2, 2, sharey='row') - # Share both X and Y axes with all subplots + # Share both X- and Y-axes with all subplots fig.subplots(2, 2, sharex='all', sharey='all') # Note that this is the same as fig.subplots(2, 2, sharex=True, sharey=True) """ - if gridspec_kw is None: - gridspec_kw = {} + gridspec_kw = dict(gridspec_kw or {}) + if height_ratios is not None: + if 'height_ratios' in gridspec_kw: + raise ValueError("'height_ratios' must not be defined both as " + "parameter and as key in 'gridspec_kw'") + gridspec_kw['height_ratios'] = height_ratios + if width_ratios is not None: + if 'width_ratios' in gridspec_kw: + raise ValueError("'width_ratios' must not be defined both as " + "parameter and as key in 'gridspec_kw'") + gridspec_kw['width_ratios'] = width_ratios + gs = self.add_gridspec(nrows, ncols, figure=self, **gridspec_kw) axs = gs.subplots(sharex=sharex, sharey=sharey, squeeze=squeeze, subplot_kw=subplot_kw) @@ -953,7 +948,7 @@ def clear(self, keep_observers=False): self.subfigs = [] for ax in tuple(self.axes): # Iterate over the copy. - ax.cla() + ax.clear() self.delaxes(ax) # Remove ax from self._axstack. self.artists = [] @@ -973,7 +968,7 @@ def clear(self, keep_observers=False): # synonym for `clear`. def clf(self, keep_observers=False): """ - Alias for the `clear()` method. + [*Discouraged*] Alias for the `clear()` method. .. admonition:: Discouraged @@ -987,11 +982,13 @@ def clf(self, keep_observers=False): """ return self.clear(keep_observers=keep_observers) - # Note: in the docstring below, the newlines in the examples after the - # calls to legend() allow replacing it with figlegend() to generate the - # docstring of pyplot.figlegend. - - @docstring.dedent_interpd + # Note: the docstring below is modified with replace for the pyplot + # version of this function because the method name differs (plt.figlegend) + # the replacements are: + # " legend(" -> " figlegend(" for the signatures + # "fig.legend(" -> "plt.figlegend" for the code examples + # "ax.plot" -> "plt.plot" for consistency in using pyplot when able + @_docstring.dedent_interpd def legend(self, *args, **kwargs): """ Place a legend on the figure. @@ -1061,7 +1058,7 @@ def legend(self, *args, **kwargs): To make a legend for all artists on all Axes, call this function with an iterable of strings, one for each legend item. For example:: - fig, (ax1, ax2) = plt.subplots(1, 2) + fig, (ax1, ax2) = plt.subplots(1, 2) ax1.plot([1, 3, 5], color='blue') ax2.plot([2, 4, 6], color='red') fig.legend(['the blues', 'the reds']) @@ -1090,7 +1087,8 @@ def legend(self, *args, **kwargs): Other Parameters ---------------- - %(_legend_kw_doc)s + %(_legend_kw_figure)s + See Also -------- @@ -1126,7 +1124,7 @@ def legend(self, *args, **kwargs): self.stale = True return l - @docstring.dedent_interpd + @_docstring.dedent_interpd def text(self, x, y, s, fontdict=None, **kwargs): """ Add text to figure. @@ -1176,36 +1174,108 @@ def text(self, x, y, s, fontdict=None, **kwargs): self.stale = True return text - @docstring.dedent_interpd - def colorbar(self, mappable, cax=None, ax=None, use_gridspec=True, **kw): - """%(colorbar_doc)s""" + @_docstring.dedent_interpd + def colorbar( + self, mappable, cax=None, ax=None, use_gridspec=True, **kwargs): + """ + Add a colorbar to a plot. + + Parameters + ---------- + mappable + The `matplotlib.cm.ScalarMappable` (i.e., `.AxesImage`, + `.ContourSet`, etc.) described by this colorbar. This argument is + mandatory for the `.Figure.colorbar` method but optional for the + `.pyplot.colorbar` function, which sets the default to the current + image. + + Note that one can create a `.ScalarMappable` "on-the-fly" to + generate colorbars not attached to a previously drawn artist, e.g. + :: + + fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax) + + cax : `~matplotlib.axes.Axes`, optional + Axes into which the colorbar will be drawn. + + ax : `~.axes.Axes` or iterable or `numpy.ndarray` of Axes, optional + One or more parent axes from which space for a new colorbar axes + will be stolen, if *cax* is None. This has no effect if *cax* is + set. + + use_gridspec : bool, optional + If *cax* is ``None``, a new *cax* is created as an instance of + Axes. If *ax* is positioned with a subplotspec and *use_gridspec* + is ``True``, then *cax* is also positioned with a subplotspec. + + Returns + ------- + colorbar : `~matplotlib.colorbar.Colorbar` + + Other Parameters + ---------------- + %(_make_axes_kw_doc)s + %(_colormap_kw_doc)s + + Notes + ----- + If *mappable* is a `~.contour.ContourSet`, its *extend* kwarg is + included automatically. + + The *shrink* kwarg provides a simple way to scale the colorbar with + respect to the axes. Note that if *cax* is specified, it determines the + size of the colorbar and *shrink* and *aspect* kwargs are ignored. + + For more precise control, you can manually specify the positions of the + axes objects in which the mappable and the colorbar are drawn. In this + case, do not use any of the axes properties kwargs. + + It is known that some vector graphics viewers (svg and pdf) renders + white gaps between segments of the colorbar. This is due to bugs in + the viewers, not Matplotlib. As a workaround, the colorbar can be + rendered with overlapping segments:: + + cbar = colorbar() + cbar.solids.set_edgecolor("face") + draw() + + However, this has negative consequences in other circumstances, e.g. + with semi-transparent images (alpha < 1) and colorbar extensions; + therefore, this workaround is not used by default (see issue #1188). + """ + if ax is None: - ax = self.gca() - if (hasattr(mappable, "axes") and ax is not mappable.axes - and cax is None): - _api.warn_deprecated( - "3.4", message="Starting from Matplotlib 3.6, colorbar() " - "will steal space from the mappable's axes, rather than " - "from the current axes, to place the colorbar. To " - "silence this warning, explicitly pass the 'ax' argument " - "to colorbar().") + ax = getattr(mappable, "axes", None) + if (self.get_layout_engine() is not None and + not self.get_layout_engine().colorbar_gridspec): + use_gridspec = False # Store the value of gca so that we can set it back later on. if cax is None: + if ax is None: + _api.warn_deprecated("3.6", message=( + 'Unable to determine Axes to steal space for Colorbar. ' + 'Using gca(), but will raise in the future. ' + 'Either provide the *cax* argument to use as the Axes for ' + 'the Colorbar, provide the *ax* argument to steal space ' + 'from it, or add *mappable* to an Axes.')) + ax = self.gca() current_ax = self.gca() userax = False - if (use_gridspec and isinstance(ax, SubplotBase) - and not self.get_constrained_layout()): - cax, kw = cbar.make_axes_gridspec(ax, **kw) + if (use_gridspec + and isinstance(ax, mpl.axes._base._AxesBase) + and ax.get_subplotspec()): + cax, kwargs = cbar.make_axes_gridspec(ax, **kwargs) else: - cax, kw = cbar.make_axes(ax, **kw) + cax, kwargs = cbar.make_axes(ax, **kwargs) + cax.grid(visible=False, which='both', axis='both') else: userax = True # need to remove kws that cannot be passed to Colorbar NON_COLORBAR_KEYS = ['fraction', 'pad', 'shrink', 'aspect', 'anchor', 'panchor'] - cb_kw = {k: v for k, v in kw.items() if k not in NON_COLORBAR_KEYS} + cb_kw = {k: v for k, v in kwargs.items() if k not in NON_COLORBAR_KEYS} cb = cbar.Colorbar(cax, mappable, **cb_kw) @@ -1243,15 +1313,16 @@ def subplots_adjust(self, left=None, bottom=None, right=None, top=None, The height of the padding between subplots, as a fraction of the average Axes height. """ - if self.get_constrained_layout(): - self.set_constrained_layout(False) + if (self.get_layout_engine() is not None and + not self.get_layout_engine().adjust_compatible): _api.warn_external( - "This figure was using constrained_layout, but that is " + "This figure was using a layout engine that is " "incompatible with subplots_adjust and/or tight_layout; " - "disabling constrained_layout.") + "not calling subplots_adjust.") + return self.subplotpars.update(left, bottom, right, top, wspace, hspace) for ax in self.axes: - if hasattr(ax, 'get_subplotspec'): + if ax.get_subplotspec() is not None: ax._set_position(ax.get_subplotspec().get_position(self)) self.stale = True @@ -1271,7 +1342,7 @@ def align_xlabels(self, axs=None): Parameters ---------- axs : list of `~matplotlib.axes.Axes` - Optional list of (or ndarray) `~matplotlib.axes.Axes` + Optional list of (or `~numpy.ndarray`) `~matplotlib.axes.Axes` to align the xlabels. Default is to align all Axes on the figure. @@ -1298,9 +1369,7 @@ def align_xlabels(self, axs=None): """ if axs is None: axs = self.axes - axs = np.ravel(axs) - axs = [ax for ax in axs if hasattr(ax, 'get_subplotspec')] - + axs = [ax for ax in np.ravel(axs) if ax.get_subplotspec() is not None] for ax in axs: _log.debug(' Working on: %s', ax.get_xlabel()) rowspan = ax.get_subplotspec().rowspan @@ -1334,7 +1403,7 @@ def align_ylabels(self, axs=None): Parameters ---------- axs : list of `~matplotlib.axes.Axes` - Optional list (or ndarray) of `~matplotlib.axes.Axes` + Optional list (or `~numpy.ndarray`) of `~matplotlib.axes.Axes` to align the ylabels. Default is to align all Axes on the figure. @@ -1360,9 +1429,7 @@ def align_ylabels(self, axs=None): """ if axs is None: axs = self.axes - axs = np.ravel(axs) - axs = [ax for ax in axs if hasattr(ax, 'get_subplotspec')] - + axs = [ax for ax in np.ravel(axs) if ax.get_subplotspec() is not None] for ax in axs: _log.debug(' Working on: %s', ax.get_ylabel()) colspan = ax.get_subplotspec().colspan @@ -1391,7 +1458,7 @@ def align_labels(self, axs=None): Parameters ---------- axs : list of `~matplotlib.axes.Axes` - Optional list (or ndarray) of `~matplotlib.axes.Axes` + Optional list (or `~numpy.ndarray`) of `~matplotlib.axes.Axes` to align the labels. Default is to align all Axes on the figure. @@ -1415,7 +1482,7 @@ def add_gridspec(self, nrows=1, ncols=1, **kwargs): Number of rows in grid. ncols : int, default: 1 - Number or columns in grid. + Number of columns in grid. Returns ------- @@ -1445,7 +1512,6 @@ def add_gridspec(self, nrows=1, ncols=1, **kwargs): _ = kwargs.pop('figure', None) # pop in case user has added this... gs = GridSpec(nrows=nrows, ncols=ncols, figure=self, **kwargs) - self._gridspecs.append(gs) return gs def subfigures(self, nrows=1, ncols=1, squeeze=True, @@ -1453,7 +1519,7 @@ def subfigures(self, nrows=1, ncols=1, squeeze=True, width_ratios=None, height_ratios=None, **kwargs): """ - Add a subfigure to this figure or subfigure. + Add a set of subfigures to this figure or subfigure. A subfigure has the same artist methods as a figure, and is logically the same as a figure, but cannot print itself. @@ -1504,8 +1570,7 @@ def subfigures(self, nrows=1, ncols=1, squeeze=True, def add_subfigure(self, subplotspec, **kwargs): """ - Add a `~.figure.SubFigure` to the figure as part of a subplot - arrangement. + Add a `.SubFigure` to the figure as part of a subplot arrangement. Parameters ---------- @@ -1515,12 +1580,12 @@ def add_subfigure(self, subplotspec, **kwargs): Returns ------- - `.figure.SubFigure` + `.SubFigure` Other Parameters ---------------- **kwargs - Are passed to the `~.figure.SubFigure` object. + Are passed to the `.SubFigure` object. See Also -------- @@ -1536,8 +1601,7 @@ def sca(self, a): self._axobservers.process("_axes_change_event", self) return a - @docstring.dedent_interpd - def gca(self, **kwargs): + def gca(self): """ Get the current Axes. @@ -1546,27 +1610,9 @@ def gca(self, **kwargs): Axes on a Figure, check whether ``figure.axes`` is empty. To test whether there is currently a Figure on the pyplot figure stack, check whether `.pyplot.get_fignums()` is empty.) - - The following kwargs are supported for ensuring the returned Axes - adheres to the given projection etc., and for Axes creation if - the active Axes does not exist: - - %(Axes:kwdoc)s - """ - if kwargs: - _api.warn_deprecated( - "3.4", - message="Calling gca() with keyword arguments was deprecated " - "in Matplotlib %(since)s. Starting %(removal)s, gca() will " - "take no keyword arguments. The gca() function should only be " - "used to get the current axes, or if no axes exist, create " - "new axes with default keyword arguments. To create a new " - "axes with non-default arguments, use plt.axes() or " - "plt.subplot().") - if self._axstack.empty(): - return self.add_subplot(1, 1, 1, **kwargs) - else: - return self._axstack() + """ + ax = self._axstack.current() + return ax if ax is not None else self.add_subplot() def _gci(self): # Helper for `~matplotlib.pyplot.gci`. Do not use elsewhere. @@ -1585,13 +1631,13 @@ def _gci(self): Historically, the only colorable artists were images; hence the name ``gci`` (get current image). """ - # Look first for an image in the current Axes: - if self._axstack.empty(): + # Look first for an image in the current Axes. + ax = self._axstack.current() + if ax is None: return None - im = self._axstack()._gci() + im = ax._gci() if im is not None: return im - # If there is no image in the current Axes, search for # one in a previously created Axes. Whether this makes # sense is debatable, but it is the documented behavior. @@ -1635,8 +1681,6 @@ def _process_projection_requirements( raise TypeError( f"projection must be a string, None or implement a " f"_as_mpl_axes method, not {projection!r}") - if projection_class.__name__ == 'Axes3D': - kwargs.setdefault('auto_add_to_figure', False) return projection_class, kwargs def get_default_bbox_extra_artists(self): @@ -1647,7 +1691,7 @@ def get_default_bbox_extra_artists(self): bbox_artists.extend(ax.get_default_bbox_extra_artists()) return bbox_artists - def get_tightbbox(self, renderer, bbox_extra_artists=None): + def get_tightbbox(self, renderer=None, bbox_extra_artists=None): """ Return a (tight) bounding box of the figure *in inches*. @@ -1660,7 +1704,7 @@ def get_tightbbox(self, renderer, bbox_extra_artists=None): Parameters ---------- renderer : `.RendererBase` subclass - renderer that will be used to draw the figures (i.e. + Renderer that will be used to draw the figures (i.e. ``fig.canvas.get_renderer()``) bbox_extra_artists : list of `.Artist` or ``None`` @@ -1674,6 +1718,9 @@ def get_tightbbox(self, renderer, bbox_extra_artists=None): containing the bounding box (in figure inches). """ + if renderer is None: + renderer = self.figure._get_renderer() + bb = [] if bbox_extra_artists is None: artists = self.get_default_bbox_extra_artists() @@ -1682,7 +1729,7 @@ def get_tightbbox(self, renderer, bbox_extra_artists=None): for a in artists: bbox = a.get_tightbbox(renderer) - if bbox is not None and (bbox.width != 0 or bbox.height != 0): + if bbox is not None: bb.append(bbox) for ax in self.axes: @@ -1715,6 +1762,25 @@ def get_tightbbox(self, renderer, bbox_extra_artists=None): return _bbox + @staticmethod + def _norm_per_subplot_kw(per_subplot_kw): + expanded = {} + for k, v in per_subplot_kw.items(): + if isinstance(k, tuple): + for sub_key in k: + if sub_key in expanded: + raise ValueError( + f'The key {sub_key!r} appears multiple times.' + ) + expanded[sub_key] = v + else: + if k in expanded: + raise ValueError( + f'The key {k!r} appears multiple times.' + ) + expanded[k] = v + return expanded + @staticmethod def _normalize_grid_string(layout): if '\n' not in layout: @@ -1726,16 +1792,16 @@ def _normalize_grid_string(layout): return [list(ln) for ln in layout.strip('\n').split('\n')] def subplot_mosaic(self, mosaic, *, sharex=False, sharey=False, - subplot_kw=None, gridspec_kw=None, empty_sentinel='.'): + width_ratios=None, height_ratios=None, + empty_sentinel='.', + subplot_kw=None, per_subplot_kw=None, gridspec_kw=None): """ Build a layout of Axes based on ASCII art or nested lists. This is a helper function to build complex GridSpec layouts visually. - .. note :: - - This API is provisional and may be revised in the future based on - early user feedback. + See :doc:`/gallery/subplots_axes_and_figures/mosaic` + for an example and full API documentation Parameters ---------- @@ -1773,19 +1839,53 @@ def subplot_mosaic(self, mosaic, *, sharex=False, sharey=False, The string notation allows only single character Axes labels and does not support nesting but is very terse. + The Axes identifiers may be `str` or a non-iterable hashable + object (e.g. `tuple` s may not be used). + sharex, sharey : bool, default: False If True, the x-axis (*sharex*) or y-axis (*sharey*) will be shared among all subplots. In that case, tick label visibility and axis units behave as for `subplots`. If False, each subplot's x- or y-axis will be independent. + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Equivalent + to ``gridspec_kw={'width_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Equivalent + to ``gridspec_kw={'height_ratios': [...]}``. In the case of nested + layouts, this argument applies only to the outer layout. + subplot_kw : dict, optional Dictionary with keywords passed to the `.Figure.add_subplot` call - used to create each subplot. + used to create each subplot. These values may be overridden by + values in *per_subplot_kw*. + + per_subplot_kw : dict, optional + A dictionary mapping the Axes identifiers or tuples of identifiers + to a dictionary of keyword arguments to be passed to the + `.Figure.add_subplot` call used to create each subplot. The values + in these dictionaries have precedence over the values in + *subplot_kw*. + + If *mosaic* is a string, and thus all keys are single characters, + it is possible to use a single string instead of a tuple as keys; + i.e. ``"AB"`` is equivalent to ``("A", "B")``. + + .. versionadded:: 3.7 gridspec_kw : dict, optional Dictionary with keywords passed to the `.GridSpec` constructor used - to create the grid the subplots are placed on. + to create the grid the subplots are placed on. In the case of + nested layouts, this argument applies only to the outer layout. + For more complex layouts, users should use `.Figure.subfigures` + to create the nesting. empty_sentinel : object, optional Entry in the layout to mean "leave this space empty". Defaults @@ -1802,10 +1902,29 @@ def subplot_mosaic(self, mosaic, *, sharex=False, sharey=False, """ subplot_kw = subplot_kw or {} - gridspec_kw = gridspec_kw or {} + gridspec_kw = dict(gridspec_kw or {}) + per_subplot_kw = per_subplot_kw or {} + + if height_ratios is not None: + if 'height_ratios' in gridspec_kw: + raise ValueError("'height_ratios' must not be defined both as " + "parameter and as key in 'gridspec_kw'") + gridspec_kw['height_ratios'] = height_ratios + if width_ratios is not None: + if 'width_ratios' in gridspec_kw: + raise ValueError("'width_ratios' must not be defined both as " + "parameter and as key in 'gridspec_kw'") + gridspec_kw['width_ratios'] = width_ratios + # special-case string input if isinstance(mosaic, str): mosaic = self._normalize_grid_string(mosaic) + per_subplot_kw = { + tuple(k): v for k, v in per_subplot_kw.items() + } + + per_subplot_kw = self._norm_per_subplot_kw(per_subplot_kw) + # Only accept strict bools to allow a possible future API expansion. _api.check_isinstance(bool, sharex=sharex, sharey=sharey) @@ -1927,7 +2046,7 @@ def _do_layout(gs, mosaic, unique_ids, nested): name, arg, method = this_level[key] # we are doing some hokey function dispatch here based # on the 'method' string stashed above to sort out if this - # element is an axes or a nested mosaic. + # element is an Axes or a nested mosaic. if method == 'axes': slc = arg # add a single axes @@ -1935,7 +2054,11 @@ def _do_layout(gs, mosaic, unique_ids, nested): raise ValueError(f"There are duplicate keys {name} " f"in the layout\n{mosaic!r}") ax = self.add_subplot( - gs[slc], **{'label': str(name), **subplot_kw} + gs[slc], **{ + 'label': str(name), + **subplot_kw, + **per_subplot_kw.get(name, {}) + } ) output[name] = ax elif method == 'nested': @@ -1944,7 +2067,7 @@ def _do_layout(gs, mosaic, unique_ids, nested): # recursively add the nested mosaic rows, cols = nested_mosaic.shape nested_output = _do_layout( - gs[j, k].subgridspec(rows, cols, **gridspec_kw), + gs[j, k].subgridspec(rows, cols), nested_mosaic, *_identify_keys_and_nested(nested_mosaic) ) @@ -1972,9 +2095,11 @@ def _do_layout(gs, mosaic, unique_ids, nested): if sharey: ax.sharey(ax0) ax._label_outer_yaxis(check_patch=True) - for k, ax in ret.items(): - if isinstance(k, str): - ax.set_label(k) + if extra := set(per_subplot_kw) - set(ret): + raise ValueError( + f"The keys {extra} are in *per_subplot_kw* " + "but not in the mosaic." + ) return ret def _set_artist_props(self, a): @@ -1984,7 +2109,7 @@ def _set_artist_props(self, a): a.set_transform(self.transSubfigure) -@docstring.interpd +@_docstring.interpd class SubFigure(FigureBase): """ Logical figure that can be placed inside a figure. @@ -2002,6 +2127,10 @@ class SubFigure(FigureBase): See :doc:`/gallery/subplots_axes_and_figures/subfigures` """ + callbacks = _api.deprecated( + "3.6", alternative=("the 'resize_event' signal in " + "Figure.canvas.callbacks") + )(property(lambda self: self._fig_callbacks)) def __init__(self, parent, subplotspec, *, facecolor=None, @@ -2012,7 +2141,7 @@ def __init__(self, parent, subplotspec, *, """ Parameters ---------- - parent : `.figure.Figure` or `.figure.SubFigure` + parent : `.Figure` or `.SubFigure` Figure or subfigure that contains the SubFigure. SubFigures can be nested. @@ -2050,7 +2179,7 @@ def __init__(self, parent, subplotspec, *, self._subplotspec = subplotspec self._parent = parent self.figure = parent.figure - self.callbacks = parent.callbacks + self._fig_callbacks = parent._fig_callbacks # subfigures use the parent axstack self._axstack = parent._axstack @@ -2082,6 +2211,26 @@ def dpi(self): def dpi(self, value): self._parent.dpi = value + def get_dpi(self): + """ + Return the resolution of the parent figure in dots-per-inch as a float. + """ + return self._parent.dpi + + def set_dpi(self, val): + """ + Set the resolution of parent figure in dots-per-inch. + + Parameters + ---------- + val : float + """ + self._parent.dpi = val + self.stale = True + + def _get_renderer(self): + return self._parent._get_renderer() + def _redo_transform_rel_fig(self, bbox=None): """ Make the transSubfigure bbox relative to Figure transform. @@ -2090,7 +2239,7 @@ def _redo_transform_rel_fig(self, bbox=None): ---------- bbox : bbox or None If not None, then the bbox is used for relative bounding box. - Otherwise it is calculated from the subplotspec. + Otherwise, it is calculated from the subplotspec. """ if bbox is not None: self.bbox_relative.p0 = bbox.p0 @@ -2134,26 +2283,28 @@ def get_constrained_layout_pads(self, relative=False): """ return self._parent.get_constrained_layout_pads(relative=relative) + def get_layout_engine(self): + return self._parent.get_layout_engine() + @property def axes(self): """ List of Axes in the SubFigure. You can access and modify the Axes in the SubFigure through this list. - Do not modify the list itself. Instead, use `~.SubFigure.add_axes`, + Modifying this list has no effect. Instead, use `~.SubFigure.add_axes`, `~.SubFigure.add_subplot` or `~.SubFigure.delaxes` to add or remove an Axes. Note: The `.SubFigure.axes` property and `~.SubFigure.get_axes` method are equivalent. """ - return self._localaxes.as_list() + return self._localaxes[:] get_axes = axes.fget def draw(self, renderer): # docstring inherited - self._cachedRenderer = renderer # draw the figure bounding box, perhaps none for white figure if not self.get_visible(): @@ -2174,16 +2325,11 @@ def draw(self, renderer): self.stale = False -@docstring.interpd +@_docstring.interpd class Figure(FigureBase): """ The top level container for all the plot elements. - The Figure instance supports callbacks through a *callbacks* attribute - which is a `.CallbackRegistry` instance. The events you can connect to - are 'dpi_changed', and the callback will be called with ``func(fig)`` where - fig is the `Figure` instance. - Attributes ---------- patch @@ -2194,6 +2340,12 @@ class Figure(FigureBase): depending on the renderer option_image_nocomposite function. If *suppressComposite* is a boolean, this will override the renderer. """ + # Remove the self._fig_callbacks properties on figure and subfigure + # after the deprecation expires. + callbacks = _api.deprecated( + "3.6", alternative=("the 'resize_event' signal in " + "Figure.canvas.callbacks") + )(property(lambda self: self._fig_callbacks)) def __str__(self): return "Figure(%gx%g)" % tuple(self.bbox.size) @@ -2205,6 +2357,7 @@ def __repr__(self): naxes=len(self.axes), ) + @_api.make_keyword_only("3.6", "facecolor") def __init__(self, figsize=None, dpi=None, @@ -2262,22 +2415,32 @@ def __init__(self, The use of this parameter is discouraged. Please use ``layout='constrained'`` instead. - layout : {'constrained', 'tight'}, optional - The layout mechanism for positioning of plot elements. - Supported values: + layout : {'constrained', 'compressed', 'tight', `.LayoutEngine`, None} + The layout mechanism for positioning of plot elements to avoid + overlapping Axes decorations (labels, ticks, etc). Note that + layout managers can have significant performance penalties. + Defaults to *None*. - - 'constrained': The constrained layout solver usually gives the - best layout results and is thus recommended. However, it is - computationally expensive and can be slow for complex figures - with many elements. + - 'constrained': The constrained layout solver adjusts axes sizes + to avoid overlapping axes decorations. Can handle complex plot + layouts and colorbars, and is thus recommended. See :doc:`/tutorials/intermediate/constrainedlayout_guide` for examples. + - 'compressed': uses the same algorithm as 'constrained', but + removes extra space between fixed-aspect-ratio Axes. Best for + simple grids of axes. + - 'tight': Use the tight layout mechanism. This is a relatively - simple algorithm, that adjusts the subplot parameters so that - decorations like tick labels, axis labels and titles have enough - space. See `.Figure.set_tight_layout` for further details. + simple algorithm that adjusts the subplot parameters so that + decorations do not overlap. See `.Figure.set_tight_layout` for + further details. + + - A `.LayoutEngine` instance. Builtin layout classes are + `.ConstrainedLayoutEngine` and `.TightLayoutEngine`, more easily + accessible by 'constrained' and 'tight'. Passing an instance + allows third parties to provide their own layout engine. If not given, fall back to using the parameters *tight_layout* and *constrained_layout*, including their config defaults @@ -2290,34 +2453,56 @@ def __init__(self, %(Figure:kwdoc)s """ super().__init__(**kwargs) + self._layout_engine = None if layout is not None: - if tight_layout is not None: + if (tight_layout is not None): _api.warn_external( - "The Figure parameters 'layout' and 'tight_layout' " - "cannot be used together. Please use 'layout' only.") - if constrained_layout is not None: + "The Figure parameters 'layout' and 'tight_layout' cannot " + "be used together. Please use 'layout' only.") + if (constrained_layout is not None): _api.warn_external( "The Figure parameters 'layout' and 'constrained_layout' " "cannot be used together. Please use 'layout' only.") - if layout == 'constrained': - tight_layout = False - constrained_layout = True - elif layout == 'tight': - tight_layout = True - constrained_layout = False - else: - _api.check_in_list(['constrained', 'tight'], layout=layout) + self.set_layout_engine(layout=layout) + elif tight_layout is not None: + if constrained_layout is not None: + _api.warn_external( + "The Figure parameters 'tight_layout' and " + "'constrained_layout' cannot be used together. Please use " + "'layout' parameter") + self.set_layout_engine(layout='tight') + if isinstance(tight_layout, dict): + self.get_layout_engine().set(**tight_layout) + elif constrained_layout is not None: + if isinstance(constrained_layout, dict): + self.set_layout_engine(layout='constrained') + self.get_layout_engine().set(**constrained_layout) + elif constrained_layout: + self.set_layout_engine(layout='constrained') - self.callbacks = cbook.CallbackRegistry() + else: + # everything is None, so use default: + self.set_layout_engine(layout=layout) + + self._fig_callbacks = cbook.CallbackRegistry(signals=["dpi_changed"]) # Callbacks traditionally associated with the canvas (and exposed with # a proxy property), but that actually need to be on the figure for # pickling. - self._canvas_callbacks = cbook.CallbackRegistry() - self._button_pick_id = self._canvas_callbacks.connect( - 'button_press_event', lambda event: self.canvas.pick(event)) - self._scroll_pick_id = self._canvas_callbacks.connect( - 'scroll_event', lambda event: self.canvas.pick(event)) + self._canvas_callbacks = cbook.CallbackRegistry( + signals=FigureCanvasBase.events) + connect = self._canvas_callbacks._connect_picklable + self._mouse_key_ids = [ + connect('key_press_event', backend_bases._key_handler), + connect('key_release_event', backend_bases._key_handler), + connect('key_release_event', backend_bases._key_handler), + connect('button_press_event', backend_bases._mouse_handler), + connect('button_release_event', backend_bases._mouse_handler), + connect('scroll_event', backend_bases._mouse_handler), + connect('motion_notify_event', backend_bases._mouse_handler), + ] + self._button_pick_id = connect('button_press_event', self.pick) + self._scroll_pick_id = connect('scroll_event', self.pick) if figsize is None: figsize = mpl.rcParams['figure.figsize'] @@ -2358,19 +2543,100 @@ def __init__(self, self.subplotpars = subplotpars - # constrained_layout: - self._constrained = False - - self.set_tight_layout(tight_layout) - self._axstack = _AxesStack() # track all figure axes and current axes self.clear() - self._cachedRenderer = None - self.set_constrained_layout(constrained_layout) + def pick(self, mouseevent): + if not self.canvas.widgetlock.locked(): + super().pick(mouseevent) + + def _check_layout_engines_compat(self, old, new): + """ + Helper for set_layout engine + + If the figure has used the old engine and added a colorbar then the + value of colorbar_gridspec must be the same on the new engine. + """ + if old is None or new is None: + return True + if old.colorbar_gridspec == new.colorbar_gridspec: + return True + # colorbar layout different, so check if any colorbars are on the + # figure... + for ax in self.axes: + if hasattr(ax, '_colorbar'): + # colorbars list themselves as a colorbar. + return False + return True + + def set_layout_engine(self, layout=None, **kwargs): + """ + Set the layout engine for this figure. + + Parameters + ---------- + layout: {'constrained', 'compressed', 'tight', 'none'} or \ +`LayoutEngine` or None + + - 'constrained' will use `~.ConstrainedLayoutEngine` + - 'compressed' will also use `~.ConstrainedLayoutEngine`, but with + a correction that attempts to make a good layout for fixed-aspect + ratio Axes. + - 'tight' uses `~.TightLayoutEngine` + - 'none' removes layout engine. + + If `None`, the behavior is controlled by :rc:`figure.autolayout` + (which if `True` behaves as if 'tight' was passed) and + :rc:`figure.constrained_layout.use` (which if `True` behaves as if + 'constrained' was passed). If both are `True`, + :rc:`figure.autolayout` takes priority. + + Users and libraries can define their own layout engines and pass + the instance directly as well. + + kwargs: dict + The keyword arguments are passed to the layout engine to set things + like padding and margin sizes. Only used if *layout* is a string. + + """ + if layout is None: + if mpl.rcParams['figure.autolayout']: + layout = 'tight' + elif mpl.rcParams['figure.constrained_layout.use']: + layout = 'constrained' + else: + self._layout_engine = None + return + if layout == 'tight': + new_layout_engine = TightLayoutEngine(**kwargs) + elif layout == 'constrained': + new_layout_engine = ConstrainedLayoutEngine(**kwargs) + elif layout == 'compressed': + new_layout_engine = ConstrainedLayoutEngine(compress=True, + **kwargs) + elif layout == 'none': + if self._layout_engine is not None: + new_layout_engine = PlaceHolderLayoutEngine( + self._layout_engine.adjust_compatible, + self._layout_engine.colorbar_gridspec + ) + else: + new_layout_engine = None + elif isinstance(layout, LayoutEngine): + new_layout_engine = layout + else: + raise ValueError(f"Invalid value for 'layout': {layout!r}") + + if self._check_layout_engines_compat(self._layout_engine, + new_layout_engine): + self._layout_engine = new_layout_engine + else: + raise RuntimeError('Colorbar layout of new layout engine not ' + 'compatible with old engine, and a colorbar ' + 'has been created. Engine not changed.') - # list of child gridspecs for this figure - self._gridspecs = [] + def get_layout_engine(self): + return self._layout_engine # TODO: I'd like to dynamically add the _repr_html_ method # to the figure in the right context, but then IPython doesn't @@ -2397,18 +2663,25 @@ def show(self, warn=True): may only be shown briefly or not shown at all if you or your environment are not managing an event loop. - Proper use cases for `.Figure.show` include running this from a - GUI application or an IPython shell. + Use cases for `.Figure.show` include running this from a GUI + application (where there is persistently an event loop running) or + from a shell, like IPython, that install an input hook to allow the + interactive shell to accept input while the figure is also being + shown and interactive. Some, but not all, GUI toolkits will + register an input hook on import. See :ref:`cp_integration` for + more details. - If you're running a pure python shell or executing a non-GUI - python script, you should use `matplotlib.pyplot.show` instead, - which takes care of managing the event loop for you. + If you're in a shell without input hook integration or executing a + python script, you should use `matplotlib.pyplot.show` with + ``block=True`` instead, which takes care of starting and running + the event loop for you. Parameters ---------- warn : bool, default: True If ``True`` and we are not running headless (i.e. on Linux with an unset DISPLAY), issue warning when called on a non-GUI backend. + """ if self.canvas.manager is None: raise AttributeError( @@ -2436,6 +2709,12 @@ def axes(self): get_axes = axes.fget + def _get_renderer(self): + if hasattr(self.canvas, 'get_renderer'): + return self.canvas.get_renderer() + else: + return _get_renderer(self) + def _get_dpi(self): return self._dpi @@ -2455,17 +2734,24 @@ def _set_dpi(self, dpi, forward=True): self.dpi_scale_trans.clear().scale(dpi) w, h = self.get_size_inches() self.set_size_inches(w, h, forward=forward) - self.callbacks.process('dpi_changed', self) + self._fig_callbacks.process('dpi_changed', self) dpi = property(_get_dpi, _set_dpi, doc="The resolution in dots per inch.") def get_tight_layout(self): """Return whether `.tight_layout` is called when drawing.""" - return self._tight + return isinstance(self.get_layout_engine(), TightLayoutEngine) + @_api.deprecated("3.6", alternative="set_layout_engine", + pending=True) def set_tight_layout(self, tight): """ - Set whether and how `.tight_layout` is called when drawing. + [*Discouraged*] Set whether and how `.tight_layout` is called when + drawing. + + .. admonition:: Discouraged + + This method is discouraged in favor of `~.set_layout_engine`. Parameters ---------- @@ -2477,8 +2763,9 @@ def set_tight_layout(self, tight): """ if tight is None: tight = mpl.rcParams['figure.autolayout'] - self._tight = bool(tight) - self._tight_parameters = tight if isinstance(tight, dict) else {} + _tight = 'tight' if bool(tight) else 'none' + _tight_parameters = tight if isinstance(tight, dict) else {} + self.set_layout_engine(_tight, **_tight_parameters) self.stale = True def get_constrained_layout(self): @@ -2487,40 +2774,41 @@ def get_constrained_layout(self): See :doc:`/tutorials/intermediate/constrainedlayout_guide`. """ - return self._constrained + return isinstance(self.get_layout_engine(), ConstrainedLayoutEngine) + @_api.deprecated("3.6", alternative="set_layout_engine('constrained')", + pending=True) def set_constrained_layout(self, constrained): """ - Set whether ``constrained_layout`` is used upon drawing. If None, - :rc:`figure.constrained_layout.use` value will be used. + [*Discouraged*] Set whether ``constrained_layout`` is used upon + drawing. + + If None, :rc:`figure.constrained_layout.use` value will be used. - When providing a dict containing the keys `w_pad`, `h_pad` + When providing a dict containing the keys ``w_pad``, ``h_pad`` the default ``constrained_layout`` paddings will be overridden. These pads are in inches and default to 3.0/72.0. ``w_pad`` is the width padding and ``h_pad`` is the height padding. - See :doc:`/tutorials/intermediate/constrainedlayout_guide`. + .. admonition:: Discouraged + + This method is discouraged in favor of `~.set_layout_engine`. Parameters ---------- constrained : bool or dict or None """ - self._constrained_layout_pads = dict() - self._constrained_layout_pads['w_pad'] = None - self._constrained_layout_pads['h_pad'] = None - self._constrained_layout_pads['wspace'] = None - self._constrained_layout_pads['hspace'] = None if constrained is None: constrained = mpl.rcParams['figure.constrained_layout.use'] - self._constrained = bool(constrained) - if isinstance(constrained, dict): - self.set_constrained_layout_pads(**constrained) - else: - self.set_constrained_layout_pads() + _constrained = 'constrained' if bool(constrained) else 'none' + _parameters = constrained if isinstance(constrained, dict) else {} + self.set_layout_engine(_constrained, **_parameters) self.stale = True - def set_constrained_layout_pads(self, *, w_pad=None, h_pad=None, - wspace=None, hspace=None): + @_api.deprecated( + "3.6", alternative="figure.get_layout_engine().set()", + pending=True) + def set_constrained_layout_pads(self, **kwargs): """ Set padding for ``constrained_layout``. @@ -2548,21 +2836,18 @@ def set_constrained_layout_pads(self, *, w_pad=None, h_pad=None, subplot width. The total padding ends up being h_pad + hspace. """ + if isinstance(self.get_layout_engine(), ConstrainedLayoutEngine): + self.get_layout_engine().set(**kwargs) - for name, size in zip(['w_pad', 'h_pad', 'wspace', 'hspace'], - [w_pad, h_pad, wspace, hspace]): - if size is not None: - self._constrained_layout_pads[name] = size - else: - self._constrained_layout_pads[name] = ( - mpl.rcParams[f'figure.constrained_layout.{name}']) - + @_api.deprecated("3.6", alternative="fig.get_layout_engine().get()", + pending=True) def get_constrained_layout_pads(self, relative=False): """ Get padding for ``constrained_layout``. Returns a list of ``w_pad, h_pad`` in inches and ``wspace`` and ``hspace`` as fractions of the subplot. + All values are None if ``constrained_layout`` is not used. See :doc:`/tutorials/intermediate/constrainedlayout_guide`. @@ -2571,13 +2856,16 @@ def get_constrained_layout_pads(self, relative=False): relative : bool If `True`, then convert from inches to figure relative. """ - w_pad = self._constrained_layout_pads['w_pad'] - h_pad = self._constrained_layout_pads['h_pad'] - wspace = self._constrained_layout_pads['wspace'] - hspace = self._constrained_layout_pads['hspace'] + if not isinstance(self.get_layout_engine(), ConstrainedLayoutEngine): + return None, None, None, None + info = self.get_layout_engine().get_info() + w_pad = info['w_pad'] + h_pad = info['h_pad'] + wspace = info['wspace'] + hspace = info['hspace'] if relative and (w_pad is not None or h_pad is not None): - renderer = _get_renderer(self) + renderer = self._get_renderer() dpi = renderer.dpi w_pad = w_pad * dpi / renderer.width h_pad = h_pad * dpi / renderer.height @@ -2594,6 +2882,7 @@ def set_canvas(self, canvas): """ self.canvas = canvas + @_docstring.interpd def figimage(self, X, xo=0, yo=0, alpha=None, norm=None, cmap=None, vmin=None, vmax=None, origin=None, resize=False, **kwargs): """ @@ -2607,9 +2896,11 @@ def figimage(self, X, xo=0, yo=0, alpha=None, norm=None, cmap=None, X The image data. This is an array of one of the following shapes: - - MxN: luminance (grayscale) values - - MxNx3: RGB values - - MxNx4: RGBA values + - (M, N): an image with scalar data. Color-mapping is controlled + by *cmap*, *norm*, *vmin*, and *vmax*. + - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). + - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), + i.e. including transparency. xo, yo : int The *x*/*y* image offset in pixels. @@ -2617,16 +2908,17 @@ def figimage(self, X, xo=0, yo=0, alpha=None, norm=None, cmap=None, alpha : None or float The alpha blending value. - norm : `matplotlib.colors.Normalize` - A `.Normalize` instance to map the luminance to the - interval [0, 1]. + %(cmap_doc)s + + This parameter is ignored if *X* is RGB(A). - cmap : str or `matplotlib.colors.Colormap`, default: :rc:`image.cmap` - The colormap to use. + %(norm_doc)s - vmin, vmax : float - If *norm* is not given, these values set the data limits for the - colormap. + This parameter is ignored if *X* is RGB(A). + + %(vmin_vmax_doc)s + + This parameter is ignored if *X* is RGB(A). origin : {'upper', 'lower'}, default: :rc:`image.origin` Indicates where the [0, 0] index of the array is in the upper left @@ -2667,7 +2959,9 @@ def figimage(self, X, xo=0, yo=0, alpha=None, norm=None, cmap=None, figsize = [x / dpi for x in (X.shape[1], X.shape[0])] self.set_size_inches(figsize, forward=True) - im = mimage.FigureImage(self, cmap, norm, xo, yo, origin, **kwargs) + im = mimage.FigureImage(self, cmap=cmap, norm=norm, + offsetx=xo, offsety=yo, + origin=origin, **kwargs) im.stale_callback = _stale_figure_callback im.set_array(X) @@ -2716,11 +3010,9 @@ def set_size_inches(self, w, h=None, forward=True): raise ValueError(f'figure size must be positive finite not {size}') self.bbox_inches.p1 = size if forward: - canvas = getattr(self, 'canvas') - if canvas is not None: - manager = getattr(canvas, 'manager', None) - if manager is not None: - manager.resize(*(size * self.dpi).astype(int)) + manager = self.canvas.manager + if manager is not None: + manager.resize(*(size * self.dpi).astype(int)) self.stale = True def get_size_inches(self): @@ -2806,7 +3098,7 @@ def clear(self, keep_observers=False): super().clear(keep_observers=keep_observers) # FigureBase.clear does not clear toolbars, as # only Figure can have toolbars - toolbar = getattr(self.canvas, 'toolbar', None) + toolbar = self.canvas.toolbar if toolbar is not None: toolbar.update() @@ -2814,21 +3106,17 @@ def clear(self, keep_observers=False): @allow_rasterization def draw(self, renderer): # docstring inherited - self._cachedRenderer = renderer # draw the figure bounding box, perhaps none for white figure if not self.get_visible(): return artists = self._get_draw_artists(renderer) - try: renderer.open_group('figure', gid=self.get_gid()) - if self.get_constrained_layout() and self.axes: - self.execute_constrained_layout(renderer) - if self.get_tight_layout() and self.axes: + if self.axes and self.get_layout_engine() is not None: try: - self.tight_layout(**self._tight_parameters) + self.get_layout_engine().execute(self) except ValueError: pass # ValueError can occur when resizing a window. @@ -2844,7 +3132,7 @@ def draw(self, renderer): finally: self.stale = False - self.canvas.draw_event(renderer) + DrawEvent("draw_event", self.canvas, renderer)._process() def draw_without_rendering(self): """ @@ -2858,14 +3146,8 @@ def draw_without_rendering(self): def draw_artist(self, a): """ Draw `.Artist` *a* only. - - This method can only be used after an initial draw of the figure, - because that creates and caches the renderer needed here. """ - if self._cachedRenderer is None: - raise AttributeError("draw_artist can only be used after an " - "initial draw which caches the renderer") - a.draw(self._cachedRenderer) + a.draw(self.canvas.get_renderer()) def __getstate__(self): state = super().__getstate__() @@ -2875,16 +3157,15 @@ def __getstate__(self): # re-attached to another. state.pop("canvas") - # Set cached renderer to None -- it can't be pickled. - state["_cachedRenderer"] = None + # discard any changes to the dpi due to pixel ratio changes + state["_dpi"] = state.get('_original_dpi', state['_dpi']) # add version information to the state state['__mpl_version__'] = mpl.__version__ # check whether the figure manager (if any) is registered with pyplot from matplotlib import _pylab_helpers - if getattr(self.canvas, 'manager', None) \ - in _pylab_helpers.Gcf.figs.values(): + if self.canvas.manager in _pylab_helpers.Gcf.figs.values(): state['_restore_to_pylab'] = True return state @@ -2908,7 +3189,8 @@ def __setstate__(self, state): import matplotlib._pylab_helpers as pylab_helpers allnums = plt.get_fignums() num = max(allnums) + 1 if allnums else 1 - mgr = plt._backend_mod.new_figure_manager_given_figure(num, self) + backend = plt._get_backend_mod() + mgr = backend.new_figure_manager_given_figure(num, self) pylab_helpers.Gcf._set_new_active_manager(mgr) plt.draw_if_interactive() @@ -3071,7 +3353,7 @@ def ginput(self, n=1, timeout=30, show_clicks=True, clicks until the input is terminated manually. timeout : float, default: 30 seconds Number of seconds to wait before timing out. If zero or negative - will never timeout. + will never time out. show_clicks : bool, default: True If True, show a red cross at the location of each click. mouse_add : `.MouseButton` or None, default: `.MouseButton.LEFT` @@ -3090,7 +3372,7 @@ def ginput(self, n=1, timeout=30, show_clicks=True, ----- The keyboard can also be used to select points in case your mouse does not have one or more of the buttons. The delete and backspace - keys act like right clicking (i.e., remove last point), the enter key + keys act like right-clicking (i.e., remove last point), the enter key terminates input and any other key (not already used by the window manager) selects a point. """ @@ -3161,6 +3443,7 @@ def handler(ev): return None if event is None else event.name == "key_press_event" + @_api.deprecated("3.6", alternative="figure.get_layout_engine().execute()") def execute_constrained_layout(self, renderer=None): """ Use ``layoutgrid`` to determine pos positions within Axes. @@ -3171,20 +3454,9 @@ def execute_constrained_layout(self, renderer=None): ------- layoutgrid : private debugging object """ - from matplotlib.tight_layout import get_renderer - from matplotlib._constrained_layout import do_constrained_layout - - _log.debug('Executing constrainedlayout') - w_pad, h_pad, wspace, hspace = self.get_constrained_layout_pads() - # convert to unit-relative lengths - fig = self - width, height = fig.get_size_inches() - w_pad = w_pad / width - h_pad = h_pad / height - if renderer is None: - renderer = get_renderer(fig) - return do_constrained_layout(fig, renderer, h_pad, w_pad, - hspace, wspace) + if not isinstance(self.get_layout_engine(), ConstrainedLayoutEngine): + return None + return self.get_layout_engine().execute(self) def tight_layout(self, *, pad=1.08, h_pad=None, w_pad=None, rect=None): """ @@ -3208,24 +3480,23 @@ def tight_layout(self, *, pad=1.08, h_pad=None, w_pad=None, rect=None): See Also -------- - .Figure.set_tight_layout + .Figure.set_layout_engine .pyplot.tight_layout """ - from contextlib import nullcontext - from .tight_layout import ( - get_subplotspec_list, get_tight_layout_figure, get_renderer) - subplotspec_list = get_subplotspec_list(self.axes) - if None in subplotspec_list: - _api.warn_external("This figure includes Axes that are not " - "compatible with tight_layout, so results " - "might be incorrect.") - renderer = get_renderer(self) - with getattr(renderer, "_draw_disabled", nullcontext)(): - kwargs = get_tight_layout_figure( - self, self.axes, subplotspec_list, renderer, - pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) - if kwargs: - self.subplots_adjust(**kwargs) + # note that here we do not permanently set the figures engine to + # tight_layout but rather just perform the layout in place and remove + # any previous engines. + engine = TightLayoutEngine(pad=pad, h_pad=h_pad, w_pad=w_pad, + rect=rect) + try: + previous_engine = self.get_layout_engine() + self.set_layout_engine(engine) + engine.execute(self) + if not isinstance(previous_engine, TightLayoutEngine) \ + and previous_engine is not None: + _api.warn_external('The figure layout has changed to tight') + finally: + self.set_layout_engine(None) def figaspect(arg): diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 9d45575eb13d..8a4b52e96f32 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1,7 +1,7 @@ """ A module for finding, managing, and using fonts across platforms. -This module provides a single `FontManager` instance that can +This module provides a single `FontManager` instance, ``fontManager``, that can be shared across backends and platforms. The `findfont` function returns the best TrueType (TTF) font file in the local or system font path that matches the specified `FontProperties` @@ -23,8 +23,11 @@ # - setWeights function needs improvement # - 'light' is an invalid weight value, remove it. +from base64 import b64encode +import copy import dataclasses from functools import lru_cache +from io import BytesIO import json import logging from numbers import Number @@ -33,16 +36,11 @@ import re import subprocess import sys -try: - import threading - from threading import Timer -except ImportError: - import dummy_threading as threading - from dummy_threading import Timer +import threading import matplotlib as mpl -from matplotlib import _api, afm, cbook, ft2font, rcParams -from matplotlib.fontconfig_pattern import ( +from matplotlib import _api, _afm, cbook, ft2font +from matplotlib._fontconfig_pattern import ( parse_fontconfig_pattern, generate_fontconfig_pattern) from matplotlib.rcsetup import _validators @@ -169,14 +167,9 @@ ] -@lru_cache(64) -def _cached_realpath(path): - return os.path.realpath(path) - - def get_fontext_synonyms(fontext): """ - Return a list of file extensions extensions that are synonyms for + Return a list of file extensions that are synonyms for the given file extension *fileext*. """ return { @@ -217,82 +210,6 @@ def win32FontDirectory(): return os.path.join(os.environ['WINDIR'], 'Fonts') -def _win32RegistryFonts(reg_domain, base_dir): - r""" - Search for fonts in the Windows registry. - - Parameters - ---------- - reg_domain : int - The top level registry domain (e.g. HKEY_LOCAL_MACHINE). - - base_dir : str - The path to the folder where the font files are usually located (e.g. - C:\Windows\Fonts). If only the filename of the font is stored in the - registry, the absolute path is built relative to this base directory. - - Returns - ------- - `set` - `pathlib.Path` objects with the absolute path to the font files found. - - """ - import winreg - items = set() - - for reg_path in MSFontDirectories: - try: - with winreg.OpenKey(reg_domain, reg_path) as local: - for j in range(winreg.QueryInfoKey(local)[1]): - # value may contain the filename of the font or its - # absolute path. - key, value, tp = winreg.EnumValue(local, j) - if not isinstance(value, str): - continue - try: - # If value contains already an absolute path, then it - # is not changed further. - path = Path(base_dir, value).resolve() - except RuntimeError: - # Don't fail with invalid entries. - continue - - items.add(path) - except (OSError, MemoryError): - continue - - return items - - -# Also remove _win32RegistryFonts when this is removed. -@_api.deprecated("3.5") -def win32InstalledFonts(directory=None, fontext='ttf'): - """ - Search for fonts in the specified font directory, or use the - system directories if none given. Additionally, it is searched for user - fonts installed. A list of TrueType font filenames are returned by default, - or AFM fonts if *fontext* == 'afm'. - """ - import winreg - - if directory is None: - directory = win32FontDirectory() - - fontext = ['.' + ext for ext in get_fontext_synonyms(fontext)] - - items = set() - - # System fonts - items.update(_win32RegistryFonts(winreg.HKEY_LOCAL_MACHINE, directory)) - - # User fonts - for userdir in MSUserFontDirectories: - items.update(_win32RegistryFonts(winreg.HKEY_CURRENT_USER, userdir)) - - # Keep only paths with matching file extension. - return [str(path) for path in items if path.suffix.lower() in fontext] - - def _get_win32_installed_fonts(): """List the font paths known to the Windows registry.""" import winreg @@ -327,7 +244,7 @@ def _get_win32_installed_fonts(): @lru_cache() def _get_fontconfig_fonts(): - """Cache and list the font paths known to `fc-list`.""" + """Cache and list the font paths known to ``fc-list``.""" try: if b'--format' not in subprocess.check_output(['fc-list', '--help']): _log.warning( # fontconfig 2.7 implemented --format. @@ -339,14 +256,6 @@ def _get_fontconfig_fonts(): return [Path(os.fsdecode(fname)) for fname in out.split(b'\n')] -@_api.deprecated("3.5") -def get_fontconfig_fonts(fontext='ttf'): - """List font filenames known to `fc-list` having the given extension.""" - fontext = ['.' + ext for ext in get_fontext_synonyms(fontext)] - return [str(path) for path in _get_fontconfig_fonts() - if path.suffix.lower() in fontext] - - def findSystemFonts(fontpaths=None, fontext='ttf'): """ Search for fonts in the specified font paths. If no paths are @@ -361,7 +270,7 @@ def findSystemFonts(fontpaths=None, fontext='ttf'): if fontpaths is None: if sys.platform == 'win32': installed_fonts = _get_win32_installed_fonts() - fontpaths = MSUserFontDirectories + [win32FontDirectory()] + fontpaths = [] else: installed_fonts = _get_fontconfig_fonts() if sys.platform == 'darwin': @@ -380,6 +289,22 @@ def findSystemFonts(fontpaths=None, fontext='ttf'): return [fname for fname in fontfiles if os.path.exists(fname)] +def _fontentry_helper_repr_png(fontent): + from matplotlib.figure import Figure # Circular import. + fig = Figure() + font_path = Path(fontent.fname) if fontent.fname != '' else None + fig.text(0, 0, fontent.name, font=font_path) + with BytesIO() as buf: + fig.savefig(buf, bbox_inches='tight', transparent=True) + return buf.getvalue() + + +def _fontentry_helper_repr_html(fontent): + png_stream = _fontentry_helper_repr_png(fontent) + png_b64 = b64encode(png_stream).decode() + return f"" + + FontEntry = dataclasses.make_dataclass( 'FontEntry', [ ('fname', str, dataclasses.field(default='')), @@ -395,7 +320,11 @@ def findSystemFonts(fontpaths=None, fontext='ttf'): A class for storing Font properties. It is used when populating the font lookup dictionary. - """}) + """, + '_repr_html_': lambda self: _fontentry_helper_repr_html(self), + '_repr_png_': lambda self: _fontentry_helper_repr_png(self), + } +) def ttfFontProperty(font): @@ -531,7 +460,7 @@ def afmFontProperty(fontpath, font): Parameters ---------- - font : `.AFM` + font : AFM The AFM font file from which information will be extracted. Returns @@ -603,33 +532,34 @@ class FontProperties: specification and *math_fontfamily* for math fonts: - family: A list of font names in decreasing order of priority. - The items may include a generic font family name, either - 'sans-serif' (default), 'serif', 'cursive', 'fantasy', or 'monospace'. - In that case, the actual font to be used will be looked up - from the associated rcParam. + The items may include a generic font family name, either 'sans-serif', + 'serif', 'cursive', 'fantasy', or 'monospace'. In that case, the actual + font to be used will be looked up from the associated rcParam during the + search process in `.findfont`. Default: :rc:`font.family` - - style: Either 'normal' (default), 'italic' or 'oblique'. + - style: Either 'normal', 'italic' or 'oblique'. + Default: :rc:`font.style` - - variant: Either 'normal' (default) or 'small-caps'. + - variant: Either 'normal' or 'small-caps'. + Default: :rc:`font.variant` - stretch: A numeric value in the range 0-1000 or one of 'ultra-condensed', 'extra-condensed', 'condensed', - 'semi-condensed', 'normal' (default), 'semi-expanded', 'expanded', - 'extra-expanded' or 'ultra-expanded'. + 'semi-condensed', 'normal', 'semi-expanded', 'expanded', + 'extra-expanded' or 'ultra-expanded'. Default: :rc:`font.stretch` - weight: A numeric value in the range 0-1000 or one of - 'ultralight', 'light', 'normal' (default), 'regular', 'book', 'medium', + 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', - 'extra bold', 'black'. + 'extra bold', 'black'. Default: :rc:`font.weight` - - size: Either an relative value of 'xx-small', 'x-small', + - size: Either a relative value of 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large' or an - absolute font size, e.g., 10 (default). + absolute font size, e.g., 10. Default: :rc:`font.size` - - math_fontfamily: The family of fonts used to render math text; overrides - :rc:`mathtext.fontset`. Supported values are the same as the ones - supported by :rc:`mathtext.fontset`: 'dejavusans', 'dejavuserif', 'cm', - 'stix', 'stixsans' and 'custom'. + - math_fontfamily: The family of fonts used to render math text. + Supported values are: 'dejavusans', 'dejavuserif', 'cm', + 'stix', 'stixsans' and 'custom'. Default: :rc:`mathtext.fontset` Alternatively, a font may be specified using the absolute path to a font file, by using the *fname* kwarg. However, in this case, it is typically @@ -659,23 +589,6 @@ def __init__(self, family=None, style=None, variant=None, weight=None, stretch=None, size=None, fname=None, # if set, it's a hardcoded filename to use math_fontfamily=None): - self._family = _normalize_font_family(rcParams['font.family']) - self._slant = rcParams['font.style'] - self._variant = rcParams['font.variant'] - self._weight = rcParams['font.weight'] - self._stretch = rcParams['font.stretch'] - self._size = rcParams['font.size'] - self._file = None - self.set_math_fontfamily(math_fontfamily) - - if isinstance(family, str): - # Treat family as a fontconfig pattern if it is the only - # parameter provided. - if (style is None and variant is None and weight is None and - stretch is None and size is None and fname is None): - self.set_fontconfig_pattern(family) - return - self.set_family(family) self.set_style(style) self.set_variant(variant) @@ -683,6 +596,14 @@ def __init__(self, family=None, style=None, variant=None, weight=None, self.set_stretch(stretch) self.set_file(fname) self.set_size(size) + self.set_math_fontfamily(math_fontfamily) + # Treat family as a fontconfig pattern if it is the only parameter + # provided. Even in that case, call the other setters first to set + # attributes not specified by the pattern to the rcParams defaults. + if (isinstance(family, str) + and style is None and variant is None and weight is None + and stretch is None and size is None and fname is None): + self.set_fontconfig_pattern(family) @classmethod def _from_any(cls, arg): @@ -713,7 +634,7 @@ def __hash__(self): self.get_variant(), self.get_weight(), self.get_stretch(), - self.get_size_in_points(), + self.get_size(), self.get_file(), self.get_math_fontfamily()) return hash(l) @@ -726,7 +647,11 @@ def __str__(self): def get_family(self): """ - Return a list of font names that comprise the font family. + Return a list of individual font family names or generic family names. + + The font families or generic font families (which will be resolved + from their respective rcParams when searching for a matching font) in + the order of preference. """ return self._family @@ -741,7 +666,6 @@ def get_style(self): Return the font style. Values are: 'normal', 'italic' or 'oblique'. """ return self._slant - get_slant = get_style def get_variant(self): """ @@ -772,9 +696,6 @@ def get_size(self): """ return self._size - def get_size_in_points(self): - return self._size - def get_file(self): """ Return the filename of the associated font. @@ -793,80 +714,109 @@ def get_fontconfig_pattern(self): def set_family(self, family): """ - Change the font family. May be either an alias (generic name + Change the font family. Can be either an alias (generic name is CSS parlance), such as: 'serif', 'sans-serif', 'cursive', 'fantasy', or 'monospace', a real font name or a list of real font names. Real font names are not supported when - :rc:`text.usetex` is `True`. + :rc:`text.usetex` is `True`. Default: :rc:`font.family` """ if family is None: - family = rcParams['font.family'] - self._family = _normalize_font_family(family) - set_name = set_family + family = mpl.rcParams['font.family'] + if isinstance(family, str): + family = [family] + self._family = family def set_style(self, style): """ - Set the font style. Values are: 'normal', 'italic' or 'oblique'. + Set the font style. + + Parameters + ---------- + style : {'normal', 'italic', 'oblique'}, default: :rc:`font.style` """ if style is None: - style = rcParams['font.style'] + style = mpl.rcParams['font.style'] _api.check_in_list(['normal', 'italic', 'oblique'], style=style) self._slant = style - set_slant = set_style def set_variant(self, variant): """ - Set the font variant. Values are: 'normal' or 'small-caps'. + Set the font variant. + + Parameters + ---------- + variant : {'normal', 'small-caps'}, default: :rc:`font.variant` """ if variant is None: - variant = rcParams['font.variant'] + variant = mpl.rcParams['font.variant'] _api.check_in_list(['normal', 'small-caps'], variant=variant) self._variant = variant def set_weight(self, weight): """ - Set the font weight. May be either a numeric value in the - range 0-1000 or one of 'ultralight', 'light', 'normal', - 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', - 'demi', 'bold', 'heavy', 'extra bold', 'black' + Set the font weight. + + Parameters + ---------- + weight : int or {'ultralight', 'light', 'normal', 'regular', 'book', \ +'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', \ +'extra bold', 'black'}, default: :rc:`font.weight` + If int, must be in the range 0-1000. """ if weight is None: - weight = rcParams['font.weight'] + weight = mpl.rcParams['font.weight'] + if weight in weight_dict: + self._weight = weight + return try: weight = int(weight) - if weight < 0 or weight > 1000: - raise ValueError() except ValueError: - if weight not in weight_dict: - raise ValueError("weight is invalid") - self._weight = weight + pass + else: + if 0 <= weight <= 1000: + self._weight = weight + return + raise ValueError(f"{weight=} is invalid") def set_stretch(self, stretch): """ - Set the font stretch or width. Options are: 'ultra-condensed', - 'extra-condensed', 'condensed', 'semi-condensed', 'normal', - 'semi-expanded', 'expanded', 'extra-expanded' or - 'ultra-expanded', or a numeric value in the range 0-1000. + Set the font stretch or width. + + Parameters + ---------- + stretch : int or {'ultra-condensed', 'extra-condensed', 'condensed', \ +'semi-condensed', 'normal', 'semi-expanded', 'expanded', 'extra-expanded', \ +'ultra-expanded'}, default: :rc:`font.stretch` + If int, must be in the range 0-1000. """ if stretch is None: - stretch = rcParams['font.stretch'] + stretch = mpl.rcParams['font.stretch'] + if stretch in stretch_dict: + self._stretch = stretch + return try: stretch = int(stretch) - if stretch < 0 or stretch > 1000: - raise ValueError() - except ValueError as err: - if stretch not in stretch_dict: - raise ValueError("stretch is invalid") from err - self._stretch = stretch + except ValueError: + pass + else: + if 0 <= stretch <= 1000: + self._stretch = stretch + return + raise ValueError(f"{stretch=} is invalid") def set_size(self, size): """ - Set the font size. Either an relative value of 'xx-small', - 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large' - or an absolute font size, e.g., 12. + Set the font size. + + Parameters + ---------- + size : float or {'xx-small', 'x-small', 'small', 'medium', \ +'large', 'x-large', 'xx-large'}, default: :rc:`font.size` + If a float, the font size in points. The string values denote + sizes relative to the default font size. """ if size is None: - size = rcParams['font.size'] + size = mpl.rcParams['font.size'] try: size = float(size) except ValueError: @@ -932,7 +882,7 @@ def set_math_fontfamily(self, fontfamily): .text.Text.get_math_fontfamily """ if fontfamily is None: - fontfamily = rcParams['mathtext.fontset'] + fontfamily = mpl.rcParams['mathtext.fontset'] else: valid_fonts = _validators['mathtext.fontset'].valid.values() # _check_in_list() Validates the parameter math_fontfamily as @@ -942,9 +892,13 @@ def set_math_fontfamily(self, fontfamily): def copy(self): """Return a copy of self.""" - new = type(self)() - vars(new).update(vars(self)) - return new + return copy.copy(self) + + # Aliases + set_name = set_family + get_slant = get_style + set_slant = set_style + get_size_in_points = get_size class _JSONEncoder(json.JSONEncoder): @@ -1015,16 +969,10 @@ def json_load(filename): -------- json_dump """ - with open(filename, 'r') as fh: + with open(filename) as fh: return json.load(fh, object_hook=_json_decode) -def _normalize_font_family(family): - if isinstance(family, str): - family = [family] - return family - - class FontManager: """ On import, the `FontManager` singleton instance creates a list of ttf and @@ -1034,7 +982,7 @@ class FontManager: font is returned. """ # Increment this version number whenever the font cache data - # format or behavior has changed and requires a existing font + # format or behavior has changed and requires an existing font # cache files to be rebuilt. __version__ = 330 @@ -1047,7 +995,7 @@ def __init__(self, size=None, weight='normal'): # Create list of font paths. paths = [cbook._get_data_path('fonts', subdir) for subdir in ['ttf', 'afm', 'pdfcorefonts']] - _log.debug('font search path %s', str(paths)) + _log.debug('font search path %s', paths) self.defaultFamily = { 'ttf': 'DejaVu Sans', @@ -1057,7 +1005,7 @@ def __init__(self, size=None, weight='normal'): self.ttflist = [] # Delay the warning by 5s. - timer = Timer(5, lambda: _log.warning( + timer = threading.Timer(5, lambda: _log.warning( 'Matplotlib is building the font cache; this may take a moment.')) timer.start() try: @@ -1083,15 +1031,19 @@ def addfont(self, path): ---------- path : str or path-like """ + # Convert to string in case of a path as + # afmFontProperty and FT2Font expect this + path = os.fsdecode(path) if Path(path).suffix.lower() == ".afm": with open(path, "rb") as fh: - font = afm.AFM(fh) + font = _afm.AFM(fh) prop = afmFontProperty(path, font) self.afmlist.append(prop) else: font = ft2font.FT2Font(path) prop = ttfFontProperty(font) self.ttflist.append(prop) + self._findfont_cached.cache_clear() @property def defaultFont(self): @@ -1111,7 +1063,7 @@ def get_default_size(): """ Return the default font size. """ - return rcParams['font.size'] + return mpl.rcParams['font.size'] def set_default_weight(self, weight): """ @@ -1123,7 +1075,7 @@ def set_default_weight(self, weight): def _expand_aliases(family): if family in ('sans', 'sans serif'): family = 'sans-serif' - return rcParams['font.' + family] + return mpl.rcParams['font.' + family] # Each of the scoring functions below should return a value between # 0.0 (perfect match) and 1.0 (terrible match) @@ -1267,7 +1219,7 @@ def findfont(self, prop, fontext='ttf', directory=None, If given, only search this directory and its subdirectories. fallback_to_default : bool - If True, will fallback to the default font family (usually + If True, will fall back to the default font family (usually "DejaVu Sans" or "Helvetica") if the first lookup hard-fails. rebuild_if_missing : bool @@ -1301,14 +1253,111 @@ def findfont(self, prop, fontext='ttf', directory=None, # Pass the relevant rcParams (and the font manager, as `self`) to # _findfont_cached so to prevent using a stale cache entry after an # rcParam was changed. - rc_params = tuple(tuple(rcParams[key]) for key in [ + rc_params = tuple(tuple(mpl.rcParams[key]) for key in [ "font.serif", "font.sans-serif", "font.cursive", "font.fantasy", "font.monospace"]) - return self._findfont_cached( + ret = self._findfont_cached( prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params) + if isinstance(ret, Exception): + raise ret + return ret + + def get_font_names(self): + """Return the list of available fonts.""" + return list(set([font.name for font in self.ttflist])) + + def _find_fonts_by_props(self, prop, fontext='ttf', directory=None, + fallback_to_default=True, rebuild_if_missing=True): + """ + Find font families that most closely match the given properties. - @lru_cache() + Parameters + ---------- + prop : str or `~matplotlib.font_manager.FontProperties` + The font properties to search for. This can be either a + `.FontProperties` object or a string defining a + `fontconfig patterns`_. + + fontext : {'ttf', 'afm'}, default: 'ttf' + The extension of the font file: + + - 'ttf': TrueType and OpenType fonts (.ttf, .ttc, .otf) + - 'afm': Adobe Font Metrics (.afm) + + directory : str, optional + If given, only search this directory and its subdirectories. + + fallback_to_default : bool + If True, will fall back to the default font family (usually + "DejaVu Sans" or "Helvetica") if none of the families were found. + + rebuild_if_missing : bool + Whether to rebuild the font cache and search again if the first + match appears to point to a nonexisting font (i.e., the font cache + contains outdated entries). + + Returns + ------- + list[str] + The paths of the fonts found + + Notes + ----- + This is an extension/wrapper of the original findfont API, which only + returns a single font for given font properties. Instead, this API + returns a dict containing multiple fonts and their filepaths + which closely match the given font properties. Since this internally + uses the original API, there's no change to the logic of performing the + nearest neighbor search. See `findfont` for more details. + """ + + prop = FontProperties._from_any(prop) + + fpaths = [] + for family in prop.get_family(): + cprop = prop.copy() + cprop.set_family(family) # set current prop's family + + try: + fpaths.append( + self.findfont( + cprop, fontext, directory, + fallback_to_default=False, # don't fallback to default + rebuild_if_missing=rebuild_if_missing, + ) + ) + except ValueError: + if family in font_family_aliases: + _log.warning( + "findfont: Generic family %r not found because " + "none of the following families were found: %s", + family, ", ".join(self._expand_aliases(family)) + ) + else: + _log.warning("findfont: Font family %r not found.", family) + + # only add default family if no other font was found and + # fallback_to_default is enabled + if not fpaths: + if fallback_to_default: + dfamily = self.defaultFamily[fontext] + cprop = prop.copy() + cprop.set_family(dfamily) + fpaths.append( + self.findfont( + cprop, fontext, directory, + fallback_to_default=True, + rebuild_if_missing=rebuild_if_missing, + ) + ) + else: + raise ValueError("Failed to find any font, and fallback " + "to the default font was disabled") + + return fpaths + + @lru_cache(1024) def _findfont_cached(self, prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params): @@ -1361,8 +1410,11 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default, return self.findfont(default_prop, fontext, directory, fallback_to_default=False) else: - raise ValueError(f"Failed to find font {prop}, and fallback " - f"to the default font was disabled") + # This return instead of raise is intentional, as we wish to + # cache the resulting exception, which will not occur if it was + # actually raised. + return ValueError(f"Failed to find font {prop}, and fallback " + f"to the default font was disabled") else: _log.debug('findfont: Matching %s to %s (%r) with score of %f.', prop, best_font.name, best_font.fname, best_score) @@ -1381,7 +1433,10 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default, return self.findfont( prop, fontext, directory, rebuild_if_missing=False) else: - raise ValueError("No valid font could be found") + # This return instead of raise is intentional, as we wish to + # cache the resulting exception, which will not occur if it was + # actually raised. + return ValueError("No valid font could be found") return _cached_realpath(result) @@ -1401,29 +1456,75 @@ def is_opentype_cff_font(filename): @lru_cache(64) -def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id): +def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id): + first_fontpath, *rest = font_filepaths return ft2font.FT2Font( - filename, hinting_factor, _kerning_factor=_kerning_factor) + first_fontpath, hinting_factor, + _fallback_list=[ + ft2font.FT2Font( + fpath, hinting_factor, + _kerning_factor=_kerning_factor + ) + for fpath in rest + ], + _kerning_factor=_kerning_factor + ) # FT2Font objects cannot be used across fork()s because they reference the same # FT_Library object. While invalidating *all* existing FT2Fonts after a fork # would be too complicated to be worth it, the main way FT2Fonts get reused is -# via the cache of _get_font, which we can empty upon forking (in Py3.7+). +# via the cache of _get_font, which we can empty upon forking (not on Windows, +# which has no fork() or register_at_fork()). if hasattr(os, "register_at_fork"): os.register_at_fork(after_in_child=_get_font.cache_clear) -def get_font(filename, hinting_factor=None): +@lru_cache(64) +def _cached_realpath(path): # Resolving the path avoids embedding the font twice in pdf/ps output if a # single font is selected using two different relative paths. - filename = _cached_realpath(filename) + return os.path.realpath(path) + + +@_api.rename_parameter('3.6', "filepath", "font_filepaths") +def get_font(font_filepaths, hinting_factor=None): + """ + Get an `.ft2font.FT2Font` object given a list of file paths. + + Parameters + ---------- + font_filepaths : Iterable[str, Path, bytes], str, Path, bytes + Relative or absolute paths to the font files to be used. + + If a single string, bytes, or `pathlib.Path`, then it will be treated + as a list with that entry only. + + If more than one filepath is passed, then the returned FT2Font object + will fall back through the fonts, in the order given, to find a needed + glyph. + + Returns + ------- + `.ft2font.FT2Font` + + """ + if isinstance(font_filepaths, (str, Path, bytes)): + paths = (_cached_realpath(font_filepaths),) + else: + paths = tuple(_cached_realpath(fname) for fname in font_filepaths) + if hinting_factor is None: - hinting_factor = rcParams['text.hinting_factor'] - # also key on the thread ID to prevent segfaults with multi-threading - return _get_font(filename, hinting_factor, - _kerning_factor=rcParams['text.kerning_factor'], - thread_id=threading.get_ident()) + hinting_factor = mpl.rcParams['text.hinting_factor'] + + return _get_font( + # must be a tuple to be cached + paths, + hinting_factor, + _kerning_factor=mpl.rcParams['text.kerning_factor'], + # also key on the thread ID to prevent segfaults with multi-threading + thread_id=threading.get_ident() + ) def _load_fontmanager(*, try_read_cache=True): @@ -1446,3 +1547,4 @@ def _load_fontmanager(*, try_read_cache=True): fontManager = _load_fontmanager() findfont = fontManager.findfont +get_font_names = fontManager.get_font_names diff --git a/lib/matplotlib/fontconfig_pattern.py b/lib/matplotlib/fontconfig_pattern.py index c47e19bf99dc..292435b1487a 100644 --- a/lib/matplotlib/fontconfig_pattern.py +++ b/lib/matplotlib/fontconfig_pattern.py @@ -1,209 +1,20 @@ -""" -A module for parsing and generating `fontconfig patterns`_. - -.. _fontconfig patterns: - https://www.freedesktop.org/software/fontconfig/fontconfig-user.html -""" - -# This class logically belongs in `matplotlib.font_manager`, but placing it -# there would have created cyclical dependency problems, because it also needs -# to be available from `matplotlib.rcsetup` (for parsing matplotlibrc files). - -from functools import lru_cache import re -import numpy as np -from pyparsing import (Literal, ZeroOrMore, Optional, Regex, StringEnd, - ParseException, Suppress) - -family_punc = r'\\\-:,' -family_unescape = re.compile(r'\\([%s])' % family_punc).sub -family_escape = re.compile(r'([%s])' % family_punc).sub - -value_punc = r'\\=_:,' -value_unescape = re.compile(r'\\([%s])' % value_punc).sub -value_escape = re.compile(r'([%s])' % value_punc).sub - - -class FontconfigPatternParser: - """ - A simple pyparsing-based parser for `fontconfig patterns`_. - - .. _fontconfig patterns: - https://www.freedesktop.org/software/fontconfig/fontconfig-user.html - """ - - _constants = { - 'thin': ('weight', 'light'), - 'extralight': ('weight', 'light'), - 'ultralight': ('weight', 'light'), - 'light': ('weight', 'light'), - 'book': ('weight', 'book'), - 'regular': ('weight', 'regular'), - 'normal': ('weight', 'normal'), - 'medium': ('weight', 'medium'), - 'demibold': ('weight', 'demibold'), - 'semibold': ('weight', 'semibold'), - 'bold': ('weight', 'bold'), - 'extrabold': ('weight', 'extra bold'), - 'black': ('weight', 'black'), - 'heavy': ('weight', 'heavy'), - 'roman': ('slant', 'normal'), - 'italic': ('slant', 'italic'), - 'oblique': ('slant', 'oblique'), - 'ultracondensed': ('width', 'ultra-condensed'), - 'extracondensed': ('width', 'extra-condensed'), - 'condensed': ('width', 'condensed'), - 'semicondensed': ('width', 'semi-condensed'), - 'expanded': ('width', 'expanded'), - 'extraexpanded': ('width', 'extra-expanded'), - 'ultraexpanded': ('width', 'ultra-expanded') - } - - def __init__(self): - - family = Regex( - r'([^%s]|(\\[%s]))*' % (family_punc, family_punc) - ).setParseAction(self._family) - - size = Regex( - r"([0-9]+\.?[0-9]*|\.[0-9]+)" - ).setParseAction(self._size) - - name = Regex( - r'[a-z]+' - ).setParseAction(self._name) - - value = Regex( - r'([^%s]|(\\[%s]))*' % (value_punc, value_punc) - ).setParseAction(self._value) - - families = ( - family - + ZeroOrMore( - Literal(',') - + family) - ).setParseAction(self._families) - - point_sizes = ( - size - + ZeroOrMore( - Literal(',') - + size) - ).setParseAction(self._point_sizes) - - property = ( - (name - + Suppress(Literal('=')) - + value - + ZeroOrMore( - Suppress(Literal(',')) - + value)) - | name - ).setParseAction(self._property) +from pyparsing import ParseException - pattern = ( - Optional( - families) - + Optional( - Literal('-') - + point_sizes) - + ZeroOrMore( - Literal(':') - + property) - + StringEnd() - ) +from matplotlib._fontconfig_pattern import * # noqa: F401, F403 +from matplotlib._fontconfig_pattern import ( + parse_fontconfig_pattern, _family_punc, _value_punc) +from matplotlib import _api +_api.warn_deprecated("3.6", name=__name__, obj_type="module") - self._parser = pattern - self.ParseException = ParseException - def parse(self, pattern): - """ - Parse the given fontconfig *pattern* and return a dictionary - of key/value pairs useful for initializing a - `.font_manager.FontProperties` object. - """ - props = self._properties = {} - try: - self._parser.parseString(pattern) - except self.ParseException as e: - raise ValueError( - "Could not parse font string: '%s'\n%s" % (pattern, e)) from e +family_unescape = re.compile(r'\\([%s])' % _family_punc).sub +value_unescape = re.compile(r'\\([%s])' % _value_punc).sub +family_escape = re.compile(r'([%s])' % _family_punc).sub +value_escape = re.compile(r'([%s])' % _value_punc).sub - self._properties = None - self._parser.resetCache() - - return props - - def _family(self, s, loc, tokens): - return [family_unescape(r'\1', str(tokens[0]))] - - def _size(self, s, loc, tokens): - return [float(tokens[0])] - - def _name(self, s, loc, tokens): - return [str(tokens[0])] - - def _value(self, s, loc, tokens): - return [value_unescape(r'\1', str(tokens[0]))] - - def _families(self, s, loc, tokens): - self._properties['family'] = [str(x) for x in tokens] - return [] - - def _point_sizes(self, s, loc, tokens): - self._properties['size'] = [str(x) for x in tokens] - return [] - - def _property(self, s, loc, tokens): - if len(tokens) == 1: - if tokens[0] in self._constants: - key, val = self._constants[tokens[0]] - self._properties.setdefault(key, []).append(val) - else: - key = tokens[0] - val = tokens[1:] - self._properties.setdefault(key, []).extend(val) - return [] - - -# `parse_fontconfig_pattern` is a bottleneck during the tests because it is -# repeatedly called when the rcParams are reset (to validate the default -# fonts). In practice, the cache size doesn't grow beyond a few dozen entries -# during the test suite. -parse_fontconfig_pattern = lru_cache()(FontconfigPatternParser().parse) - - -def _escape_val(val, escape_func): - """ - Given a string value or a list of string values, run each value through - the input escape function to make the values into legal font config - strings. The result is returned as a string. - """ - if not np.iterable(val) or isinstance(val, str): - val = [val] - - return ','.join(escape_func(r'\\\1', str(x)) for x in val - if x is not None) - - -def generate_fontconfig_pattern(d): - """ - Given a dictionary of key/value pairs, generates a fontconfig - pattern string. - """ - props = [] - - # Family is added first w/o a keyword - family = d.get_family() - if family is not None and family != []: - props.append(_escape_val(family, family_escape)) - - # The other keys are added as key=value - for key in ['style', 'variant', 'weight', 'stretch', 'file', 'size']: - val = getattr(d, 'get_' + key)() - # Don't use 'if not val' because 0 is a valid input. - if val is not None and val != []: - props.append(":%s=%s" % (key, _escape_val(val, value_escape))) +class FontconfigPatternParser: + ParseException = ParseException - return ''.join(props) + def parse(self, pattern): return parse_fontconfig_pattern(pattern) diff --git a/lib/matplotlib/gridspec.py b/lib/matplotlib/gridspec.py index c80783164886..e3abb4425aba 100644 --- a/lib/matplotlib/gridspec.py +++ b/lib/matplotlib/gridspec.py @@ -18,7 +18,7 @@ import numpy as np import matplotlib as mpl -from matplotlib import _api, _pylab_helpers, tight_layout, rcParams +from matplotlib import _api, _pylab_helpers, _tight_layout from matplotlib.transforms import Bbox _log = logging.getLogger(__name__) @@ -142,6 +142,7 @@ def get_height_ratios(self): """ return self._row_height_ratios + @_api.delete_parameter("3.7", "raw") def get_grid_positions(self, fig, raw=False): """ Return the positions of the grid cells in figure coordinates. @@ -210,8 +211,8 @@ def _check_gridspec_exists(figure, nrows, ncols): or create a new one """ for ax in figure.get_axes(): - if hasattr(ax, 'get_subplotspec'): - gs = ax.get_subplotspec().get_gridspec() + gs = ax.get_gridspec() + if gs is not None: if hasattr(gs, 'get_topmost_subplotspec'): # This is needed for colorbar gridspec layouts. # This is probably OK because this whole logic tree @@ -275,21 +276,12 @@ def subplots(self, *, sharex=False, sharey=False, squeeze=True, raise ValueError("GridSpec.subplots() only works for GridSpecs " "created with a parent figure") - if isinstance(sharex, bool): + if not isinstance(sharex, str): sharex = "all" if sharex else "none" - if isinstance(sharey, bool): + if not isinstance(sharey, str): sharey = "all" if sharey else "none" - # This check was added because it is very easy to type - # `subplots(1, 2, 1)` when `subplot(1, 2, 1)` was intended. - # In most cases, no error will ever occur, but mysterious behavior - # will result because what was intended to be the subplot index is - # instead treated as a bool for sharex. This check should go away - # once sharex becomes kwonly. - if isinstance(sharex, Integral): - _api.warn_external( - "sharex argument to subplots() was an integer. Did you " - "intend to use subplot() (without 's')?") - _api.check_in_list(["all", "row", "col", "none"], + + _api.check_in_list(["all", "row", "col", "none", False, True], sharex=sharex, sharey=sharey) if subplot_kw is None: subplot_kw = {} @@ -331,6 +323,8 @@ class GridSpec(GridSpecBase): The location of the grid cells is determined in a similar way to `~.figure.SubplotParams` using *left*, *right*, *top*, *bottom*, *wspace* and *hspace*. + + Indexing a GridSpec instance returns a `.SubplotSpec`. """ def __init__(self, nrows, ncols, figure=None, left=None, bottom=None, right=None, top=None, @@ -342,7 +336,7 @@ def __init__(self, nrows, ncols, figure=None, nrows, ncols : int The number of rows and columns of the grid. - figure : `~.figure.Figure`, optional + figure : `.Figure`, optional Only used for constrained layout to create a proper layoutgrid. left, right, top, bottom : float, optional @@ -410,7 +404,7 @@ def update(self, **kwargs): raise AttributeError(f"{k} is an unknown keyword") for figmanager in _pylab_helpers.Gcf.figs.values(): for ax in figmanager.canvas.figure.axes: - if isinstance(ax, mpl.axes.SubplotBase): + if ax.get_subplotspec() is not None: ss = ax.get_subplotspec().get_topmost_subplotspec() if ss.get_gridspec() == self: ax._set_position( @@ -427,7 +421,8 @@ def get_subplot_params(self, figure=None): - :rc:`figure.subplot.*` """ if figure is None: - kw = {k: rcParams["figure.subplot."+k] for k in self._AllowedKeys} + kw = {k: mpl.rcParams["figure.subplot."+k] + for k in self._AllowedKeys} subplotpars = mpl.figure.SubplotParams(**kw) else: subplotpars = copy.copy(figure.subplotpars) @@ -458,25 +453,17 @@ def tight_layout(self, figure, renderer=None, h_pad, w_pad : float, optional Padding (height/width) between edges of adjacent subplots. Defaults to *pad*. - rect : tuple of 4 floats, default: (0, 0, 1, 1), i.e. the whole figure + rect : tuple (left, bottom, right, top), default: None (left, bottom, right, top) rectangle in normalized figure coordinates that the whole subplots area (including labels) will - fit into. + fit into. Default (None) is the whole figure. """ - - subplotspec_list = tight_layout.get_subplotspec_list( - figure.axes, grid_spec=self) - if None in subplotspec_list: - _api.warn_external("This figure includes Axes that are not " - "compatible with tight_layout, so results " - "might be incorrect.") - if renderer is None: - renderer = tight_layout.get_renderer(figure) - - kwargs = tight_layout.get_tight_layout_figure( - figure, figure.axes, subplotspec_list, renderer, - pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) + renderer = figure._get_renderer() + kwargs = _tight_layout.get_tight_layout_figure( + figure, figure.axes, + _tight_layout.get_subplotspec_list(figure.axes, grid_spec=self), + renderer, pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) if kwargs: self.update(**kwargs) @@ -517,10 +504,10 @@ def get_subplot_params(self, figure=None): """Return a dictionary of subplot layout parameters.""" hspace = (self._hspace if self._hspace is not None else figure.subplotpars.hspace if figure is not None - else rcParams["figure.subplot.hspace"]) + else mpl.rcParams["figure.subplot.hspace"]) wspace = (self._wspace if self._wspace is not None else figure.subplotpars.wspace if figure is not None - else rcParams["figure.subplot.wspace"]) + else mpl.rcParams["figure.subplot.wspace"]) figbox = self._subplot_spec.get_position(figure) left, bottom, right, top = figbox.extents @@ -542,17 +529,17 @@ class SubplotSpec: .. note:: - Likely, you'll never instantiate a `SubplotSpec` yourself. Instead you - will typically obtain one from a `GridSpec` using item-access. + Likely, you will never instantiate a `SubplotSpec` yourself. Instead, + you will typically obtain one from a `GridSpec` using item-access. Parameters ---------- gridspec : `~matplotlib.gridspec.GridSpec` The GridSpec, which the subplot is referencing. num1, num2 : int - The subplot will occupy the num1-th cell of the given - gridspec. If num2 is provided, the subplot will span between - num1-th cell and num2-th cell *inclusive*. + The subplot will occupy the *num1*-th cell of the given + *gridspec*. If *num2* is provided, the subplot will span between + *num1*-th cell and *num2*-th cell **inclusive**. The index starts from 0. """ @@ -591,8 +578,7 @@ def _from_subplot_args(figure, args): elif len(args) == 3: rows, cols, num = args else: - raise TypeError(f"subplot() takes 1 or 3 positional arguments but " - f"{len(args)} were given") + raise _api.nargs_error("subplot", takes="1 or 3", given=len(args)) gs = GridSpec._check_gridspec_exists(figure, rows, cols) if gs is None: @@ -606,7 +592,9 @@ def _from_subplot_args(figure, args): else: if not isinstance(num, Integral) or num < 1 or num > rows*cols: raise ValueError( - f"num must be 1 <= num <= {rows*cols}, not {num!r}") + f"num must be an integer with 1 <= num <= {rows*cols}, " + f"not {num!r}" + ) i = j = num return gs[i-1:j] @@ -621,9 +609,6 @@ def num2(self): def num2(self, value): self._num2 = value - def __getstate__(self): - return {**self.__dict__} - def get_gridspec(self): return self._gridspec @@ -665,8 +650,7 @@ def is_first_col(self): def is_last_col(self): return self.colspan.stop == self.get_gridspec().ncols - @_api.delete_parameter("3.4", "return_all") - def get_position(self, figure, return_all=False): + def get_position(self, figure): """ Update the subplot position from ``figure.subplotpars``. """ @@ -680,12 +664,7 @@ def get_position(self, figure, return_all=False): fig_top = fig_tops[rows].max() fig_left = fig_lefts[cols].min() fig_right = fig_rights[cols].max() - figbox = Bbox.from_extents(fig_left, fig_bottom, fig_right, fig_top) - - if return_all: - return figbox, rows[0], cols[0], nrows, ncols - else: - return figbox + return Bbox.from_extents(fig_left, fig_bottom, fig_right, fig_top) def get_topmost_subplotspec(self): """ @@ -724,7 +703,7 @@ def subgridspec(self, nrows, ncols, **kwargs): Number of rows in grid. ncols : int - Number or columns in grid. + Number of columns in grid. Returns ------- diff --git a/lib/matplotlib/hatch.py b/lib/matplotlib/hatch.py index 9c836cbf3cb9..396baa55dbbb 100644 --- a/lib/matplotlib/hatch.py +++ b/lib/matplotlib/hatch.py @@ -188,7 +188,7 @@ def _validate_hatch_pattern(hatch): invalids = ''.join(sorted(invalids)) _api.warn_deprecated( '3.4', - removal='3.7', # one release after custom hatches (#20690) + removal='3.8', # one release after custom hatches (#20690) message=f'hatch must consist of a string of "{valid}" or ' 'None, but found the following invalid values ' f'"{invalids}". Passing invalid values is deprecated ' diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index cdb8580c3627..ba495f8e3f1a 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -7,22 +7,21 @@ import os import logging from pathlib import Path +import warnings import numpy as np import PIL.PngImagePlugin import matplotlib as mpl -from matplotlib import _api +from matplotlib import _api, cbook, cm +# For clarity, names from _image are given explicitly in this module +from matplotlib import _image +# For user convenience, the names from _image are also imported into +# the image namespace +from matplotlib._image import * import matplotlib.artist as martist from matplotlib.backend_bases import FigureCanvasBase import matplotlib.colors as mcolors -import matplotlib.cm as cm -import matplotlib.cbook as cbook -# For clarity, names from _image are given explicitly in this module: -import matplotlib._image as _image -# For user convenience, the names from _image are also imported into -# the image namespace: -from matplotlib._image import * from matplotlib.transforms import ( Affine2D, BboxBase, Bbox, BboxTransform, BboxTransformTo, IdentityTransform, TransformedBbox) @@ -166,7 +165,22 @@ def _resample( allocating the output array and fetching the relevant properties from the Image object *image_obj*. """ - + # AGG can only handle coordinates smaller than 24-bit signed integers, + # so raise errors if the input data is larger than _image.resample can + # handle. + msg = ('Data with more than {n} cannot be accurately displayed. ' + 'Downsampling to less than {n} before displaying. ' + 'To remove this warning, manually downsample your data.') + if data.shape[1] > 2**23: + warnings.warn(msg.format(n='2**23 columns')) + step = int(np.ceil(data.shape[1] / 2**23)) + data = data[:, ::step] + transform = Affine2D().scale(step, 1) + transform + if data.shape[0] > 2**24: + warnings.warn(msg.format(n='2**24 rows')) + step = int(np.ceil(data.shape[0] / 2**24)) + data = data[::step, :] + transform = Affine2D().scale(1, step) + transform # decide if we need to apply anti-aliasing if the data is upsampled: # compare the number of displayed pixels to the number of # the data pixels. @@ -257,13 +271,18 @@ def __init__(self, ax, self._imcache = None - self.update(kwargs) + self._internal_update(kwargs) + + def __str__(self): + try: + size = self.get_size() + return f"{type(self).__name__}(size={size!r})" + except RuntimeError: + return type(self).__name__ def __getstate__(self): - state = super().__getstate__() - # We can't pickle the C Image cached object. - state['_imcache'] = None - return state + # Save some space on the pickle by not saving the cache. + return {**super().__getstate__(), "_imcache": None} def get_size(self): """Return the size of the image as tuple (numrows, numcols).""" @@ -304,7 +323,6 @@ def changed(self): Call this whenever the mappable is changed so observers can update. """ self._imcache = None - self._rgbacache = None cm.ScalarMappable.changed(self) def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, @@ -402,43 +420,34 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, # have to resample to the correct number of pixels # TODO slice input array first - inp_dtype = A.dtype a_min = A.min() a_max = A.max() - # figure out the type we should scale to. For floats, - # leave as is. For integers cast to an appropriate-sized - # float. Small integers get smaller floats in an attempt - # to keep the memory footprint reasonable. - if a_min is np.ma.masked: - # all masked, so values don't matter + if a_min is np.ma.masked: # All masked; values don't matter. a_min, a_max = np.int32(0), np.int32(1) - if inp_dtype.kind == 'f': + if A.dtype.kind == 'f': # Float dtype: scale to same dtype. scaled_dtype = np.dtype( np.float64 if A.dtype.itemsize > 4 else np.float32) if scaled_dtype.itemsize < A.dtype.itemsize: - _api.warn_external( - f"Casting input data from {A.dtype} to " - f"{scaled_dtype} for imshow") - else: - # probably an integer of some type. + _api.warn_external(f"Casting input data from {A.dtype}" + f" to {scaled_dtype} for imshow.") + else: # Int dtype, likely. + # Scale to appropriately sized float: use float32 if the + # dynamic range is small, to limit the memory footprint. da = a_max.astype(np.float64) - a_min.astype(np.float64) - # give more breathing room if a big dynamic range scaled_dtype = np.float64 if da > 1e8 else np.float32 - # scale the input data to [.1, .9]. The Agg - # interpolators clip to [0, 1] internally, use a - # smaller input scale to identify which of the - # interpolated points need to be should be flagged as - # over / under. - # This may introduce numeric instabilities in very broadly - # scaled data + # Scale the input data to [.1, .9]. The Agg interpolators clip + # to [0, 1] internally, and we use a smaller input scale to + # identify the interpolated points that need to be flagged as + # over/under. This may introduce numeric instabilities in very + # broadly scaled data. + # Always copy, and don't allow array subtypes. A_scaled = np.array(A, dtype=scaled_dtype) - # clip scaled data around norm if necessary. - # This is necessary for big numbers at the edge of - # float64's ability to represent changes. Applying - # a norm first would be good, but ruins the interpolation - # of over numbers. + # Clip scaled data around norm if necessary. This is necessary + # for big numbers at the edge of float64's ability to represent + # changes. Applying a norm first would be good, but ruins the + # interpolation of over numbers. self.norm.autoscale_None(A) dv = np.float64(self.norm.vmax) - np.float64(self.norm.vmin) vmid = np.float64(self.norm.vmin) + dv / 2 @@ -456,21 +465,17 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, if newmax is not None or newmin is not None: np.clip(A_scaled, newmin, newmax, out=A_scaled) - # used to rescale the raw data to [offset, 1-offset] - # so that the resampling code will run cleanly. Using - # dyadic numbers here could reduce the error, but - # would not full eliminate it and breaks a number of - # tests (due to the slightly different error bouncing - # some pixels across a boundary in the (very + # Rescale the raw data to [offset, 1-offset] so that the + # resampling code will run cleanly. Using dyadic numbers here + # could reduce the error, but would not fully eliminate it and + # breaks a number of tests (due to the slightly different + # error bouncing some pixels across a boundary in the (very # quantized) colormapping step). offset = .1 frac = .8 - # we need to run the vmin/vmax through the same rescaling - # that we run the raw data through because there are small - # errors in the round-trip due to float precision. If we - # do not run the vmin/vmax through the same pipeline we can - # have values close or equal to the boundaries end up on the - # wrong side. + # Run vmin/vmax through the same rescaling as the raw data; + # otherwise, data values close or equal to the boundaries can + # end up on the wrong side due to floating point error. vmin, vmax = self.norm.vmin, self.norm.vmax if vmin is np.ma.masked: vmin, vmax = a_min, a_max @@ -478,8 +483,7 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, A_scaled -= a_min vrange -= a_min - # a_min and a_max might be ndarray subclasses so use - # item to avoid errors + # .item() handles a_min/a_max being ndarray subclasses. a_min = a_min.astype(scaled_dtype).item() a_max = a_max.astype(scaled_dtype).item() @@ -490,13 +494,11 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, vrange += offset # resample the input data to the correct resolution and shape A_resampled = _resample(self, A_scaled, out_shape, t) - # done with A_scaled now, remove from namespace to be sure! - del A_scaled - # un-scale the resampled data to approximately the - # original range things that interpolated to above / - # below the original min/max will still be above / - # below, but possibly clipped in the case of higher order - # interpolation + drastically changing data. + del A_scaled # Make sure we don't use A_scaled anymore! + # Un-scale the resampled data to approximately the original + # range. Things that interpolated to outside the original range + # will still be outside, but possibly clipped in the case of + # higher order interpolation + drastically changing data. A_resampled -= offset vrange -= offset if a_min != a_max: @@ -514,8 +516,7 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, # we always have to interpolate the mask to account for # non-affine transformations out_alpha = _resample(self, mask, out_shape, t, resample=True) - # done with the mask now, delete from namespace to be sure! - del mask + del mask # Make sure we don't use mask anymore! # Agg updates out_alpha in place. If the pixel has no image # data it will not be updated (and still be 0 as we initialized # it), if input data that would go into that output pixel than @@ -539,12 +540,9 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, s_vmin = np.finfo(scaled_dtype).eps # Block the norm from sending an update signal during the # temporary vmin/vmax change - with self.norm.callbacks.blocked(): - with cbook._setattr_cm(self.norm, - vmin=s_vmin, - vmax=s_vmax, - ): - output = self.norm(resampled_masked) + with self.norm.callbacks.blocked(), \ + cbook._setattr_cm(self.norm, vmin=s_vmin, vmax=s_vmax): + output = self.norm(resampled_masked) else: if A.ndim == 2: # _interpolation_stage == 'rgba' self.norm.autoscale_None(A) @@ -558,8 +556,7 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, self, _rgb_to_rgba(A[..., :3]), out_shape, t, alpha=alpha) output[..., 3] = output_alpha # recombine rgb and alpha - # at this point output is either a 2D array of normed data - # (of int or float) + # output is now either a 2D array of normed (int or float) data # or an RGBA array of re-sampled input output = self.to_rgba(output, bytes=True, norm=False) # output is now a correctly sized RGBA array of uint8 @@ -568,17 +565,15 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, if A.ndim == 2: alpha = self._get_scalar_alpha() alpha_channel = output[:, :, 3] - alpha_channel[:] = np.asarray( - np.asarray(alpha_channel, np.float32) * out_alpha * alpha, - np.uint8) + alpha_channel[:] = ( # Assignment will cast to uint8. + alpha_channel.astype(np.float32) * out_alpha * alpha) else: if self._imcache is None: self._imcache = self.to_rgba(A, bytes=True, norm=(A.ndim == 2)) output = self._imcache - # Subset the input image to only the part that will be - # displayed + # Subset the input image to only the part that will be displayed. subset = TransformedBbox(clip_bbox, t0.inverted()).frozen() output = output[ int(max(subset.ymin, 0)): @@ -733,7 +728,6 @@ def set_data(self, A): self._A = self._A.astype(np.uint8) self._imcache = None - self._rgbacache = None self.stale = True def set_array(self, A): @@ -793,7 +787,7 @@ def set_interpolation_stage(self, s): """ if s is None: s = "data" # placeholder for maybe having rcParam - _api.check_in_list(['data', 'rgba']) + _api.check_in_list(['data', 'rgba'], s=s) self._interpolation_stage = s self.stale = True @@ -871,7 +865,7 @@ class AxesImage(_ImageBase): cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` The Colormap instance or registered colormap name used to map scalar data to colors. - norm : `~matplotlib.colors.Normalize` + norm : str or `~matplotlib.colors.Normalize` Maps luminance to 0-1. interpolation : str, default: :rc:`image.interpolation` Supported values are 'none', 'antialiased', 'nearest', 'bilinear', @@ -907,9 +901,8 @@ class AxesImage(_ImageBase): the output image is larger than the input image. **kwargs : `.Artist` properties """ - def __str__(self): - return "AxesImage(%g,%g;%gx%g)" % tuple(self.axes.bbox.bounds) + @_api.make_keyword_only("3.6", name="cmap") def __init__(self, ax, cmap=None, norm=None, @@ -942,7 +935,7 @@ def __init__(self, ax, def get_window_extent(self, renderer=None): x0, x1, y0, y1 = self._extent bbox = Bbox.from_extents([x0, y0, x1, y1]) - return bbox.transformed(self.axes.transData) + return bbox.transformed(self.get_transform()) def make_image(self, renderer, magnification=1.0, unsampled=False): # docstring inherited @@ -960,7 +953,7 @@ def _check_unsampled_image(self): """Return whether the image would be better drawn unsampled.""" return self.get_interpolation() == "none" - def set_extent(self, extent): + def set_extent(self, extent, **kwargs): """ Set the image extent. @@ -969,6 +962,10 @@ def set_extent(self, extent): extent : 4-tuple of float The position and size of the image as tuple ``(left, right, bottom, top)`` in data coordinates. + **kwargs + Other parameters from which unit info (i.e., the *xunits*, + *yunits*, *zunits* (for 3D axes), *runits* and *thetaunits* (for + polar axes) entries are applied, if present. Notes ----- @@ -977,14 +974,30 @@ def set_extent(self, extent): state is not changed, so following this with ``ax.autoscale_view()`` will redo the autoscaling in accord with ``dataLim``. """ - self._extent = xmin, xmax, ymin, ymax = extent + (xmin, xmax), (ymin, ymax) = self.axes._process_unit_info( + [("x", [extent[0], extent[1]]), + ("y", [extent[2], extent[3]])], + kwargs) + if kwargs: + raise _api.kwarg_error("set_extent", kwargs) + xmin = self.axes._validate_converted_limits( + xmin, self.convert_xunits) + xmax = self.axes._validate_converted_limits( + xmax, self.convert_xunits) + ymin = self.axes._validate_converted_limits( + ymin, self.convert_yunits) + ymax = self.axes._validate_converted_limits( + ymax, self.convert_yunits) + extent = [xmin, xmax, ymin, ymax] + + self._extent = extent corners = (xmin, ymin), (xmax, ymax) self.axes.update_datalim(corners) self.sticky_edges.x[:] = [xmin, xmax] self.sticky_edges.y[:] = [ymin, ymax] - if self.axes._autoscaleXon: + if self.axes.get_autoscalex_on(): self.axes.set_xlim((xmin, xmax), auto=None) - if self.axes._autoscaleYon: + if self.axes.get_autoscaley_on(): self.axes.set_ylim((ymin, ymax), auto=None) self.stale = True @@ -1127,8 +1140,8 @@ def set_data(self, x, y, A): Monotonic arrays of shapes (N,) and (M,), respectively, specifying pixel centers. A : array-like - (M, N) ndarray or masked array of values to be colormapped, or - (M, N, 3) RGB array, or (M, N, 4) RGBA array. + (M, N) `~numpy.ndarray` or masked array of values to be + colormapped, or (M, N, 3) RGB array, or (M, N, 4) RGBA array. """ x = np.array(x, np.float32) y = np.array(y, np.float32) @@ -1193,6 +1206,8 @@ class PcolorImage(AxesImage): This uses a variation of the original irregular image code, and it is used by pcolorfast for the corresponding grid type. """ + + @_api.make_keyword_only("3.6", name="cmap") def __init__(self, ax, x=None, y=None, @@ -1214,19 +1229,19 @@ def __init__(self, ax, The data to be color-coded. The interpretation depends on the shape: - - (M, N) ndarray or masked array: values to be colormapped + - (M, N) `~numpy.ndarray` or masked array: values to be colormapped - (M, N, 3): RGB array - (M, N, 4): RGBA array cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` The Colormap instance or registered colormap name used to map scalar data to colors. - norm : `~matplotlib.colors.Normalize` + norm : str or `~matplotlib.colors.Normalize` Maps luminance to 0-1. **kwargs : `.Artist` properties """ super().__init__(ax, norm=norm, cmap=cmap) - self.update(kwargs) + self._internal_update(kwargs) if A is not None: self.set_data(x, y, A) @@ -1237,10 +1252,10 @@ def make_image(self, renderer, magnification=1.0, unsampled=False): if unsampled: raise ValueError('unsampled not supported on PColorImage') - if self._rgbacache is None: + if self._imcache is None: A = self.to_rgba(self._A, bytes=True) - self._rgbacache = np.pad(A, [(1, 1), (1, 1), (0, 0)], "constant") - padded_A = self._rgbacache + self._imcache = np.pad(A, [(1, 1), (1, 1), (0, 0)], "constant") + padded_A = self._imcache bg = mcolors.to_rgba(self.axes.patch.get_facecolor(), 0) bg = (np.array(bg) * 255).astype(np.uint8) if (padded_A[0, 0] != bg).all(): @@ -1249,8 +1264,8 @@ def make_image(self, renderer, magnification=1.0, unsampled=False): l, b, r, t = self.axes.bbox.extents width = (round(r) + 0.5) - (round(l) - 0.5) height = (round(t) + 0.5) - (round(b) - 0.5) - width = int(round(width * magnification)) - height = int(round(height * magnification)) + width = round(width * magnification) + height = round(height * magnification) vl = self.axes.viewLim x_pix = np.linspace(vl.x0, vl.x1, width) @@ -1280,7 +1295,7 @@ def set_data(self, x, y, A): The data to be color-coded. The interpretation depends on the shape: - - (M, N) ndarray or masked array: values to be colormapped + - (M, N) `~numpy.ndarray` or masked array: values to be colormapped - (M, N, 3): RGB array - (M, N, 4): RGBA array """ @@ -1317,7 +1332,7 @@ def set_data(self, x, y, A): self._A = A self._Ax = x self._Ay = y - self._rgbacache = None + self._imcache = None self.stale = True def set_array(self, *args): @@ -1344,6 +1359,7 @@ class FigureImage(_ImageBase): _interpolation = 'nearest' + @_api.make_keyword_only("3.6", name="cmap") def __init__(self, fig, cmap=None, norm=None, @@ -1367,7 +1383,7 @@ def __init__(self, fig, self.figure = fig self.ox = offsetx self.oy = offsety - self.update(kwargs) + self._internal_update(kwargs) self.magnification = 1.0 def get_extent(self): @@ -1402,6 +1418,7 @@ def set_data(self, A): class BboxImage(_ImageBase): """The Image class whose size is determined by the given bbox.""" + @_api.make_keyword_only("3.6", name="cmap") def __init__(self, bbox, cmap=None, norm=None, @@ -1433,7 +1450,7 @@ def __init__(self, bbox, def get_window_extent(self, renderer=None): if renderer is None: - renderer = self.get_figure()._cachedRenderer + renderer = self.get_figure()._get_renderer() if isinstance(self.bbox, BboxBase): return self.bbox @@ -1490,7 +1507,7 @@ def imread(fname, format=None): format : str, optional The image file format assumed for reading the data. The image is loaded as a PNG file if *format* is set to "png", if *fname* is a path - or opened file with a ".png" extension, or if it is an URL. In all + or opened file with a ".png" extension, or if it is a URL. In all other cases, *format* is ignored and the format is auto-detected by `PIL.Image.open`. @@ -1534,29 +1551,13 @@ def imread(fname, format=None): ext = format img_open = ( PIL.PngImagePlugin.PngImageFile if ext == 'png' else PIL.Image.open) - if isinstance(fname, str): - parsed = parse.urlparse(fname) - if len(parsed.scheme) > 1: # Pillow doesn't handle URLs directly. - _api.warn_deprecated( - "3.4", message="Directly reading images from URLs is " - "deprecated since %(since)s and will no longer be supported " - "%(removal)s. Please open the URL for reading and pass the " - "result to Pillow, e.g. with " - "``np.array(PIL.Image.open(urllib.request.urlopen(url)))``.") - # hide imports to speed initial import on systems with slow linkers - from urllib import request - ssl_ctx = mpl._get_ssl_context() - if ssl_ctx is None: - _log.debug( - "Could not get certifi ssl context, https may not work." - ) - with request.urlopen(fname, context=ssl_ctx) as response: - import io - try: - response.seek(0) - except (AttributeError, io.UnsupportedOperation): - response = io.BytesIO(response.read()) - return imread(response, format=ext) + if isinstance(fname, str) and len(parse.urlparse(fname).scheme) > 1: + # Pillow doesn't handle URLs directly. + raise ValueError( + "Please open the URL for reading and pass the " + "result to Pillow, e.g. with " + "``np.array(PIL.Image.open(urllib.request.urlopen(url)))``." + ) with img_open(fname) as image: return (_pil_png_to_float_array(image) if isinstance(image, PIL.PngImagePlugin.PngImageFile) else @@ -1566,7 +1567,15 @@ def imread(fname, format=None): def imsave(fname, arr, vmin=None, vmax=None, cmap=None, format=None, origin=None, dpi=100, *, metadata=None, pil_kwargs=None): """ - Save an array as an image file. + Colormap and save an array as an image file. + + RGB(A) images are passed through. Single channel images will be + colormapped according to *cmap* and *norm*. + + .. note:: + + If you want to save a single channel image as gray scale please use an + image I/O library (such as pillow, tifffile, or imageio) directly. Parameters ---------- @@ -1624,23 +1633,26 @@ def imsave(fname, arr, vmin=None, vmax=None, cmap=None, format=None, else: # Don't bother creating an image; this avoids rounding errors on the # size when dividing and then multiplying by dpi. - sm = cm.ScalarMappable(cmap=cmap) - sm.set_clim(vmin, vmax) if origin is None: origin = mpl.rcParams["image.origin"] if origin == "lower": arr = arr[::-1] if (isinstance(arr, memoryview) and arr.format == "B" and arr.ndim == 3 and arr.shape[-1] == 4): - # Such an ``arr`` would also be handled fine by sm.to_rgba (after - # casting with asarray), but it is useful to special-case it + # Such an ``arr`` would also be handled fine by sm.to_rgba below + # (after casting with asarray), but it is useful to special-case it # because that's what backend_agg passes, and can be in fact used # as is, saving a few operations. rgba = arr else: + sm = cm.ScalarMappable(cmap=cmap) + sm.set_clim(vmin, vmax) rgba = sm.to_rgba(arr, bytes=True) if pil_kwargs is None: pil_kwargs = {} + else: + # we modify this below, so make a copy (don't modify caller's dict) + pil_kwargs = pil_kwargs.copy() pil_shape = (rgba.shape[1], rgba.shape[0]) image = PIL.Image.frombuffer( "RGBA", pil_shape, rgba, "raw", "RGBA", 0, 1) @@ -1720,7 +1732,7 @@ def _pil_png_to_float_array(pil_png): mode = pil_png.mode rawmode = pil_png.png.im_rawmode if rawmode == "1": # Grayscale. - return np.asarray(pil_png).astype(np.float32) + return np.asarray(pil_png, np.float32) if rawmode == "L;2": # Grayscale. return np.divide(pil_png, 2**2 - 1, dtype=np.float32) if rawmode == "L;4": # Grayscale. @@ -1775,7 +1787,7 @@ def thumbnail(infile, thumbfile, scale=0.1, interpolation='bilinear', Returns ------- - `~.figure.Figure` + `.Figure` The figure instance containing the thumbnail. """ diff --git a/lib/matplotlib/layout_engine.py b/lib/matplotlib/layout_engine.py new file mode 100644 index 000000000000..49b3f1dd125f --- /dev/null +++ b/lib/matplotlib/layout_engine.py @@ -0,0 +1,281 @@ +""" +Classes to layout elements in a `.Figure`. + +Figures have a ``layout_engine`` property that holds a subclass of +`~.LayoutEngine` defined here (or *None* for no layout). At draw time +``figure.get_layout_engine().execute()`` is called, the goal of which is +usually to rearrange Axes on the figure to produce a pleasing layout. This is +like a ``draw`` callback but with two differences. First, when printing we +disable the layout engine for the final draw. Second, it is useful to know the +layout engine while the figure is being created. In particular, colorbars are +made differently with different layout engines (for historical reasons). + +Matplotlib supplies two layout engines, `.TightLayoutEngine` and +`.ConstrainedLayoutEngine`. Third parties can create their own layout engine +by subclassing `.LayoutEngine`. +""" + +from contextlib import nullcontext + +import matplotlib as mpl + +from matplotlib._constrained_layout import do_constrained_layout +from matplotlib._tight_layout import (get_subplotspec_list, + get_tight_layout_figure) + + +class LayoutEngine: + """ + Base class for Matplotlib layout engines. + + A layout engine can be passed to a figure at instantiation or at any time + with `~.figure.Figure.set_layout_engine`. Once attached to a figure, the + layout engine ``execute`` function is called at draw time by + `~.figure.Figure.draw`, providing a special draw-time hook. + + .. note:: + + However, note that layout engines affect the creation of colorbars, so + `~.figure.Figure.set_layout_engine` should be called before any + colorbars are created. + + Currently, there are two properties of `LayoutEngine` classes that are + consulted while manipulating the figure: + + - ``engine.colorbar_gridspec`` tells `.Figure.colorbar` whether to make the + axes using the gridspec method (see `.colorbar.make_axes_gridspec`) or + not (see `.colorbar.make_axes`); + - ``engine.adjust_compatible`` stops `.Figure.subplots_adjust` from being + run if it is not compatible with the layout engine. + + To implement a custom `LayoutEngine`: + + 1. override ``_adjust_compatible`` and ``_colorbar_gridspec`` + 2. override `LayoutEngine.set` to update *self._params* + 3. override `LayoutEngine.execute` with your implementation + + """ + # override these in subclass + _adjust_compatible = None + _colorbar_gridspec = None + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._params = {} + + def set(self, **kwargs): + raise NotImplementedError + + @property + def colorbar_gridspec(self): + """ + Return a boolean if the layout engine creates colorbars using a + gridspec. + """ + if self._colorbar_gridspec is None: + raise NotImplementedError + return self._colorbar_gridspec + + @property + def adjust_compatible(self): + """ + Return a boolean if the layout engine is compatible with + `~.Figure.subplots_adjust`. + """ + if self._adjust_compatible is None: + raise NotImplementedError + return self._adjust_compatible + + def get(self): + """ + Return copy of the parameters for the layout engine. + """ + return dict(self._params) + + def execute(self, fig): + """ + Execute the layout on the figure given by *fig*. + """ + # subclasses must implement this. + raise NotImplementedError + + +class PlaceHolderLayoutEngine(LayoutEngine): + """ + This layout engine does not adjust the figure layout at all. + + The purpose of this `.LayoutEngine` is to act as a placeholder when the + user removes a layout engine to ensure an incompatible `.LayoutEngine` can + not be set later. + + Parameters + ---------- + adjust_compatible, colorbar_gridspec : bool + Allow the PlaceHolderLayoutEngine to mirror the behavior of whatever + layout engine it is replacing. + + """ + def __init__(self, adjust_compatible, colorbar_gridspec, **kwargs): + self._adjust_compatible = adjust_compatible + self._colorbar_gridspec = colorbar_gridspec + super().__init__(**kwargs) + + def execute(self, fig): + return + + +class TightLayoutEngine(LayoutEngine): + """ + Implements the ``tight_layout`` geometry management. See + :doc:`/tutorials/intermediate/tight_layout_guide` for details. + """ + _adjust_compatible = True + _colorbar_gridspec = True + + def __init__(self, *, pad=1.08, h_pad=None, w_pad=None, + rect=(0, 0, 1, 1), **kwargs): + """ + Initialize tight_layout engine. + + Parameters + ---------- + pad : float, 1.08 + Padding between the figure edge and the edges of subplots, as a + fraction of the font size. + h_pad, w_pad : float + Padding (height/width) between edges of adjacent subplots. + Defaults to *pad*. + rect : tuple (left, bottom, right, top), default: (0, 0, 1, 1). + rectangle in normalized figure coordinates that the subplots + (including labels) will fit into. + """ + super().__init__(**kwargs) + for td in ['pad', 'h_pad', 'w_pad', 'rect']: + # initialize these in case None is passed in above: + self._params[td] = None + self.set(pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) + + def execute(self, fig): + """ + Execute tight_layout. + + This decides the subplot parameters given the padding that + will allow the axes labels to not be covered by other labels + and axes. + + Parameters + ---------- + fig : `.Figure` to perform layout on. + + See also: `.figure.Figure.tight_layout` and `.pyplot.tight_layout`. + """ + info = self._params + renderer = fig._get_renderer() + with getattr(renderer, "_draw_disabled", nullcontext)(): + kwargs = get_tight_layout_figure( + fig, fig.axes, get_subplotspec_list(fig.axes), renderer, + pad=info['pad'], h_pad=info['h_pad'], w_pad=info['w_pad'], + rect=info['rect']) + if kwargs: + fig.subplots_adjust(**kwargs) + + def set(self, *, pad=None, w_pad=None, h_pad=None, rect=None): + for td in self.set.__kwdefaults__: + if locals()[td] is not None: + self._params[td] = locals()[td] + + +class ConstrainedLayoutEngine(LayoutEngine): + """ + Implements the ``constrained_layout`` geometry management. See + :doc:`/tutorials/intermediate/constrainedlayout_guide` for details. + """ + + _adjust_compatible = False + _colorbar_gridspec = False + + def __init__(self, *, h_pad=None, w_pad=None, + hspace=None, wspace=None, rect=(0, 0, 1, 1), + compress=False, **kwargs): + """ + Initialize ``constrained_layout`` settings. + + Parameters + ---------- + h_pad, w_pad : float + Padding around the axes elements in figure-normalized units. + Default to :rc:`figure.constrained_layout.h_pad` and + :rc:`figure.constrained_layout.w_pad`. + hspace, wspace : float + Fraction of the figure to dedicate to space between the + axes. These are evenly spread between the gaps between the axes. + A value of 0.2 for a three-column layout would have a space + of 0.1 of the figure width between each column. + If h/wspace < h/w_pad, then the pads are used instead. + Default to :rc:`figure.constrained_layout.hspace` and + :rc:`figure.constrained_layout.wspace`. + rect : tuple of 4 floats + Rectangle in figure coordinates to perform constrained layout in + (left, bottom, width, height), each from 0-1. + compress : bool + Whether to shift Axes so that white space in between them is + removed. This is useful for simple grids of fixed-aspect Axes (e.g. + a grid of images). See :ref:`compressed_layout`. + """ + super().__init__(**kwargs) + # set the defaults: + self.set(w_pad=mpl.rcParams['figure.constrained_layout.w_pad'], + h_pad=mpl.rcParams['figure.constrained_layout.h_pad'], + wspace=mpl.rcParams['figure.constrained_layout.wspace'], + hspace=mpl.rcParams['figure.constrained_layout.hspace'], + rect=(0, 0, 1, 1)) + # set anything that was passed in (None will be ignored): + self.set(w_pad=w_pad, h_pad=h_pad, wspace=wspace, hspace=hspace, + rect=rect) + self._compress = compress + + def execute(self, fig): + """ + Perform constrained_layout and move and resize axes accordingly. + + Parameters + ---------- + fig : `.Figure` to perform layout on. + """ + width, height = fig.get_size_inches() + # pads are relative to the current state of the figure... + w_pad = self._params['w_pad'] / width + h_pad = self._params['h_pad'] / height + + return do_constrained_layout(fig, w_pad=w_pad, h_pad=h_pad, + wspace=self._params['wspace'], + hspace=self._params['hspace'], + rect=self._params['rect'], + compress=self._compress) + + def set(self, *, h_pad=None, w_pad=None, + hspace=None, wspace=None, rect=None): + """ + Set the pads for constrained_layout. + + Parameters + ---------- + h_pad, w_pad : float + Padding around the axes elements in figure-normalized units. + Default to :rc:`figure.constrained_layout.h_pad` and + :rc:`figure.constrained_layout.w_pad`. + hspace, wspace : float + Fraction of the figure to dedicate to space between the + axes. These are evenly spread between the gaps between the axes. + A value of 0.2 for a three-column layout would have a space + of 0.1 of the figure width between each column. + If h/wspace < h/w_pad, then the pads are used instead. + Default to :rc:`figure.constrained_layout.hspace` and + :rc:`figure.constrained_layout.wspace`. + rect : tuple of 4 floats + Rectangle in figure coordinates to perform constrained layout in + (left, bottom, width, height), each from 0-1. + """ + for td in self.set.__kwdefaults__: + if locals()[td] is not None: + self._params[td] = locals()[td] diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index f1407c4337d3..e9e0651066b3 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -28,7 +28,7 @@ import numpy as np import matplotlib as mpl -from matplotlib import _api, docstring, colors, offsetbox +from matplotlib import _api, _docstring, colors, offsetbox from matplotlib.artist import Artist, allow_rasterization from matplotlib.cbook import silent_list from matplotlib.font_manager import FontProperties @@ -38,6 +38,7 @@ from matplotlib.collections import ( Collection, CircleCollection, LineCollection, PathCollection, PolyCollection, RegularPolyCollection) +from matplotlib.text import Text from matplotlib.transforms import Bbox, BboxBase, TransformedBbox from matplotlib.transforms import BboxTransformTo, BboxTransformFrom from matplotlib.offsetbox import ( @@ -93,51 +94,7 @@ def _update_bbox_to_anchor(self, loc_in_canvas): self.legend.set_bbox_to_anchor(loc_in_bbox) -docstring.interpd.update(_legend_kw_doc=""" -loc : str or pair of floats, default: :rc:`legend.loc` ('best' for axes, \ -'upper right' for figures) - The location of the legend. - - The strings - ``'upper left', 'upper right', 'lower left', 'lower right'`` - place the legend at the corresponding corner of the axes/figure. - - The strings - ``'upper center', 'lower center', 'center left', 'center right'`` - place the legend at the center of the corresponding edge of the - axes/figure. - - The string ``'center'`` places the legend at the center of the axes/figure. - - The string ``'best'`` places the legend at the location, among the nine - locations defined so far, with the minimum overlap with other drawn - artists. This option can be quite slow for plots with large amounts of - data; your plotting speed may benefit from providing a specific location. - - The location can also be a 2-tuple giving the coordinates of the lower-left - corner of the legend in axes coordinates (in which case *bbox_to_anchor* - will be ignored). - - For back-compatibility, ``'center right'`` (but no other location) can also - be spelled ``'right'``, and each "string" locations can also be given as a - numeric value: - - =============== ============= - Location String Location Code - =============== ============= - 'best' 0 - 'upper right' 1 - 'upper left' 2 - 'lower left' 3 - 'lower right' 4 - 'right' 5 - 'center left' 6 - 'center right' 7 - 'lower center' 8 - 'upper center' 9 - 'center' 10 - =============== ============= - +_legend_kw_doc_base = """ bbox_to_anchor : `.BboxBase`, 2-tuple, or 4-tuple of floats Box that is used to position the legend in conjunction with *loc*. Defaults to `axes.bbox` (if called as a method to `.Axes.legend`) or @@ -161,9 +118,12 @@ def _update_bbox_to_anchor(self, loc_in_canvas): loc='upper right', bbox_to_anchor=(0.5, 0.5) -ncol : int, default: 1 +ncols : int, default: 1 The number of columns that the legend has. + For backward compatibility, the spelling *ncol* is also supported + but it is discouraged. If both are given, *ncols* takes precedence. + prop : None or `matplotlib.font_manager.FontProperties` or dict The font properties of the legend. If None (default), the current :data:`matplotlib.rcParams` will be used. @@ -205,6 +165,12 @@ def _update_bbox_to_anchor(self, loc_in_canvas): If *True*, legend marker is placed to the left of the legend label. If *False*, legend marker is placed to the right of the legend label. +reverse : bool, default: False + If *True*, the legend labels are displayed in reverse order from the input. + If *False*, the legend labels are displayed in the same order as the input. + + .. versionadded:: 3.7 + frameon : bool, default: :rc:`legend.frameon` Whether the legend should be drawn on a patch (frame). @@ -253,6 +219,10 @@ def _update_bbox_to_anchor(self, loc_in_canvas): to set the fontsize alongside other font properties, use the *size* parameter in *title_fontproperties*. +alignment : {'center', 'left', 'right'}, default: 'center' + The alignment of the legend title and the box of entries. The entries + are aligned as a single block, so that markers always lined up. + borderpad : float, default: :rc:`legend.borderpad` The fractional whitespace inside the legend border, in font-size units. @@ -278,7 +248,82 @@ def _update_bbox_to_anchor(self, loc_in_canvas): The custom dictionary mapping instances or types to a legend handler. This *handler_map* updates the default handler map found at `matplotlib.legend.Legend.get_legend_handler_map`. -""") + +draggable : bool, default: False + Whether the legend can be dragged with the mouse. +""" + +_loc_doc_base = """ +loc : str or pair of floats, {0} + The location of the legend. + + The strings + ``'upper left', 'upper right', 'lower left', 'lower right'`` + place the legend at the corresponding corner of the axes/figure. + + The strings + ``'upper center', 'lower center', 'center left', 'center right'`` + place the legend at the center of the corresponding edge of the + axes/figure. + + The string ``'center'`` places the legend at the center of the axes/figure. + + The string ``'best'`` places the legend at the location, among the nine + locations defined so far, with the minimum overlap with other drawn + artists. This option can be quite slow for plots with large amounts of + data; your plotting speed may benefit from providing a specific location. + + The location can also be a 2-tuple giving the coordinates of the lower-left + corner of the legend in axes coordinates (in which case *bbox_to_anchor* + will be ignored). + + For back-compatibility, ``'center right'`` (but no other location) can also + be spelled ``'right'``, and each "string" locations can also be given as a + numeric value: + + =============== ============= + Location String Location Code + =============== ============= + 'best' 0 + 'upper right' 1 + 'upper left' 2 + 'lower left' 3 + 'lower right' 4 + 'right' 5 + 'center left' 6 + 'center right' 7 + 'lower center' 8 + 'upper center' 9 + 'center' 10 + =============== ============= + {1}""" + +_legend_kw_axes_st = (_loc_doc_base.format("default: :rc:`legend.loc`", '') + + _legend_kw_doc_base) +_docstring.interpd.update(_legend_kw_axes=_legend_kw_axes_st) + +_outside_doc = """ + If a figure is using the constrained layout manager, the string codes + of the *loc* keyword argument can get better layout behaviour using the + prefix 'outside'. There is ambiguity at the corners, so 'outside + upper right' will make space for the legend above the rest of the + axes in the layout, and 'outside right upper' will make space on the + right side of the layout. In addition to the values of *loc* + listed above, we have 'outside right upper', 'outside right lower', + 'outside left upper', and 'outside left lower'. See + :doc:`/tutorials/intermediate/legend_guide` for more details. +""" + +_legend_kw_figure_st = (_loc_doc_base.format("default: 'upper right'", + _outside_doc) + + _legend_kw_doc_base) +_docstring.interpd.update(_legend_kw_figure=_legend_kw_figure_st) + +_legend_kw_both_st = ( + _loc_doc_base.format("default: 'best' for axes, 'upper right' for figures", + _outside_doc) + + _legend_kw_doc_base) +_docstring.interpd.update(_legend_kw_doc=_legend_kw_both_st) class Legend(Artist): @@ -293,13 +338,15 @@ class Legend(Artist): def __str__(self): return "Legend" - @docstring.dedent_interpd + @_api.make_keyword_only("3.6", "loc") + @_docstring.dedent_interpd def __init__( self, parent, handles, labels, loc=None, numpoints=None, # number of points in the legend line markerscale=None, # relative size of legend markers vs. original markerfirst=True, # left/right ordering of legend marker and label + reverse=False, # reverse ordering of legend marker and label scatterpoints=None, # number of scatter points scatteryoffsets=None, prop=None, # properties for the legend texts @@ -315,7 +362,7 @@ def __init__( borderaxespad=None, # pad between the axes and legend border columnspacing=None, # spacing between columns - ncol=1, # number of columns + ncols=1, # number of columns mode=None, # horizontal distribution of columns: None or "expand" fancybox=None, # True: fancy box, False: rounded box, None: rcParam @@ -331,6 +378,10 @@ def __init__( frameon=None, # draw frame handler_map=None, title_fontproperties=None, # properties for the legend title + alignment="center", # control the alignment within the legend box + *, + ncol=1, # synonym for ncols (backward compatibility) + draggable=False # whether the legend can be dragged with the mouse ): """ Parameters @@ -344,12 +395,19 @@ def __init__( labels : list of str A list of labels to show next to the artists. The length of handles and labels should be the same. If they are not, they are truncated - to the smaller of both lengths. + to the length of the shorter list. Other Parameters ---------------- %(_legend_kw_doc)s + Attributes + ---------- + legend_handles + List of `.Artist` objects added as legend entries. + + .. versionadded:: 3.7 + Notes ----- Users can specify any arbitrary location for the legend using the @@ -381,40 +439,46 @@ def __init__( self._fontsize = self.prop.get_size_in_points() self.texts = [] - self.legendHandles = [] + self.legend_handles = [] self._legend_title_box = None #: A dictionary with the extra handler mappings for this Legend #: instance. self._custom_handler_map = handler_map - locals_view = locals() - for name in ["numpoints", "markerscale", "shadow", "columnspacing", - "scatterpoints", "handleheight", 'borderpad', - 'labelspacing', 'handlelength', 'handletextpad', - 'borderaxespad']: - if locals_view[name] is None: - value = mpl.rcParams["legend." + name] - else: - value = locals_view[name] - setattr(self, name, value) - del locals_view + def val_or_rc(val, rc_name): + return val if val is not None else mpl.rcParams[rc_name] + + self.numpoints = val_or_rc(numpoints, 'legend.numpoints') + self.markerscale = val_or_rc(markerscale, 'legend.markerscale') + self.scatterpoints = val_or_rc(scatterpoints, 'legend.scatterpoints') + self.borderpad = val_or_rc(borderpad, 'legend.borderpad') + self.labelspacing = val_or_rc(labelspacing, 'legend.labelspacing') + self.handlelength = val_or_rc(handlelength, 'legend.handlelength') + self.handleheight = val_or_rc(handleheight, 'legend.handleheight') + self.handletextpad = val_or_rc(handletextpad, 'legend.handletextpad') + self.borderaxespad = val_or_rc(borderaxespad, 'legend.borderaxespad') + self.columnspacing = val_or_rc(columnspacing, 'legend.columnspacing') + self.shadow = val_or_rc(shadow, 'legend.shadow') # trim handles and labels if illegal label... _lab, _hand = [], [] for label, handle in zip(labels, handles): if isinstance(label, str) and label.startswith('_'): - _api.warn_external('The handle {!r} has a label of {!r} ' - 'which cannot be automatically added to' - ' the legend.'.format(handle, label)) + _api.warn_external(f"The label {label!r} of {handle!r} starts " + "with '_'. It is thus excluded from the " + "legend.") else: _lab.append(label) _hand.append(handle) labels, handles = _lab, _hand - handles = list(handles) + if reverse: + labels.reverse() + handles.reverse() + if len(handles) < 2: - ncol = 1 - self._ncol = ncol + ncols = 1 + self._ncols = ncols if ncols != 1 else ncol if self.numpoints <= 0: raise ValueError("numpoints must be > 0; it was %d" % numpoints) @@ -446,13 +510,37 @@ def __init__( ) self.parent = parent + loc0 = loc self._loc_used_default = loc is None if loc is None: loc = mpl.rcParams["legend.loc"] if not self.isaxes and loc in [0, 'best']: loc = 'upper right' + + # handle outside legends: + self._outside_loc = None if isinstance(loc, str): + if loc.split()[0] == 'outside': + # strip outside: + loc = loc.split('outside ')[1] + # strip "center" at the beginning + self._outside_loc = loc.replace('center ', '') + # strip first + self._outside_loc = self._outside_loc.split()[0] + locs = loc.split() + if len(locs) > 1 and locs[0] in ('right', 'left'): + # locs doesn't accept "left upper", etc, so swap + if locs[0] != 'center': + locs = locs[::-1] + loc = locs[0] + ' ' + locs[1] + # check that loc is in acceptable strings loc = _api.check_getitem(self.codes, loc=loc) + + if self.isaxes and self._outside_loc: + raise ValueError( + f"'outside' option for loc='{loc0}' keyword argument only " + "works for figure legends") + if not self.isaxes and loc == 0: raise ValueError( "Automatic legend placement (loc='best') not implemented for " @@ -495,6 +583,9 @@ def __init__( ) self._set_artist_props(self.legendPatch) + _api.check_in_list(["center", "left", "right"], alignment=alignment) + self._alignment = alignment + # init with null renderer self._init_legend_box(handles, labels, markerfirst) @@ -519,7 +610,9 @@ def __init__( title_prop_fp.set_size(title_fontsize) self.set_title(title, prop=title_prop_fp) + self._draggable = None + self.set_draggable(state=draggable) # set the text color @@ -537,11 +630,25 @@ def __init__( labelcolor = mpl.rcParams['text.color'] if isinstance(labelcolor, str) and labelcolor in color_getters: getter_names = color_getters[labelcolor] - for handle, text in zip(self.legendHandles, self.texts): + for handle, text in zip(self.legend_handles, self.texts): + try: + if handle.get_array() is not None: + continue + except AttributeError: + pass for getter_name in getter_names: try: color = getattr(handle, getter_name)() - text.set_color(color) + if isinstance(color, np.ndarray): + if ( + color.shape[0] == 1 + or np.isclose(color, color[0]).all() + ): + text.set_color(color[0]) + else: + pass + else: + text.set_color(color) break except AttributeError: pass @@ -554,8 +661,10 @@ def __init__( colors.to_rgba_array(labelcolor))): text.set_color(color) else: - raise ValueError("Invalid argument for labelcolor : %s" % - str(labelcolor)) + raise ValueError(f"Invalid labelcolor: {labelcolor!r}") + + legendHandles = _api.deprecated('3.7', alternative="legend_handles")( + property(lambda self: self.legend_handles)) def _set_artist_props(self, a): """ @@ -577,6 +686,10 @@ def _set_loc(self, loc): self.stale = True self._legend_box.set_offset(self._findoffset) + def set_ncols(self, ncols): + """Set the number of columns.""" + self._ncols = ncols + def _get_loc(self): return self._loc_real @@ -618,7 +731,7 @@ def draw(self, renderer): # update the location and size of the legend. This needs to # be done in any case to clip the figure right. bbox = self._legend_box.get_window_extent(renderer) - self.legendPatch.set_bounds(bbox.x0, bbox.y0, bbox.width, bbox.height) + self.legendPatch.set_bounds(bbox.bounds) self.legendPatch.set_mutation_scale(fontsize) if self.shadow: @@ -654,38 +767,24 @@ def draw(self, renderer): @classmethod def get_default_handler_map(cls): - """ - A class method that returns the default handler map. - """ + """Return the global default handler map, shared by all legends.""" return cls._default_handler_map @classmethod def set_default_handler_map(cls, handler_map): - """ - A class method to set the default handler map. - """ + """Set the global default handler map, shared by all legends.""" cls._default_handler_map = handler_map @classmethod def update_default_handler_map(cls, handler_map): - """ - A class method to update the default handler map. - """ + """Update the global default handler map, shared by all legends.""" cls._default_handler_map.update(handler_map) def get_legend_handler_map(self): - """ - Return the handler map. - """ - + """Return this legend instance's handler map.""" default_handler_map = self.get_default_handler_map() - - if self._custom_handler_map: - hm = default_handler_map.copy() - hm.update(self._custom_handler_map) - return hm - else: - return default_handler_map + return ({**default_handler_map, **self._custom_handler_map} + if self._custom_handler_map else default_handler_map) @staticmethod def get_legend_handler(legend_handler_map, orig_handle): @@ -723,27 +822,19 @@ def _init_legend_box(self, handles, labels, markerfirst=True): fontsize = self._fontsize - # legend_box is a HPacker, horizontally packed with - # columns. Each column is a VPacker, vertically packed with - # legend items. Each legend item is HPacker packed with - # legend handleBox and labelBox. handleBox is an instance of - # offsetbox.DrawingArea which contains legend handle. labelBox - # is an instance of offsetbox.TextArea which contains legend - # text. + # legend_box is a HPacker, horizontally packed with columns. + # Each column is a VPacker, vertically packed with legend items. + # Each legend item is a HPacker packed with: + # - handlebox: a DrawingArea which contains the legend handle. + # - labelbox: a TextArea which contains the legend text. text_list = [] # the list of text instances handle_list = [] # the list of handle instances handles_and_labels = [] - label_prop = dict(verticalalignment='baseline', - horizontalalignment='left', - fontproperties=self.prop, - ) - # The approximate height and descent of text. These values are # only used for plotting the legend handle. - descent = 0.35 * fontsize * (self.handleheight - 0.7) - # 0.35 and 0.7 are just heuristic numbers and may need to be improved. + descent = 0.35 * fontsize * (self.handleheight - 0.7) # heuristic. height = fontsize * self.handleheight - descent # each handle needs to be drawn inside a box of (x, y, w, h) = # (0, -descent, width, height). And their coordinates should @@ -755,21 +846,24 @@ def _init_legend_box(self, handles, labels, markerfirst=True): # manually set their transform to the self.get_transform(). legend_handler_map = self.get_legend_handler_map() - for orig_handle, lab in zip(handles, labels): + for orig_handle, label in zip(handles, labels): handler = self.get_legend_handler(legend_handler_map, orig_handle) if handler is None: _api.warn_external( - "Legend does not support {!r} instances.\nA proxy artist " - "may be used instead.\nSee: " - "https://matplotlib.org/users/legend_guide.html" - "#creating-artists-specifically-for-adding-to-the-legend-" - "aka-proxy-artists".format(orig_handle)) - # We don't have a handle for this artist, so we just defer - # to None. + "Legend does not support handles for {0} " + "instances.\nA proxy artist may be used " + "instead.\nSee: https://matplotlib.org/" + "stable/tutorials/intermediate/legend_guide.html" + "#controlling-the-legend-entries".format( + type(orig_handle).__name__)) + # No handle for this artist, so we just defer to None. handle_list.append(None) else: - textbox = TextArea(lab, textprops=label_prop, - multilinebaseline=True) + textbox = TextArea(label, multilinebaseline=True, + textprops=dict( + verticalalignment='baseline', + horizontalalignment='left', + fontproperties=self.prop)) handlebox = DrawingArea(width=self.handlelength * fontsize, height=height, xdescent=0., ydescent=descent) @@ -781,34 +875,25 @@ def _init_legend_box(self, handles, labels, markerfirst=True): fontsize, handlebox)) handles_and_labels.append((handlebox, textbox)) - if handles_and_labels: - # We calculate number of rows in each column. The first - # (num_largecol) columns will have (nrows+1) rows, and remaining - # (num_smallcol) columns will have (nrows) rows. - ncol = min(self._ncol, len(handles_and_labels)) - nrows, num_largecol = divmod(len(handles_and_labels), ncol) - num_smallcol = ncol - num_largecol - # starting index of each column and number of rows in it. - rows_per_col = [nrows + 1] * num_largecol + [nrows] * num_smallcol - start_idxs = np.concatenate([[0], np.cumsum(rows_per_col)[:-1]]) - cols = zip(start_idxs, rows_per_col) - else: - cols = [] - columnbox = [] - for i0, di in cols: - # pack handleBox and labelBox into itemBox - itemBoxes = [HPacker(pad=0, + # array_split splits n handles_and_labels into ncols columns, with the + # first n%ncols columns having an extra entry. filter(len, ...) + # handles the case where n < ncols: the last ncols-n columns are empty + # and get filtered out. + for handles_and_labels_column in filter( + len, np.array_split(handles_and_labels, self._ncols)): + # pack handlebox and labelbox into itembox + itemboxes = [HPacker(pad=0, sep=self.handletextpad * fontsize, children=[h, t] if markerfirst else [t, h], align="baseline") - for h, t in handles_and_labels[i0:i0 + di]] - # pack columnBox + for h, t in handles_and_labels_column] + # pack columnbox alignment = "baseline" if markerfirst else "right" columnbox.append(VPacker(pad=0, sep=self.labelspacing * fontsize, align=alignment, - children=itemBoxes)) + children=itemboxes)) mode = "expand" if self._mode == "expand" else "fixed" sep = self.columnspacing * fontsize @@ -819,13 +904,13 @@ def _init_legend_box(self, handles, labels, markerfirst=True): self._legend_title_box = TextArea("") self._legend_box = VPacker(pad=self.borderpad * fontsize, sep=self.labelspacing * fontsize, - align="center", + align=self._alignment, children=[self._legend_title_box, self._legend_handle_box]) self._legend_box.set_figure(self.figure) self._legend_box.axes = self.axes self.texts = text_list - self.legendHandles = handle_list + self.legend_handles = handle_list def _auto_legend_data(self): """ @@ -852,12 +937,14 @@ def _auto_legend_data(self): bboxes.append( artist.get_bbox().transformed(artist.get_data_transform())) elif isinstance(artist, Patch): - bboxes.append( - artist.get_path().get_extents(artist.get_transform())) + lines.append( + artist.get_transform().transform_path(artist.get_path())) elif isinstance(artist, Collection): - _, transOffset, hoffsets, _ = artist._prepare_points() - for offset in transOffset.transform(hoffsets): - offsets.append(offset) + transform, transOffset, hoffsets, _ = artist._prepare_points() + if len(hoffsets): + for offset in transOffset.transform(hoffsets): + offsets.append(offset) + return bboxes, lines, offsets def get_children(self): @@ -870,22 +957,53 @@ def get_frame(self): def get_lines(self): r"""Return the list of `~.lines.Line2D`\s in the legend.""" - return [h for h in self.legendHandles if isinstance(h, Line2D)] + return [h for h in self.legend_handles if isinstance(h, Line2D)] def get_patches(self): r"""Return the list of `~.patches.Patch`\s in the legend.""" return silent_list('Patch', - [h for h in self.legendHandles + [h for h in self.legend_handles if isinstance(h, Patch)]) def get_texts(self): r"""Return the list of `~.text.Text`\s in the legend.""" return silent_list('Text', self.texts) + def set_alignment(self, alignment): + """ + Set the alignment of the legend title and the box of entries. + + The entries are aligned as a single block, so that markers always + lined up. + + Parameters + ---------- + alignment : {'center', 'left', 'right'}. + + """ + _api.check_in_list(["center", "left", "right"], alignment=alignment) + self._alignment = alignment + self._legend_box.align = alignment + + def get_alignment(self): + """Get the alignment value of the legend box""" + return self._legend_box.align + def set_title(self, title, prop=None): """ - Set the legend title. Fontproperties can be optionally set - with *prop* parameter. + Set legend title and title style. + + Parameters + ---------- + title : str + The legend title. + + prop : `.font_manager.FontProperties` or `str` or `pathlib.Path` + The font properties of the legend title. + If a `str`, it is interpreted as a fontconfig pattern parsed by + `.FontProperties`. If a `pathlib.Path`, it is interpreted as the + absolute path to a font file. + """ self._legend_title_box._text.set_text(title) if title: @@ -907,10 +1025,10 @@ def get_title(self): def get_window_extent(self, renderer=None): # docstring inherited if renderer is None: - renderer = self.figure._cachedRenderer + renderer = self.figure._get_renderer() return self._legend_box.get_window_extent(renderer=renderer) - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer=None): # docstring inherited return self._legend_box.get_window_extent(renderer) @@ -967,8 +1085,7 @@ def set_bbox_to_anchor(self, bbox, transform=None): try: l = len(bbox) except TypeError as err: - raise ValueError("Invalid argument for bbox : %s" % - str(bbox)) from err + raise ValueError(f"Invalid bbox: {bbox}") from err if l == 2: bbox = [bbox[0], bbox[1], 0, 0] @@ -1095,47 +1212,43 @@ def get_draggable(self): # Helper functions to parse legend arguments for both `figure.legend` and # `axes.legend`: def _get_legend_handles(axs, legend_handler_map=None): - """ - Return a generator of artists that can be used as handles in - a legend. - - """ + """Yield artists that can be used as handles in a legend.""" handles_original = [] for ax in axs: handles_original += [ *(a for a in ax._children - if isinstance(a, (Line2D, Patch, Collection))), + if isinstance(a, (Line2D, Patch, Collection, Text))), *ax.containers] # support parasite axes: if hasattr(ax, 'parasites'): for axx in ax.parasites: handles_original += [ *(a for a in axx._children - if isinstance(a, (Line2D, Patch, Collection))), + if isinstance(a, (Line2D, Patch, Collection, Text))), *axx.containers] - handler_map = Legend.get_default_handler_map() - - if legend_handler_map is not None: - handler_map = handler_map.copy() - handler_map.update(legend_handler_map) - + handler_map = {**Legend.get_default_handler_map(), + **(legend_handler_map or {})} has_handler = Legend.get_legend_handler - for handle in handles_original: label = handle.get_label() if label != '_nolegend_' and has_handler(handler_map, handle): yield handle + elif (label and not label.startswith('_') and + not has_handler(handler_map, handle)): + _api.warn_external( + "Legend does not support handles for {0} " + "instances.\nSee: https://matplotlib.org/stable/" + "tutorials/intermediate/legend_guide.html" + "#implementing-a-custom-legend-handler".format( + type(handle).__name__)) + continue def _get_legend_handles_labels(axs, legend_handler_map=None): - """ - Return handles and labels for legend, internal method. - - """ + """Return handles and labels for legend.""" handles = [] labels = [] - for handle in _get_legend_handles(axs, legend_handler_map): label = handle.get_label() if label and not label.startswith('_'): @@ -1191,7 +1304,7 @@ def _parse_legend_args(axs, *args, handles=None, labels=None, **kwargs): """ log = logging.getLogger(__name__) - handlers = kwargs.get('handler_map', {}) or {} + handlers = kwargs.get('handler_map') extra_args = () if (handles is not None or labels is not None) and args: diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index c6bd96dc84fb..849644145856 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -9,16 +9,16 @@ ` before reading this documentation. Legend handlers are expected to be a callable object with a following -signature. :: +signature:: legend_handler(legend, orig_handle, fontsize, handlebox) Where *legend* is the legend itself, *orig_handle* is the original -plot, *fontsize* is the fontsize in pixels, and *handlebox* is a -OffsetBox instance. Within the call, you should create relevant +plot, *fontsize* is the fontsize in pixels, and *handlebox* is an +`.OffsetBox` instance. Within the call, you should create relevant artists (using relevant properties from the *legend* and/or -*orig_handle*) and add them into the handlebox. The artists needs to -be scaled according to the fontsize (note that the size is in pixel, +*orig_handle*) and add them into the *handlebox*. The artists need to +be scaled according to the *fontsize* (note that the size is in pixels, i.e., this is dpi-scaled value). This module includes definition of several legend handler classes @@ -27,7 +27,6 @@ def legend_artist(self, legend, orig_handle, fontsize, handlebox) """ -from collections.abc import Sequence from itertools import cycle import numpy as np @@ -46,10 +45,10 @@ def update_from_first_child(tgt, src): class HandlerBase: """ - A Base class for default legend handlers. + A base class for default legend handlers. The derived classes are meant to override *create_artists* method, which - has a following signature.:: + has the following signature:: def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, @@ -61,6 +60,18 @@ def create_artists(self, legend, orig_handle, """ def __init__(self, xpad=0., ypad=0., update_func=None): + """ + Parameters + ---------- + + xpad : float, optional + Padding in x-direction. + ypad : float, optional + Padding in y-direction. + update_func : callable, optional + Function for updating the legend handler properties from another + legend handler, used by `~HandlerBase.update_prop`. + """ self._xpad, self._ypad = xpad, ypad self._update_prop_func = update_func @@ -120,9 +131,6 @@ def legend_artist(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, handlebox.get_transform()) - if isinstance(artists, _Line2DHandleList): - artists = [artists[0]] - # create_artists will return a list of artists. for a in artists: handlebox.add_artist(a) @@ -133,6 +141,26 @@ def legend_artist(self, legend, orig_handle, def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + """ + Return the legend artists generated. + + Parameters + ---------- + legend : `~matplotlib.legend.Legend` + The legend for which these legend artists are being created. + orig_handle : `~matplotlib.artist.Artist` or similar + The object for which these legend artists are being created. + xdescent, ydescent, width, height : int + The rectangle (*xdescent*, *ydescent*, *width*, *height*) that the + legend artists being created should fit within. + fontsize : int + The fontsize in pixels. The legend artists being created should + be scaled according to the given fontsize. + trans : `~matplotlib.transforms.Transform` + The transform that is applied to the legend artists being created. + Typically from unit coordinates in the handler box to screen + coordinates. + """ raise NotImplementedError('Derived must override') @@ -214,23 +242,10 @@ class HandlerLine2DCompound(HandlerNpoints): a line-only with a marker-only artist. May be deprecated in the future. """ - def __init__(self, marker_pad=0.3, numpoints=None, **kwargs): - """ - Parameters - ---------- - marker_pad : float - Padding between points in legend entry. - numpoints : int - Number of points to show in legend entry. - **kwargs - Keyword arguments forwarded to `.HandlerNpoints`. - """ - super().__init__(marker_pad=marker_pad, numpoints=numpoints, **kwargs) - def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): - + # docstring inherited xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, width, height, fontsize) @@ -258,24 +273,6 @@ def create_artists(self, legend, orig_handle, return [legline, legline_marker] -class _Line2DHandleList(Sequence): - def __init__(self, legline): - self._legline = legline - - def __len__(self): - return 2 - - def __getitem__(self, index): - if index != 0: - # Make HandlerLine2D return [self._legline] directly after - # deprecation elapses. - _api.warn_deprecated( - "3.5", message="Access to the second element returned by " - "HandlerLine2D is deprecated since %(since)s; it will be " - "removed %(removal)s.") - return [self._legline, self._legline][index] - - class HandlerLine2D(HandlerNpoints): """ Handler for `.Line2D` instances. @@ -286,24 +283,10 @@ class HandlerLine2D(HandlerNpoints): artist for the line and another for the marker(s). """ - def __init__(self, marker_pad=0.3, numpoints=None, **kw): - """ - Parameters - ---------- - marker_pad : float - Padding between points in legend entry. - numpoints : int - Number of points to show in legend entry. - **kwargs - Keyword arguments forwarded to `.HandlerNpoints`. - """ - HandlerNpoints.__init__(self, marker_pad=marker_pad, - numpoints=numpoints, **kw) - def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): - + # docstring inherited xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, width, height, fontsize) @@ -326,7 +309,7 @@ def create_artists(self, legend, orig_handle, legline.set_transform(trans) - return _Line2DHandleList(legline) + return [legline] class HandlerPatch(HandlerBase): @@ -346,7 +329,7 @@ def patch_func(legend=legend, orig_handle=orig_handle, xdescent=xdescent, ydescent=ydescent, width=width, height=height, fontsize=fontsize) - Subsequently the created artist will have its ``update_prop`` + Subsequently, the created artist will have its ``update_prop`` method called and the appropriate transform will be applied. **kwargs @@ -368,6 +351,7 @@ def _create_patch(self, legend, orig_handle, def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + # docstring inherited p = self._create_patch(legend, orig_handle, xdescent, ydescent, width, height, fontsize) self.update_prop(p, orig_handle, legend) @@ -380,37 +364,34 @@ class HandlerStepPatch(HandlerBase): Handler for `~.matplotlib.patches.StepPatch` instances. """ - def _create_patch(self, legend, orig_handle, - xdescent, ydescent, width, height, fontsize): - p = Rectangle(xy=(-xdescent, -ydescent), - color=orig_handle.get_facecolor(), - width=width, height=height) - return p + @staticmethod + def _create_patch(orig_handle, xdescent, ydescent, width, height): + return Rectangle(xy=(-xdescent, -ydescent), width=width, + height=height, color=orig_handle.get_facecolor()) - # Unfilled StepPatch should show as a line - def _create_line(self, legend, orig_handle, - xdescent, ydescent, width, height, fontsize): - - # Overwrite manually because patch and line properties don't mix + @staticmethod + def _create_line(orig_handle, width, height): + # Unfilled StepPatch should show as a line legline = Line2D([0, width], [height/2, height/2], color=orig_handle.get_edgecolor(), linestyle=orig_handle.get_linestyle(), linewidth=orig_handle.get_linewidth(), ) + # Overwrite manually because patch and line properties don't mix legline.set_drawstyle('default') legline.set_marker("") return legline def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + # docstring inherited if orig_handle.get_fill() or (orig_handle.get_hatch() is not None): - p = self._create_patch(legend, orig_handle, - xdescent, ydescent, width, height, fontsize) + p = self._create_patch(orig_handle, xdescent, ydescent, width, + height) self.update_prop(p, orig_handle, legend) else: - p = self._create_line(legend, orig_handle, - xdescent, ydescent, width, height, fontsize) + p = self._create_line(orig_handle, width, height) p.set_transform(trans) return [p] @@ -435,7 +416,7 @@ def _default_update_prop(self, legend_handle, orig_handle): def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): - + # docstring inherited xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, width, height, fontsize) ydata = np.full_like(xdata, (height - ydescent) / 2) @@ -491,18 +472,18 @@ def update_prop(self, legend_handle, orig_handle, legend): legend_handle.set_clip_box(None) legend_handle.set_clip_path(None) - def create_collection(self, orig_handle, sizes, offsets, transOffset): - p = type(orig_handle)(orig_handle.get_numsides(), - rotation=orig_handle.get_rotation(), - sizes=sizes, - offsets=offsets, - transOffset=transOffset, - ) - return p + @_api.rename_parameter("3.6", "transOffset", "offset_transform") + def create_collection(self, orig_handle, sizes, offsets, offset_transform): + return type(orig_handle)( + orig_handle.get_numsides(), + rotation=orig_handle.get_rotation(), sizes=sizes, + offsets=offsets, offset_transform=offset_transform, + ) def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + # docstring inherited xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, width, height, fontsize) @@ -512,34 +493,33 @@ def create_artists(self, legend, orig_handle, sizes = self.get_sizes(legend, orig_handle, xdescent, ydescent, width, height, fontsize) - p = self.create_collection(orig_handle, sizes, - offsets=list(zip(xdata_marker, ydata)), - transOffset=trans) + p = self.create_collection( + orig_handle, sizes, + offsets=list(zip(xdata_marker, ydata)), offset_transform=trans) self.update_prop(p, orig_handle, legend) - p._transOffset = trans + p.set_offset_transform(trans) return [p] class HandlerPathCollection(HandlerRegularPolyCollection): r"""Handler for `.PathCollection`\s, which are used by `~.Axes.scatter`.""" - def create_collection(self, orig_handle, sizes, offsets, transOffset): - p = type(orig_handle)([orig_handle.get_paths()[0]], - sizes=sizes, - offsets=offsets, - transOffset=transOffset, - ) - return p + + @_api.rename_parameter("3.6", "transOffset", "offset_transform") + def create_collection(self, orig_handle, sizes, offsets, offset_transform): + return type(orig_handle)( + [orig_handle.get_paths()[0]], sizes=sizes, + offsets=offsets, offset_transform=offset_transform, + ) class HandlerCircleCollection(HandlerRegularPolyCollection): r"""Handler for `.CircleCollection`\s.""" - def create_collection(self, orig_handle, sizes, offsets, transOffset): - p = type(orig_handle)(sizes, - offsets=offsets, - transOffset=transOffset, - ) - return p + + @_api.rename_parameter("3.6", "transOffset", "offset_transform") + def create_collection(self, orig_handle, sizes, offsets, offset_transform): + return type(orig_handle)( + sizes, offsets=offsets, offset_transform=offset_transform) class HandlerErrorbar(HandlerLine2D): @@ -567,7 +547,7 @@ def get_err_size(self, legend, xdescent, ydescent, def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): - + # docstring inherited plotlines, caplines, barlinecols = orig_handle xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, @@ -593,7 +573,7 @@ def create_artists(self, legend, orig_handle, self.update_prop(legline, plotlines, legend) legline.set_drawstyle('default') - legline.set_marker('None') + legline.set_marker('none') self.update_prop(legline_marker, plotlines, legend) legline_marker.set_linestyle('None') @@ -686,6 +666,7 @@ def get_ydata(self, legend, xdescent, ydescent, width, height, fontsize): def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + # docstring inherited markerline, stemlines, baseline = orig_handle # Check to see if the stemcontainer is storing lines as a list or a # LineCollection. Eventually using a list will be removed, and this @@ -763,7 +744,7 @@ def __init__(self, ndivide=1, pad=None, **kwargs): def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): - + # docstring inherited handler_map = legend.get_legend_handler_map() if self._ndivide is None: @@ -787,8 +768,6 @@ def create_artists(self, legend, orig_handle, _a_list = handler.create_artists( legend, handle1, next(xds_cycle), ydescent, width, height, fontsize, trans) - if isinstance(_a_list, _Line2DHandleList): - _a_list = [_a_list[0]] a_list.extend(_a_list) return a_list @@ -830,6 +809,7 @@ def get_first(prop_array): def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + # docstring inherited p = Rectangle(xy=(-xdescent, -ydescent), width=width, height=height) self.update_prop(p, orig_handle, legend) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 6ae0e16f5e13..db0ce3ba0cea 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -2,13 +2,15 @@ 2D lines with support for a variety of line styles, markers, colors, etc. """ +import copy + from numbers import Integral, Number, Real import logging import numpy as np import matplotlib as mpl -from . import _api, artist, cbook, colors as mcolors, docstring, rcParams +from . import _api, cbook, colors as mcolors, _docstring from .artist import Artist, allow_rasterization from .cbook import ( _to_unmasked_float_array, ls_mapper, ls_mapper_r, STEP_LOOKUP_MAP) @@ -20,7 +22,7 @@ # Imported here for backward compatibility, even though they don't # really belong. from . import _path -from .markers import ( +from .markers import ( # noqa CARETLEFT, CARETRIGHT, CARETUP, CARETDOWN, CARETLEFTBASE, CARETRIGHTBASE, CARETUPBASE, CARETDOWNBASE, TICKLEFT, TICKRIGHT, TICKUP, TICKDOWN) @@ -40,7 +42,7 @@ def _get_dash_pattern(style): # dashed styles elif style in ['dashed', 'dashdot', 'dotted']: offset = 0 - dashes = tuple(rcParams['lines.{}_pattern'.format(style)]) + dashes = tuple(mpl.rcParams['lines.{}_pattern'.format(style)]) # elif isinstance(style, tuple): offset, dashes = style @@ -59,7 +61,7 @@ def _get_dash_pattern(style): def _scale_dashes(offset, dashes, lw): - if not rcParams['lines.scale_dashes']: + if not mpl.rcParams['lines.scale_dashes']: return offset, dashes scaled_offset = offset * lw scaled_dashes = ([x * lw if x is not None else None for x in dashes] @@ -156,8 +158,12 @@ def _slice_or_none(in_v, slc): raise ValueError( "markevery is specified relative to the axes size, but " "the line does not have a Axes as parent") + # calc cumulative distance along path (in display coords): - disp_coords = affine.transform(tpath.vertices) + fin = np.isfinite(verts).all(axis=1) + fverts = verts[fin] + disp_coords = affine.transform(fverts) + delta = np.empty((len(disp_coords), 2)) delta[0, :] = 0 delta[1:, :] = disp_coords[1:, :] - disp_coords[:-1, :] @@ -173,7 +179,7 @@ def _slice_or_none(in_v, slc): inds = inds.argmin(axis=1) inds = np.unique(inds) # return, we are done here - return Path(verts[inds], _slice_or_none(codes, inds)) + return Path(fverts[inds], _slice_or_none(codes, inds)) else: raise ValueError( f"markevery={markevery!r} is a tuple with len 2, but its " @@ -195,8 +201,8 @@ def _slice_or_none(in_v, slc): raise ValueError(f"markevery={markevery!r} is not a recognized value") -@docstring.interpd -@cbook._define_aliases({ +@_docstring.interpd +@_api.define_aliases({ "antialiased": ["aa"], "color": ["c"], "drawstyle": ["ds"], @@ -250,16 +256,6 @@ class Line2D(Artist): zorder = 2 - @_api.deprecated("3.4") - @_api.classproperty - def validCap(cls): - return tuple(cs.value for cs in CapStyle) - - @_api.deprecated("3.4") - @_api.classproperty - def validJoin(cls): - return tuple(js.value for js in JoinStyle) - def __str__(self): if self._label != "": return f"Line2D({self._label})" @@ -273,10 +269,12 @@ def __str__(self): return "Line2D(%s)" % ",".join( map("({:g},{:g})".format, self._x, self._y)) + @_api.make_keyword_only("3.6", name="linewidth") def __init__(self, xdata, ydata, linewidth=None, # all Nones default to rc linestyle=None, color=None, + gapcolor=None, marker=None, markersize=None, markeredgewidth=None, @@ -316,27 +314,27 @@ def __init__(self, xdata, ydata, raise RuntimeError('ydata must be a sequence') if linewidth is None: - linewidth = rcParams['lines.linewidth'] + linewidth = mpl.rcParams['lines.linewidth'] if linestyle is None: - linestyle = rcParams['lines.linestyle'] + linestyle = mpl.rcParams['lines.linestyle'] if marker is None: - marker = rcParams['lines.marker'] + marker = mpl.rcParams['lines.marker'] if color is None: - color = rcParams['lines.color'] + color = mpl.rcParams['lines.color'] if markersize is None: - markersize = rcParams['lines.markersize'] + markersize = mpl.rcParams['lines.markersize'] if antialiased is None: - antialiased = rcParams['lines.antialiased'] + antialiased = mpl.rcParams['lines.antialiased'] if dash_capstyle is None: - dash_capstyle = rcParams['lines.dash_capstyle'] + dash_capstyle = mpl.rcParams['lines.dash_capstyle'] if dash_joinstyle is None: - dash_joinstyle = rcParams['lines.dash_joinstyle'] + dash_joinstyle = mpl.rcParams['lines.dash_joinstyle'] if solid_capstyle is None: - solid_capstyle = rcParams['lines.solid_capstyle'] + solid_capstyle = mpl.rcParams['lines.solid_capstyle'] if solid_joinstyle is None: - solid_joinstyle = rcParams['lines.solid_joinstyle'] + solid_joinstyle = mpl.rcParams['lines.solid_joinstyle'] if drawstyle is None: drawstyle = 'default' @@ -353,14 +351,8 @@ def __init__(self, xdata, ydata, self._linestyles = None self._drawstyle = None self._linewidth = linewidth - - # scaled dash + offset - self._dashSeq = None - self._dashOffset = 0 - # unscaled dash + offset - # this is needed scaling the dash pattern by linewidth - self._us_dashSeq = None - self._us_dashOffset = 0 + self._unscaled_dash_pattern = (0, None) # offset, dash + self._dash_pattern = (0, None) # offset, dash (scaled by linewidth) self.set_linewidth(linewidth) self.set_linestyle(linestyle) @@ -368,7 +360,15 @@ def __init__(self, xdata, ydata, self._color = None self.set_color(color) - self._marker = MarkerStyle(marker, fillstyle) + if marker is None: + marker = 'none' # Default. + if not isinstance(marker, MarkerStyle): + self._marker = MarkerStyle(marker, fillstyle) + else: + self._marker = marker + + self._gapcolor = None + self.set_gapcolor(gapcolor) self._markevery = None self._markersize = None @@ -390,12 +390,12 @@ def __init__(self, xdata, ydata, # update kwargs before updating data to give the caller a # chance to init axes (and hence unit support) - self.update(kwargs) - self.pickradius = pickradius + self._internal_update(kwargs) + self._pickradius = pickradius self.ind_offset = 0 if (isinstance(self._picker, Number) and not isinstance(self._picker, bool)): - self.pickradius = self._picker + self._pickradius = self._picker self._xorig = np.asarray([]) self._yorig = np.asarray([]) @@ -456,9 +456,9 @@ def contains(self, mouseevent): # Convert pick radius from points to pixels if self.figure is None: _log.warning('no figure set when check if mouse is on line') - pixels = self.pickradius + pixels = self._pickradius else: - pixels = self.figure.dpi / 72. * self.pickradius + pixels = self.figure.dpi / 72. * self._pickradius # The math involved in checking for containment (here and inside of # segment_hits) assumes that it is OK to overflow, so temporarily set @@ -489,7 +489,8 @@ def get_pickradius(self): """ return self._pickradius - def set_pickradius(self, d): + @_api.rename_parameter("3.6", "d", "pickradius") + def set_pickradius(self, pickradius): """ Set the pick radius used for containment tests. @@ -497,12 +498,12 @@ def set_pickradius(self, d): Parameters ---------- - d : float + pickradius : float Pick radius, in points. """ - if not isinstance(d, Number) or d < 0: + if not isinstance(pickradius, Number) or pickradius < 0: raise ValueError("pick radius should be a distance") - self._pickradius = d + self._pickradius = pickradius pickradius = property(get_pickradius, set_pickradius) @@ -610,13 +611,17 @@ def set_picker(self, p): p : float or callable[[Artist, Event], tuple[bool, dict]] If a float, it is used as the pick radius in points. """ - if callable(p): - self._contains = p - else: - self.pickradius = p + if not callable(p): + self.set_pickradius(p) self._picker = p - def get_window_extent(self, renderer): + def get_bbox(self): + """Get the bounding box of this line.""" + bbox = Bbox([[0, 0], [0, 0]]) + bbox.update_from_data_xy(self.get_xydata()) + return bbox + + def get_window_extent(self, renderer=None): bbox = Bbox([[0, 0], [0, 0]]) trans_data_to_xy = self.get_transform().transform bbox.update_from_data_xy(trans_data_to_xy(self.get_xydata()), @@ -754,9 +759,6 @@ def draw(self, renderer): self._set_gc_clip(gc) gc.set_url(self.get_url()) - lc_rgba = mcolors.to_rgba(self._color, self._alpha) - gc.set_foreground(lc_rgba, isRGBA=True) - gc.set_antialiased(self._antialiased) gc.set_linewidth(self._linewidth) @@ -772,7 +774,27 @@ def draw(self, renderer): if self.get_sketch_params() is not None: gc.set_sketch_params(*self.get_sketch_params()) - gc.set_dashes(self._dashOffset, self._dashSeq) + # We first draw a path within the gaps if needed. + if self.is_dashed() and self._gapcolor is not None: + lc_rgba = mcolors.to_rgba(self._gapcolor, self._alpha) + gc.set_foreground(lc_rgba, isRGBA=True) + + # Define the inverse pattern by moving the last gap to the + # start of the sequence. + dashes = self._dash_pattern[1] + gaps = dashes[-1:] + dashes[:-1] + # Set the offset so that this new first segment is skipped + # (see backend_bases.GraphicsContextBase.set_dashes for + # offset definition). + offset_gaps = self._dash_pattern[0] + dashes[-1] + + gc.set_dashes(offset_gaps, gaps) + renderer.draw_path(gc, tpath, affine.frozen()) + + lc_rgba = mcolors.to_rgba(self._color, self._alpha) + gc.set_foreground(lc_rgba, isRGBA=True) + + gc.set_dashes(*self._dash_pattern) renderer.draw_path(gc, tpath, affine.frozen()) gc.restore() @@ -876,6 +898,14 @@ def get_drawstyle(self): """ return self._drawstyle + def get_gapcolor(self): + """ + Return the line gapcolor. + + See also `~.Line2D.set_gapcolor`. + """ + return self._gapcolor + def get_linestyle(self): """ Return the linestyle. @@ -908,7 +938,7 @@ def get_markeredgecolor(self): """ mec = self._markeredgecolor if cbook._str_equal(mec, 'auto'): - if rcParams['_internal.classic_mode']: + if mpl.rcParams['_internal.classic_mode']: if self._marker.get_marker() in ('.', ','): return self._color if (self._marker.is_filled() @@ -1066,6 +1096,29 @@ def set_drawstyle(self, drawstyle): self._invalidx = True self._drawstyle = drawstyle + def set_gapcolor(self, gapcolor): + """ + Set a color to fill the gaps in the dashed line style. + + .. note:: + + Striped lines are created by drawing two interleaved dashed lines. + There can be overlaps between those two, which may result in + artifacts when using transparency. + + This functionality is experimental and may change. + + Parameters + ---------- + gapcolor : color or None + The color with which to fill the gaps. If None, the gaps are + unfilled. + """ + if gapcolor is not None: + mcolors._check_color_like(color=gapcolor) + self._gapcolor = gapcolor + self.stale = True + def set_linewidth(self, w): """ Set the line width in points. @@ -1076,13 +1129,10 @@ def set_linewidth(self, w): Line width, in points. """ w = float(w) - if self._linewidth != w: self.stale = True self._linewidth = w - # rescale the dashes + offset - self._dashOffset, self._dashSeq = _scale_dashes( - self._us_dashOffset, self._us_dashSeq, self._linewidth) + self._dash_pattern = _scale_dashes(*self._unscaled_dash_pattern, w) def set_linestyle(self, ls): """ @@ -1118,21 +1168,18 @@ def set_linestyle(self, ls): if isinstance(ls, str): if ls in [' ', '', 'none']: ls = 'None' - _api.check_in_list([*self._lineStyles, *ls_mapper_r], ls=ls) if ls not in self._lineStyles: ls = ls_mapper_r[ls] self._linestyle = ls else: self._linestyle = '--' + self._unscaled_dash_pattern = _get_dash_pattern(ls) + self._dash_pattern = _scale_dashes( + *self._unscaled_dash_pattern, self._linewidth) + self.stale = True - # get the unscaled dashes - self._us_dashOffset, self._us_dashSeq = _get_dash_pattern(ls) - # compute the linewidth scaled dashes - self._dashOffset, self._dashSeq = _scale_dashes( - self._us_dashOffset, self._us_dashSeq, self._linewidth) - - @docstring.interpd + @_docstring.interpd def set_marker(self, marker): """ Set the line marker. @@ -1148,7 +1195,7 @@ def set_marker(self, marker): def _set_markercolor(self, name, has_rcdefault, val): if val is None: - val = rcParams[f"lines.{name}"] if has_rcdefault else "auto" + val = mpl.rcParams[f"lines.{name}"] if has_rcdefault else "auto" attr = f"_{name}" current = getattr(self, attr) if current is None: @@ -1200,7 +1247,7 @@ def set_markeredgewidth(self, ew): Marker edge width, in points. """ if ew is None: - ew = rcParams['lines.markeredgewidth'] + ew = mpl.rcParams['lines.markeredgewidth'] if self._markeredgewidth != ew: self.stale = True self._markeredgewidth = ew @@ -1227,7 +1274,16 @@ def set_xdata(self, x): ---------- x : 1D array """ - self._xorig = x + if not np.iterable(x): + # When deprecation cycle is completed + # raise RuntimeError('x must be a sequence') + _api.warn_deprecated( + since=3.7, + message="Setting data with a non sequence type " + "is deprecated since %(since)s and will be " + "remove %(removal)s") + x = [x, ] + self._xorig = copy.copy(x) self._invalidx = True self.stale = True @@ -1239,7 +1295,16 @@ def set_ydata(self, y): ---------- y : 1D array """ - self._yorig = y + if not np.iterable(y): + # When deprecation cycle is completed + # raise RuntimeError('y must be a sequence') + _api.warn_deprecated( + since=3.7, + message="Setting data with a non sequence type " + "is deprecated since %(since)s and will be " + "remove %(removal)s") + y = [y, ] + self._yorig = copy.copy(y) self._invalidy = True self.stale = True @@ -1253,6 +1318,9 @@ def set_dashes(self, seq): For example, (5, 2, 1, 2) describes a sequence of 5 point and 1 point dashes separated by 2 point spaces. + See also `~.Line2D.set_gapcolor`, which allows those spaces to be + filled with a color. + Parameters ---------- seq : sequence of floats (on/off ink in points) or (None, None) @@ -1270,15 +1338,14 @@ def update_from(self, other): self._linestyle = other._linestyle self._linewidth = other._linewidth self._color = other._color + self._gapcolor = other._gapcolor self._markersize = other._markersize self._markerfacecolor = other._markerfacecolor self._markerfacecoloralt = other._markerfacecoloralt self._markeredgecolor = other._markeredgecolor self._markeredgewidth = other._markeredgewidth - self._dashSeq = other._dashSeq - self._us_dashSeq = other._us_dashSeq - self._dashOffset = other._dashOffset - self._us_dashOffset = other._us_dashOffset + self._unscaled_dash_pattern = other._unscaled_dash_pattern + self._dash_pattern = other._dash_pattern self._dashcapstyle = other._dashcapstyle self._dashjoinstyle = other._dashjoinstyle self._solidcapstyle = other._solidcapstyle @@ -1288,7 +1355,7 @@ def update_from(self, other): self._marker = MarkerStyle(marker=other._marker) self._drawstyle = other._drawstyle - @docstring.interpd + @_docstring.interpd def set_dash_joinstyle(self, s): """ How to join segments of the line if it `~Line2D.is_dashed`. @@ -1304,7 +1371,7 @@ def set_dash_joinstyle(self, s): self.stale = True self._dashjoinstyle = js - @docstring.interpd + @_docstring.interpd def set_solid_joinstyle(self, s): """ How to join segments if the line is solid (not `~Line2D.is_dashed`). @@ -1336,7 +1403,7 @@ def get_solid_joinstyle(self): """ return self._solidjoinstyle.name - @docstring.interpd + @_docstring.interpd def set_dash_capstyle(self, s): """ How to draw the end caps if the line is `~Line2D.is_dashed`. @@ -1352,7 +1419,7 @@ def set_dash_capstyle(self, s): self.stale = True self._dashcapstyle = cs - @docstring.interpd + @_docstring.interpd def set_solid_capstyle(self, s): """ How to draw the end caps if the line is solid (not `~Line2D.is_dashed`) @@ -1466,8 +1533,7 @@ class VertexSelector: Derived classes should override the `process_selected` method to do something with the picks. - Here is an example which highlights the selected verts with red - circles:: + Here is an example which highlights the selected verts with red circles:: import numpy as np import matplotlib.pyplot as plt @@ -1475,7 +1541,7 @@ class VertexSelector: class HighlightSelected(lines.VertexSelector): def __init__(self, line, fmt='ro', **kwargs): - lines.VertexSelector.__init__(self, line) + super().__init__(line) self.markers, = self.axes.plot([], [], fmt, **kwargs) def process_selected(self, ind, xs, ys): @@ -1488,27 +1554,29 @@ def process_selected(self, ind, xs, ys): selector = HighlightSelected(line) plt.show() - """ + def __init__(self, line): """ - Initialize the class with a `.Line2D`. The line should already be - added to an `~.axes.Axes` and should have the picker property set. + Parameters + ---------- + line : `.Line2D` + The line must already have been added to an `~.axes.Axes` and must + have its picker property set. """ if line.axes is None: raise RuntimeError('You must first add the line to the Axes') - if line.get_picker() is None: raise RuntimeError('You must first set the picker property ' 'of the line') - self.axes = line.axes self.line = line - self.canvas = self.axes.figure.canvas - self.cid = self.canvas.mpl_connect('pick_event', self.onpick) - + self.cid = self.canvas.callbacks._connect_picklable( + 'pick_event', self.onpick) self.ind = set() + canvas = property(lambda self: self.axes.figure.canvas) + def process_selected(self, ind, xs, ys): """ Default "do nothing" implementation of the `process_selected` method. diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index f0074d1029ab..c9fc0141939d 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -45,7 +45,8 @@ ``9`` (``CARETRIGHTBASE``) |m34| caretright (centered at base) ``10`` (``CARETUPBASE``) |m35| caretup (centered at base) ``11`` (``CARETDOWNBASE``) |m36| caretdown (centered at base) -``"None"``, ``" "`` or ``""`` nothing +``"none"`` or ``"None"`` nothing +``" "`` or ``""`` nothing ``'$...$'`` |m37| Render the string using mathtext. E.g ``"$f$"`` for marker showing the letter ``f``. @@ -63,9 +64,10 @@ rotated by ``angle``. ============================== ====== ========================================= -``None`` is the default which means 'nothing', however this table is -referred to from other docs for the valid inputs from marker inputs and in -those cases ``None`` still means 'default'. +As a deprecated feature, ``None`` also means 'nothing' when directly +constructing a `.MarkerStyle`, but note that there are other contexts where +``marker=None`` instead means "the default marker" (e.g. :rc:`scatter.marker` +for `.Axes.scatter`). Note that special symbols can be defined via the :doc:`STIX math font `, @@ -80,11 +82,16 @@ plt.plot([1, 2, 3], marker=11) plt.plot([1, 2, 3], marker=matplotlib.markers.CARETDOWNBASE) +Markers join and cap styles can be customized by creating a new instance of +MarkerStyle. +A MarkerStyle can also have a custom `~matplotlib.transforms.Transform` +allowing it to be arbitrarily rotated or offset. + Examples showing the use of markers: * :doc:`/gallery/lines_bars_and_markers/marker_reference` * :doc:`/gallery/lines_bars_and_markers/scatter_star_poly` - +* :doc:`/gallery/lines_bars_and_markers/multivariate_marker_plot` .. |m00| image:: /_static/markers/m00.png .. |m01| image:: /_static/markers/m01.png @@ -125,12 +132,15 @@ .. |m36| image:: /_static/markers/m36.png .. |m37| image:: /_static/markers/m37.png """ +import copy from collections.abc import Sized +import inspect import numpy as np -from . import _api, cbook, rcParams +import matplotlib as mpl +from . import _api, cbook from .path import Path from .transforms import IdentityTransform, Affine2D from ._enums import JoinStyle, CapStyle @@ -199,7 +209,7 @@ class MarkerStyle: CARETUPBASE: 'caretupbase', CARETDOWNBASE: 'caretdownbase', "None": 'nothing', - None: 'nothing', + "none": 'nothing', ' ': 'nothing', '': 'nothing' } @@ -207,33 +217,63 @@ class MarkerStyle: # Just used for informational purposes. is_filled() # is calculated in the _set_* functions. filled_markers = ( - 'o', 'v', '^', '<', '>', '8', 's', 'p', '*', 'h', 'H', 'D', 'd', + '.', 'o', 'v', '^', '<', '>', '8', 's', 'p', '*', 'h', 'H', 'D', 'd', 'P', 'X') fillstyles = ('full', 'left', 'right', 'bottom', 'top', 'none') _half_fillstyles = ('left', 'right', 'bottom', 'top') - # TODO: Is this ever used as a non-constant? - _point_size_reduction = 0.5 + _unset = object() # For deprecation of MarkerStyle(). - def __init__(self, marker=None, fillstyle=None): + def __init__(self, marker=_unset, fillstyle=None, + transform=None, capstyle=None, joinstyle=None): """ Parameters ---------- - marker : str, array-like, Path, MarkerStyle, or None, default: None + marker : str, array-like, Path, MarkerStyle, or None - Another instance of *MarkerStyle* copies the details of that ``marker``. - - *None* means no marker. - - For other possible marker values see the module docstring + - *None* means no marker. This is the deprecated default. + - For other possible marker values, see the module docstring `matplotlib.markers`. fillstyle : str, default: :rc:`markers.fillstyle` One of 'full', 'left', 'right', 'bottom', 'top', 'none'. + + transform : transforms.Transform, default: None + Transform that will be combined with the native transform of the + marker. + + capstyle : CapStyle, default: None + Cap style that will override the default cap style of the marker. + + joinstyle : JoinStyle, default: None + Join style that will override the default join style of the marker. """ self._marker_function = None + self._user_transform = transform + self._user_capstyle = capstyle + self._user_joinstyle = joinstyle self._set_fillstyle(fillstyle) + # Remove _unset and signature rewriting after deprecation elapses. + if marker is self._unset: + marker = "" + _api.warn_deprecated( + "3.6", message="Calling MarkerStyle() with no parameters is " + "deprecated since %(since)s; support will be removed " + "%(removal)s. Use MarkerStyle('') to construct an empty " + "MarkerStyle.") + if marker is None: + marker = "" + _api.warn_deprecated( + "3.6", message="MarkerStyle(None) is deprecated since " + "%(since)s; support will be removed %(removal)s. Use " + "MarkerStyle('') to construct an empty MarkerStyle.") self._set_marker(marker) + __init__.__signature__ = inspect.signature( # Only for deprecation period. + lambda self, marker, fillstyle=None: None) + def _recache(self): if self._marker_function is None: return @@ -243,7 +283,7 @@ def _recache(self): self._alt_transform = None self._snap_threshold = None self._joinstyle = JoinStyle.round - self._capstyle = CapStyle.butt + self._capstyle = self._user_capstyle or CapStyle.butt # Initial guess: Assume the marker is filled unless the fillstyle is # set to 'none'. The marker function will override this for unfilled # markers. @@ -259,10 +299,6 @@ def is_filled(self): def get_fillstyle(self): return self._fillstyle - @_api.deprecated("3.4", alternative="a new marker") - def set_fillstyle(self, fillstyle): - return self._set_fillstyle(fillstyle) - def _set_fillstyle(self, fillstyle): """ Set the fillstyle. @@ -274,24 +310,20 @@ def _set_fillstyle(self, fillstyle): markerfacecolor. """ if fillstyle is None: - fillstyle = rcParams['markers.fillstyle'] + fillstyle = mpl.rcParams['markers.fillstyle'] _api.check_in_list(self.fillstyles, fillstyle=fillstyle) self._fillstyle = fillstyle self._recache() def get_joinstyle(self): - return self._joinstyle + return self._joinstyle.name def get_capstyle(self): - return self._capstyle + return self._capstyle.name def get_marker(self): return self._marker - @_api.deprecated("3.4", alternative="a new marker") - def set_marker(self, marker): - return self._set_marker(marker) - def _set_marker(self, marker): """ Set the marker. @@ -320,7 +352,8 @@ def _set_marker(self, marker): self._marker_function = getattr( self, '_set_' + self.markers[marker]) elif isinstance(marker, MarkerStyle): - self.__dict__.update(marker.__dict__) + self.__dict__ = copy.deepcopy(marker.__dict__) + else: try: Path(marker) @@ -347,7 +380,10 @@ def get_transform(self): Return the transform to be applied to the `.Path` from `MarkerStyle.get_path()`. """ - return self._transform.frozen() + if self._user_transform is None: + return self._transform.frozen() + else: + return (self._transform + self._user_transform).frozen() def get_alt_path(self): """ @@ -363,11 +399,86 @@ def get_alt_transform(self): Return the transform to be applied to the `.Path` from `MarkerStyle.get_alt_path()`. """ - return self._alt_transform.frozen() + if self._user_transform is None: + return self._alt_transform.frozen() + else: + return (self._alt_transform + self._user_transform).frozen() def get_snap_threshold(self): return self._snap_threshold + def get_user_transform(self): + """Return user supplied part of marker transform.""" + if self._user_transform is not None: + return self._user_transform.frozen() + + def transformed(self, transform: Affine2D): + """ + Return a new version of this marker with the transform applied. + + Parameters + ---------- + transform : Affine2D, default: None + Transform will be combined with current user supplied transform. + """ + new_marker = MarkerStyle(self) + if new_marker._user_transform is not None: + new_marker._user_transform += transform + else: + new_marker._user_transform = transform + return new_marker + + def rotated(self, *, deg=None, rad=None): + """ + Return a new version of this marker rotated by specified angle. + + Parameters + ---------- + deg : float, default: None + Rotation angle in degrees. + + rad : float, default: None + Rotation angle in radians. + + .. note:: You must specify exactly one of deg or rad. + """ + if deg is None and rad is None: + raise ValueError('One of deg or rad is required') + if deg is not None and rad is not None: + raise ValueError('Only one of deg and rad can be supplied') + new_marker = MarkerStyle(self) + if new_marker._user_transform is None: + new_marker._user_transform = Affine2D() + + if deg is not None: + new_marker._user_transform.rotate_deg(deg) + if rad is not None: + new_marker._user_transform.rotate(rad) + + return new_marker + + def scaled(self, sx, sy=None): + """ + Return new marker scaled by specified scale factors. + + If *sy* is None, the same scale is applied in both the *x*- and + *y*-directions. + + Parameters + ---------- + sx : float + *X*-direction scaling factor. + sy : float, default: None + *Y*-direction scaling factor. + """ + if sy is None: + sy = sx + + new_marker = MarkerStyle(self) + _transform = new_marker._user_transform or Affine2D() + new_marker._user_transform = _transform.scale(sx, sy) + return new_marker + def _set_nothing(self): self._filled = False @@ -391,21 +502,21 @@ def _set_tuple_marker(self): symstyle = marker[1] if symstyle == 0: self._path = Path.unit_regular_polygon(numsides) - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter elif symstyle == 1: self._path = Path.unit_regular_star(numsides) - self._joinstyle = JoinStyle.bevel + self._joinstyle = self._user_joinstyle or JoinStyle.bevel elif symstyle == 2: self._path = Path.unit_regular_asterisk(numsides) self._filled = False - self._joinstyle = JoinStyle.bevel + self._joinstyle = self._user_joinstyle or JoinStyle.bevel else: raise ValueError(f"Unexpected tuple marker: {marker}") self._transform = Affine2D().scale(0.5).rotate_deg(rotation) def _set_mathtext_path(self): """ - Draw mathtext markers '$...$' using TextPath object. + Draw mathtext markers '$...$' using `.TextPath` object. Submitted by tcb """ @@ -414,7 +525,7 @@ def _set_mathtext_path(self): # again, the properties could be initialised just once outside # this function text = TextPath(xy=(0, 0), s=self.get_marker(), - usetex=rcParams['text.usetex']) + usetex=mpl.rcParams['text.usetex']) if len(text.vertices) == 0: return @@ -432,8 +543,8 @@ def _set_mathtext_path(self): def _half_fill(self): return self.get_fillstyle() in self._half_fillstyles - def _set_circle(self, reduction=1.0): - self._transform = Affine2D().scale(0.5 * reduction) + def _set_circle(self, size=1.0): + self._transform = Affine2D().scale(0.5 * size) self._snap_threshold = np.inf if not self._half_fill(): self._path = Path.unit_circle() @@ -444,6 +555,9 @@ def _set_circle(self, reduction=1.0): {'right': 0, 'top': 90, 'left': 180, 'bottom': 270}[fs]) self._alt_transform = self._transform.frozen().rotate_deg(180.) + def _set_point(self): + self._set_circle(size=0.5) + def _set_pixel(self): self._path = Path.unit_rectangle() # Ideally, you'd want -0.5, -0.5 here, but then the snapping @@ -458,18 +572,13 @@ def _set_pixel(self): self._transform = Affine2D().translate(-0.49999, -0.49999) self._snap_threshold = None - def _set_point(self): - self._set_circle(reduction=self._point_size_reduction) - - _triangle_path = Path([[0, 1], [-1, -1], [1, -1], [0, 1]], closed=True) + _triangle_path = Path._create_closed([[0, 1], [-1, -1], [1, -1]]) # Going down halfway looks to small. Golden ratio is too far. - _triangle_path_u = Path([[0, 1], [-3/5, -1/5], [3/5, -1/5], [0, 1]], - closed=True) - _triangle_path_d = Path( - [[-3/5, -1/5], [3/5, -1/5], [1, -1], [-1, -1], [-3/5, -1/5]], - closed=True) - _triangle_path_l = Path([[0, 1], [0, -1], [-1, -1], [0, 1]], closed=True) - _triangle_path_r = Path([[0, 1], [0, -1], [1, -1], [0, 1]], closed=True) + _triangle_path_u = Path._create_closed([[0, 1], [-3/5, -1/5], [3/5, -1/5]]) + _triangle_path_d = Path._create_closed( + [[-3/5, -1/5], [3/5, -1/5], [1, -1], [-1, -1]]) + _triangle_path_l = Path._create_closed([[0, 1], [0, -1], [-1, -1]]) + _triangle_path_r = Path._create_closed([[0, 1], [0, -1], [1, -1]]) def _set_triangle(self, rot, skip): self._transform = Affine2D().scale(0.5).rotate_deg(rot) @@ -499,7 +608,7 @@ def _set_triangle(self, rot, skip): self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_triangle_up(self): return self._set_triangle(0.0, 0) @@ -529,7 +638,7 @@ def _set_square(self): self._transform.rotate_deg(rotate) self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_diamond(self): self._transform = Affine2D().translate(-0.5, -0.5).rotate_deg(45) @@ -543,7 +652,7 @@ def _set_diamond(self): rotate = {'right': 0, 'top': 90, 'left': 180, 'bottom': 270}[fs] self._transform.rotate_deg(rotate) self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_thin_diamond(self): self._set_diamond() @@ -570,7 +679,7 @@ def _set_pentagon(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_star(self): self._transform = Affine2D().scale(0.5) @@ -592,7 +701,7 @@ def _set_star(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.bevel + self._joinstyle = self._user_joinstyle or JoinStyle.bevel def _set_hexagon1(self): self._transform = Affine2D().scale(0.5) @@ -616,7 +725,7 @@ def _set_hexagon1(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_hexagon2(self): self._transform = Affine2D().scale(0.5).rotate_deg(30) @@ -642,7 +751,7 @@ def _set_hexagon2(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_octagon(self): self._transform = Affine2D().scale(0.5) @@ -663,7 +772,7 @@ def _set_octagon(self): {'left': 0, 'bottom': 90, 'right': 180, 'top': 270}[fs]) self._alt_transform = self._transform.frozen().rotate_deg(180.0) - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter _line_marker_path = Path([[0.0, -1.0], [0.0, 1.0]]) @@ -737,7 +846,7 @@ def _set_caretdown(self): self._snap_threshold = 3.0 self._filled = False self._path = self._caret_path - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_caretup(self): self._set_caretdown() @@ -791,19 +900,17 @@ def _set_x(self): self._filled = False self._path = self._x_path - _plus_filled_path = Path( - np.array([(-1, -3), (+1, -3), (+1, -1), (+3, -1), (+3, +1), (+1, +1), - (+1, +3), (-1, +3), (-1, +1), (-3, +1), (-3, -1), (-1, -1), - (-1, -3)]) / 6, closed=True) - _plus_filled_path_t = Path( - np.array([(+3, 0), (+3, +1), (+1, +1), (+1, +3), - (-1, +3), (-1, +1), (-3, +1), (-3, 0), - (+3, 0)]) / 6, closed=True) + _plus_filled_path = Path._create_closed(np.array([ + (-1, -3), (+1, -3), (+1, -1), (+3, -1), (+3, +1), (+1, +1), + (+1, +3), (-1, +3), (-1, +1), (-3, +1), (-3, -1), (-1, -1)]) / 6) + _plus_filled_path_t = Path._create_closed(np.array([ + (+3, 0), (+3, +1), (+1, +1), (+1, +3), + (-1, +3), (-1, +1), (-3, +1), (-3, 0)]) / 6) def _set_plus_filled(self): self._transform = Affine2D() self._snap_threshold = 5.0 - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter if not self._half_fill(): self._path = self._plus_filled_path else: @@ -814,20 +921,17 @@ def _set_plus_filled(self): {'top': 0, 'left': 90, 'bottom': 180, 'right': 270}[fs]) self._alt_transform = self._transform.frozen().rotate_deg(180) - _x_filled_path = Path( - np.array([(-1, -2), (0, -1), (+1, -2), (+2, -1), (+1, 0), (+2, +1), - (+1, +2), (0, +1), (-1, +2), (-2, +1), (-1, 0), (-2, -1), - (-1, -2)]) / 4, - closed=True) - _x_filled_path_t = Path( - np.array([(+1, 0), (+2, +1), (+1, +2), (0, +1), - (-1, +2), (-2, +1), (-1, 0), (+1, 0)]) / 4, - closed=True) + _x_filled_path = Path._create_closed(np.array([ + (-1, -2), (0, -1), (+1, -2), (+2, -1), (+1, 0), (+2, +1), + (+1, +2), (0, +1), (-1, +2), (-2, +1), (-1, 0), (-2, -1)]) / 4) + _x_filled_path_t = Path._create_closed(np.array([ + (+1, 0), (+2, +1), (+1, +2), (0, +1), + (-1, +2), (-2, +1), (-1, 0)]) / 4) def _set_x_filled(self): self._transform = Affine2D() self._snap_threshold = 5.0 - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter if not self._half_fill(): self._path = self._x_filled_path else: diff --git a/lib/matplotlib/mathtext.py b/lib/matplotlib/mathtext.py index 0a683fefcf77..fc677e83616e 100644 --- a/lib/matplotlib/mathtext.py +++ b/lib/matplotlib/mathtext.py @@ -17,38 +17,24 @@ from collections import namedtuple import functools -from io import StringIO import logging -import types import numpy as np -from PIL import Image -from matplotlib import ( - _api, colors as mcolors, rcParams, _mathtext, _mathtext_data) +import matplotlib as mpl +from matplotlib import _api, _mathtext from matplotlib.ft2font import FT2Image, LOAD_NO_HINTING from matplotlib.font_manager import FontProperties +from ._mathtext import ( # noqa: reexported API + RasterParse, VectorParse, get_unicode_index) _log = logging.getLogger(__name__) -@_api.caching_module_getattr -class __getattr__: - locals().update({ - name: _api.deprecated("3.4")( - property(lambda self, _mod=mod, _name=name: getattr(_mod, _name))) - for mod, names in [ - (_mathtext, ["SHRINK_FACTOR", "GROW_FACTOR", "NUM_SIZE_LEVELS"]), - (_mathtext_data, [ - "latex_to_bakoma", "latex_to_cmex", "latex_to_standard", - "stix_virtual_fonts", "tex2uni"])] - for name in names}) - - -get_unicode_index = _mathtext.get_unicode_index get_unicode_index.__module__ = __name__ +@_api.deprecated("3.6") class MathtextBackend: """ The base class for the mathtext backend-specific code. `MathtextBackend` @@ -104,6 +90,7 @@ def get_hinting_type(self): return LOAD_NO_HINTING +@_api.deprecated("3.6") class MathtextBackendAgg(MathtextBackend): """ Render glyphs and rectangles to an FTImage buffer, which is later @@ -137,7 +124,7 @@ def render_glyph(self, ox, oy, info): else: info.font.draw_glyph_to_bitmap( self.image, ox, oy - info.metrics.iceberg, info.glyph, - antialiased=rcParams['text.antialiased']) + antialiased=mpl.rcParams['text.antialiased']) def render_rect_filled(self, x1, y1, x2, y2): if self.mode == 'bbox': @@ -151,150 +138,17 @@ def render_rect_filled(self, x1, y1, x2, y2): y = int(y1) self.image.draw_rect_filled(int(x1), y, np.ceil(x2), y + height) - def get_results(self, box, used_characters): - self.mode = 'bbox' - orig_height = box.height - orig_depth = box.depth - _mathtext.ship(0, 0, box) - bbox = self.bbox - bbox = [bbox[0] - 1, bbox[1] - 1, bbox[2] + 1, bbox[3] + 1] - self.mode = 'render' - self.set_canvas_size( - bbox[2] - bbox[0], - (bbox[3] - bbox[1]) - orig_depth, - (bbox[3] - bbox[1]) - orig_height) - _mathtext.ship(-bbox[0], -bbox[1], box) - result = (self.ox, - self.oy, - self.width, - self.height + self.depth, - self.depth, - self.image, - used_characters) + def get_results(self, box): self.image = None - return result + self.mode = 'render' + return _mathtext.ship(box).to_raster() def get_hinting_type(self): from matplotlib.backends import backend_agg return backend_agg.get_hinting_flag() -@_api.deprecated("3.4", alternative="`.mathtext.math_to_image`") -class MathtextBackendBitmap(MathtextBackendAgg): - def get_results(self, box, used_characters): - ox, oy, width, height, depth, image, characters = \ - super().get_results(box, used_characters) - return image, depth - - -@_api.deprecated("3.4", alternative="`.MathtextBackendPath`") -class MathtextBackendPs(MathtextBackend): - """ - Store information to write a mathtext rendering to the PostScript backend. - """ - - _PSResult = namedtuple( - "_PSResult", "width height depth pswriter used_characters") - - def __init__(self): - super().__init__() - self.pswriter = StringIO() - self.lastfont = None - - def render_glyph(self, ox, oy, info): - oy = self.height - oy + info.offset - postscript_name = info.postscript_name - fontsize = info.fontsize - - if (postscript_name, fontsize) != self.lastfont: - self.lastfont = postscript_name, fontsize - self.pswriter.write( - f"/{postscript_name} findfont\n" - f"{fontsize} scalefont\n" - f"setfont\n") - - self.pswriter.write( - f"{ox:f} {oy:f} moveto\n" - f"/{info.symbol_name} glyphshow\n") - - def render_rect_filled(self, x1, y1, x2, y2): - ps = "%f %f %f %f rectfill\n" % ( - x1, self.height - y2, x2 - x1, y2 - y1) - self.pswriter.write(ps) - - def get_results(self, box, used_characters): - _mathtext.ship(0, 0, box) - return self._PSResult(self.width, - self.height + self.depth, - self.depth, - self.pswriter, - used_characters) - - -@_api.deprecated("3.4", alternative="`.MathtextBackendPath`") -class MathtextBackendPdf(MathtextBackend): - """Store information to write a mathtext rendering to the PDF backend.""" - - _PDFResult = namedtuple( - "_PDFResult", "width height depth glyphs rects used_characters") - - def __init__(self): - super().__init__() - self.glyphs = [] - self.rects = [] - - def render_glyph(self, ox, oy, info): - filename = info.font.fname - oy = self.height - oy + info.offset - self.glyphs.append( - (ox, oy, filename, info.fontsize, - info.num, info.symbol_name)) - - def render_rect_filled(self, x1, y1, x2, y2): - self.rects.append((x1, self.height - y2, x2 - x1, y2 - y1)) - - def get_results(self, box, used_characters): - _mathtext.ship(0, 0, box) - return self._PDFResult(self.width, - self.height + self.depth, - self.depth, - self.glyphs, - self.rects, - used_characters) - - -@_api.deprecated("3.4", alternative="`.MathtextBackendPath`") -class MathtextBackendSvg(MathtextBackend): - """ - Store information to write a mathtext rendering to the SVG - backend. - """ - def __init__(self): - super().__init__() - self.svg_glyphs = [] - self.svg_rects = [] - - def render_glyph(self, ox, oy, info): - oy = self.height - oy + info.offset - - self.svg_glyphs.append( - (info.font, info.fontsize, info.num, ox, oy, info.metrics)) - - def render_rect_filled(self, x1, y1, x2, y2): - self.svg_rects.append( - (x1, self.height - y1 + 1, x2 - x1, y2 - y1)) - - def get_results(self, box, used_characters): - _mathtext.ship(0, 0, box) - svg_elements = types.SimpleNamespace(svg_glyphs=self.svg_glyphs, - svg_rects=self.svg_rects) - return (self.width, - self.height + self.depth, - self.depth, - svg_elements, - used_characters) - - +@_api.deprecated("3.6") class MathtextBackendPath(MathtextBackend): """ Store information to write a mathtext rendering to the text path @@ -315,85 +169,21 @@ def render_glyph(self, ox, oy, info): def render_rect_filled(self, x1, y1, x2, y2): self.rects.append((x1, self.height - y2, x2 - x1, y2 - y1)) - def get_results(self, box, used_characters): - _mathtext.ship(0, 0, box) - return self._Result(self.width, - self.height + self.depth, - self.depth, - self.glyphs, - self.rects) - - -@_api.deprecated("3.4", alternative="`.MathtextBackendPath`") -class MathtextBackendCairo(MathtextBackend): - """ - Store information to write a mathtext rendering to the Cairo - backend. - """ - - def __init__(self): - super().__init__() - self.glyphs = [] - self.rects = [] - - def render_glyph(self, ox, oy, info): - oy = oy - info.offset - self.height - thetext = chr(info.num) - self.glyphs.append( - (info.font, info.fontsize, thetext, ox, oy)) - - def render_rect_filled(self, x1, y1, x2, y2): - self.rects.append( - (x1, y1 - self.height, x2 - x1, y2 - y1)) - - def get_results(self, box, used_characters): - _mathtext.ship(0, 0, box) - return (self.width, - self.height + self.depth, - self.depth, - self.glyphs, - self.rects) - - -for _cls_name in [ - "Fonts", - *[c.__name__ for c in _mathtext.Fonts.__subclasses__()], - "FontConstantsBase", - *[c.__name__ for c in _mathtext.FontConstantsBase.__subclasses__()], - "Node", - *[c.__name__ for c in _mathtext.Node.__subclasses__()], - "Ship", "Parser", -]: - globals()[_cls_name] = _api.deprecated("3.4")( - type(_cls_name, (getattr(_mathtext, _cls_name),), {})) + def get_results(self, box): + return _mathtext.ship(box).to_vector() +@_api.deprecated("3.6") class MathTextWarning(Warning): pass -@_api.deprecated("3.4") -def ship(ox, oy, box): - _mathtext.ship(ox, oy, box) - - ############################################################################## # MAIN class MathTextParser: _parser = None - - _backend_mapping = { - 'bitmap': MathtextBackendBitmap, - 'agg': MathtextBackendAgg, - 'ps': MathtextBackendPs, - 'pdf': MathtextBackendPdf, - 'svg': MathtextBackendSvg, - 'path': MathtextBackendPath, - 'cairo': MathtextBackendCairo, - 'macosx': MathtextBackendAgg, - } _font_type_mapping = { 'cm': _mathtext.BakomaFonts, 'dejavuserif': _mathtext.DejaVuSerifFonts, @@ -404,10 +194,20 @@ class MathTextParser: } def __init__(self, output): - """Create a MathTextParser for the given backend *output*.""" - self._output = output.lower() + """ + Create a MathTextParser for the given backend *output*. + + Parameters + ---------- + output : {"path", "agg"} + Whether to return a `VectorParse` ("path") or a + `RasterParse` ("agg", or its synonym "macosx"). + """ + self._output_type = _api.check_getitem( + {"path": "vector", "agg": "raster", "macosx": "raster"}, + output=output.lower()) - def parse(self, s, dpi=72, prop=None, *, _force_standard_ps_fonts=False): + def parse(self, s, dpi=72, prop=None): """ Parse the given math expression *s* at the given *dpi*. If *prop* is provided, it is a `.FontProperties` object specifying the "default" @@ -415,159 +215,45 @@ def parse(self, s, dpi=72, prop=None, *, _force_standard_ps_fonts=False): The results are cached, so multiple calls to `parse` with the same expression should be fast. + + Depending on the *output* type, this returns either a `VectorParse` or + a `RasterParse`. """ - if _force_standard_ps_fonts: - _api.warn_deprecated( - "3.4", - removal="3.5", - message=( - "Mathtext using only standard PostScript fonts has " - "been likely to produce wrong output for a while, " - "has been deprecated in %(since)s and will be removed " - "in %(removal)s, after which ps.useafm will have no " - "effect on mathtext." - ) - ) - - # lru_cache can't decorate parse() directly because the ps.useafm and - # mathtext.fontset rcParams also affect the parse (e.g. by affecting - # the glyph metrics). - return self._parse_cached(s, dpi, prop, _force_standard_ps_fonts) + # lru_cache can't decorate parse() directly because prop + # is mutable; key the cache using an internal copy (see + # text._get_text_metrics_with_cache for a similar case). + prop = prop.copy() if prop is not None else None + return self._parse_cached(s, dpi, prop) @functools.lru_cache(50) - def _parse_cached(self, s, dpi, prop, force_standard_ps_fonts): + def _parse_cached(self, s, dpi, prop): + from matplotlib.backends import backend_agg + if prop is None: prop = FontProperties() - - fontset_class = ( - _mathtext.StandardPsFonts if force_standard_ps_fonts - else _api.check_getitem( - self._font_type_mapping, fontset=prop.get_math_fontfamily())) - backend = self._backend_mapping[self._output]() - font_output = fontset_class(prop, backend) + fontset_class = _api.check_getitem( + self._font_type_mapping, fontset=prop.get_math_fontfamily()) + load_glyph_flags = { + "vector": LOAD_NO_HINTING, + "raster": backend_agg.get_hinting_flag(), + }[self._output_type] + fontset = fontset_class(prop, load_glyph_flags) fontsize = prop.get_size_in_points() - # This is a class variable so we don't rebuild the parser - # with each request. - if self._parser is None: + if self._parser is None: # Cache the parser globally. self.__class__._parser = _mathtext.Parser() - box = self._parser.parse(s, font_output, fontsize, dpi) - font_output.set_canvas_size(box.width, box.height, box.depth) - return font_output.get_results(box) - - @_api.deprecated("3.4", alternative="`.mathtext.math_to_image`") - def to_mask(self, texstr, dpi=120, fontsize=14): - r""" - Convert a mathtext string to a grayscale array and depth. - - Parameters - ---------- - texstr : str - A valid mathtext string, e.g., r'IQ: $\sigma_i=15$'. - dpi : float - The dots-per-inch setting used to render the text. - fontsize : int - The font size in points - - Returns - ------- - array : 2D uint8 alpha - Mask array of rasterized tex. - depth : int - Offset of the baseline from the bottom of the image, in pixels. - """ - assert self._output == "bitmap" - prop = FontProperties(size=fontsize) - ftimage, depth = self.parse(texstr, dpi=dpi, prop=prop) - return np.asarray(ftimage), depth - - @_api.deprecated("3.4", alternative="`.mathtext.math_to_image`") - def to_rgba(self, texstr, color='black', dpi=120, fontsize=14): - r""" - Convert a mathtext string to an RGBA array and depth. - - Parameters - ---------- - texstr : str - A valid mathtext string, e.g., r'IQ: $\sigma_i=15$'. - color : color - The text color. - dpi : float - The dots-per-inch setting used to render the text. - fontsize : int - The font size in points. - - Returns - ------- - array : (M, N, 4) array - RGBA color values of rasterized tex, colorized with *color*. - depth : int - Offset of the baseline from the bottom of the image, in pixels. - """ - x, depth = self.to_mask(texstr, dpi=dpi, fontsize=fontsize) + box = self._parser.parse(s, fontset, fontsize, dpi) + output = _mathtext.ship(box) + if self._output_type == "vector": + return output.to_vector() + elif self._output_type == "raster": + return output.to_raster() - r, g, b, a = mcolors.to_rgba(color) - RGBA = np.zeros((x.shape[0], x.shape[1], 4), dtype=np.uint8) - RGBA[:, :, 0] = 255 * r - RGBA[:, :, 1] = 255 * g - RGBA[:, :, 2] = 255 * b - RGBA[:, :, 3] = x - return RGBA, depth - @_api.deprecated("3.4", alternative="`.mathtext.math_to_image`") - def to_png(self, filename, texstr, color='black', dpi=120, fontsize=14): - r""" - Render a tex expression to a PNG file. - - Parameters - ---------- - filename - A writable filename or fileobject. - texstr : str - A valid mathtext string, e.g., r'IQ: $\sigma_i=15$'. - color : color - The text color. - dpi : float - The dots-per-inch setting used to render the text. - fontsize : int - The font size in points. - - Returns - ------- - int - Offset of the baseline from the bottom of the image, in pixels. - """ - rgba, depth = self.to_rgba( - texstr, color=color, dpi=dpi, fontsize=fontsize) - Image.fromarray(rgba).save(filename, format="png") - return depth - - @_api.deprecated("3.4", alternative="`.mathtext.math_to_image`") - def get_depth(self, texstr, dpi=120, fontsize=14): - r""" - Get the depth of a mathtext string. - - Parameters - ---------- - texstr : str - A valid mathtext string, e.g., r'IQ: $\sigma_i=15$'. - dpi : float - The dots-per-inch setting used to render the text. - - Returns - ------- - int - Offset of the baseline from the bottom of the image, in pixels. - """ - assert self._output == "bitmap" - prop = FontProperties(size=fontsize) - ftimage, depth = self.parse(texstr, dpi=dpi, prop=prop) - return depth - - -def math_to_image(s, filename_or_obj, prop=None, dpi=None, format=None): +def math_to_image(s, filename_or_obj, prop=None, dpi=None, format=None, + *, color=None): """ Given a math expression, renders it in a closely-clipped bounding box to an image file. @@ -586,20 +272,16 @@ def math_to_image(s, filename_or_obj, prop=None, dpi=None, format=None): format : str, optional The output format, e.g., 'svg', 'pdf', 'ps' or 'png'. If not set, the format is determined as for `.Figure.savefig`. + color : str, optional + Foreground color, defaults to :rc:`text.color`. """ from matplotlib import figure - # backend_agg supports all of the core output formats - from matplotlib.backends import backend_agg - - if prop is None: - prop = FontProperties() parser = MathTextParser('path') width, height, depth, _, _ = parser.parse(s, dpi=72, prop=prop) fig = figure.Figure(figsize=(width / 72.0, height / 72.0)) - fig.text(0, depth/height, s, fontproperties=prop) - backend_agg.FigureCanvasAgg(fig) + fig.text(0, depth/height, s, fontproperties=prop, color=color) fig.savefig(filename_or_obj, dpi=dpi, format=format) return depth diff --git a/lib/matplotlib/mlab.py b/lib/matplotlib/mlab.py index 09b6c6581fba..059cf0f1624b 100644 --- a/lib/matplotlib/mlab.py +++ b/lib/matplotlib/mlab.py @@ -55,14 +55,12 @@ import numpy as np -from matplotlib import _api -import matplotlib.cbook as cbook -from matplotlib import docstring +from matplotlib import _api, _docstring, cbook def window_hanning(x): """ - Return x times the hanning window of len(x). + Return *x* times the Hanning (or Hann) window of len(*x*). See Also -------- @@ -73,7 +71,7 @@ def window_hanning(x): def window_none(x): """ - No window function; simply return x. + No window function; simply return *x*. See Also -------- @@ -84,7 +82,7 @@ def window_none(x): def detrend(x, key=None, axis=None): """ - Return x with its trend removed. + Return *x* with its trend removed. Parameters ---------- @@ -133,7 +131,7 @@ def detrend(x, key=None, axis=None): def detrend_mean(x, axis=None): """ - Return x minus the mean(x). + Return *x* minus the mean(*x*). Parameters ---------- @@ -142,7 +140,7 @@ def detrend_mean(x, axis=None): Can have any dimensionality axis : int - The axis along which to take the mean. See numpy.mean for a + The axis along which to take the mean. See `numpy.mean` for a description of this argument. See Also @@ -161,7 +159,7 @@ def detrend_mean(x, axis=None): def detrend_none(x, axis=None): """ - Return x: no detrending. + Return *x*: no detrending. Parameters ---------- @@ -183,7 +181,7 @@ def detrend_none(x, axis=None): def detrend_linear(y): """ - Return x minus best fit line; 'linear' detrending. + Return *x* minus best fit line; 'linear' detrending. Parameters ---------- @@ -215,9 +213,10 @@ def detrend_linear(y): return y - (b*x + a) +@_api.deprecated("3.6") def stride_windows(x, n, noverlap=None, axis=0): """ - Get all windows of x with length n as a single array, + Get all windows of *x* with length *n* as a single array, using strides to avoid data duplication. .. warning:: @@ -246,6 +245,18 @@ def stride_windows(x, n, noverlap=None, axis=0): """ if noverlap is None: noverlap = 0 + if np.ndim(x) != 1: + raise ValueError('only 1-dimensional arrays can be used') + return _stride_windows(x, n, noverlap, axis) + + +def _stride_windows(x, n, noverlap=0, axis=0): + # np>=1.20 provides sliding_window_view, and we only ever use axis=0. + if hasattr(np.lib.stride_tricks, "sliding_window_view") and axis == 0: + if noverlap >= n: + raise ValueError('noverlap must be less than n') + return np.lib.stride_tricks.sliding_window_view( + x, n, axis=0)[::n - noverlap].T if noverlap >= n: raise ValueError('noverlap must be less than n') @@ -254,13 +265,11 @@ def stride_windows(x, n, noverlap=None, axis=0): x = np.asarray(x) - if x.ndim != 1: - raise ValueError('only 1-dimensional arrays can be used') if n == 1 and noverlap == 0: if axis == 0: return x[np.newaxis] else: - return x[np.newaxis].transpose() + return x[np.newaxis].T if n > x.size: raise ValueError('n cannot be greater than the length of x') @@ -370,7 +379,7 @@ def _spectral_helper(x, y=None, NFFT=None, Fs=None, detrend_func=None, raise ValueError( "The window length must match the data's first dimension") - result = stride_windows(x, NFFT, noverlap, axis=0) + result = _stride_windows(x, NFFT, noverlap) result = detrend(result, detrend_func, axis=0) result = result * window.reshape((-1, 1)) result = np.fft.fft(result, n=pad_to, axis=0)[:numFreqs, :] @@ -378,7 +387,7 @@ def _spectral_helper(x, y=None, NFFT=None, Fs=None, detrend_func=None, if not same_data: # if same_data is False, mode must be 'psd' - resultY = stride_windows(y, NFFT, noverlap) + resultY = _stride_windows(y, NFFT, noverlap) resultY = detrend(resultY, detrend_func, axis=0) resultY = resultY * window.reshape((-1, 1)) resultY = np.fft.fft(resultY, n=pad_to, axis=0)[:numFreqs, :] @@ -386,12 +395,12 @@ def _spectral_helper(x, y=None, NFFT=None, Fs=None, detrend_func=None, elif mode == 'psd': result = np.conj(result) * result elif mode == 'magnitude': - result = np.abs(result) / np.abs(window).sum() + result = np.abs(result) / window.sum() elif mode == 'angle' or mode == 'phase': # we unwrap the phase later to handle the onesided vs. twosided case result = np.angle(result) elif mode == 'complex': - result /= np.abs(window).sum() + result /= window.sum() if mode == 'psd': @@ -415,10 +424,10 @@ def _spectral_helper(x, y=None, NFFT=None, Fs=None, detrend_func=None, result /= Fs # Scale the spectrum by the norm of the window to compensate for # windowing loss; see Bendat & Piersol Sec 11.5.2. - result /= (np.abs(window)**2).sum() + result /= (window**2).sum() else: # In this case, preserve power in the segment, not amplitude - result /= np.abs(window).sum()**2 + result /= window.sum()**2 t = np.arange(NFFT/2, len(x) - NFFT/2 + 1, NFFT - noverlap)/Fs @@ -464,7 +473,7 @@ def _single_spectrum_helper( # Split out these keyword docs so that they can be used elsewhere -docstring.interpd.update( +_docstring.interpd.update( Spectral="""\ Fs : float, default: 2 The sampling frequency (samples per time unit). It is used to calculate @@ -488,8 +497,8 @@ def _single_spectrum_helper( the FFT. While not increasing the actual resolution of the spectrum (the minimum distance between resolvable peaks), this can give more points in the plot, allowing for more detail. This corresponds to the *n* parameter - in the call to fft(). The default is None, which sets *pad_to* equal to - the length of the input signal (i.e. no padding).""", + in the call to `~numpy.fft.fft`. The default is None, which sets *pad_to* + equal to the length of the input signal (i.e. no padding).""", PSD="""\ pad_to : int, optional @@ -498,8 +507,8 @@ def _single_spectrum_helper( of data points used. While not increasing the actual resolution of the spectrum (the minimum distance between resolvable peaks), this can give more points in the plot, allowing for more detail. This corresponds to - the *n* parameter in the call to fft(). The default is None, which sets - *pad_to* equal to *NFFT* + the *n* parameter in the call to `~numpy.fft.fft`. The default is None, + which sets *pad_to* equal to *NFFT* NFFT : int, default: 256 The number of data points used in each block for the FFT. A power 2 is @@ -517,12 +526,12 @@ def _single_spectrum_helper( scale_by_freq : bool, default: True Whether the resulting density values should be scaled by the scaling - frequency, which gives density in units of Hz^-1. This allows for + frequency, which gives density in units of 1/Hz. This allows for integration over the returned frequency values. The default is True for MATLAB compatibility.""") -@docstring.dedent_interpd +@_docstring.dedent_interpd def psd(x, NFFT=None, Fs=None, detrend=None, window=None, noverlap=None, pad_to=None, sides=None, scale_by_freq=None): r""" @@ -578,7 +587,7 @@ def psd(x, NFFT=None, Fs=None, detrend=None, window=None, return Pxx.real, freqs -@docstring.dedent_interpd +@_docstring.dedent_interpd def csd(x, y, NFFT=None, Fs=None, detrend=None, window=None, noverlap=None, pad_to=None, sides=None, scale_by_freq=None): """ @@ -683,33 +692,33 @@ def csd(x, y, NFFT=None, Fs=None, detrend=None, window=None, complex_spectrum = functools.partial(_single_spectrum_helper, "complex") complex_spectrum.__doc__ = _single_spectrum_docs.format( quantity="complex-valued frequency spectrum", - **docstring.interpd.params) + **_docstring.interpd.params) magnitude_spectrum = functools.partial(_single_spectrum_helper, "magnitude") magnitude_spectrum.__doc__ = _single_spectrum_docs.format( quantity="magnitude (absolute value) of the frequency spectrum", - **docstring.interpd.params) + **_docstring.interpd.params) angle_spectrum = functools.partial(_single_spectrum_helper, "angle") angle_spectrum.__doc__ = _single_spectrum_docs.format( quantity="angle of the frequency spectrum (wrapped phase spectrum)", - **docstring.interpd.params) + **_docstring.interpd.params) phase_spectrum = functools.partial(_single_spectrum_helper, "phase") phase_spectrum.__doc__ = _single_spectrum_docs.format( quantity="phase of the frequency spectrum (unwrapped phase spectrum)", - **docstring.interpd.params) + **_docstring.interpd.params) -@docstring.dedent_interpd +@_docstring.dedent_interpd def specgram(x, NFFT=None, Fs=None, detrend=None, window=None, noverlap=None, pad_to=None, sides=None, scale_by_freq=None, mode=None): """ Compute a spectrogram. - Compute and plot a spectrogram of data in x. Data are split into - NFFT length segments and the spectrum of each section is - computed. The windowing function window is applied to each + Compute and plot a spectrogram of data in *x*. Data are split into + *NFFT* length segments and the spectrum of each section is + computed. The windowing function *window* is applied to each segment, and the amount of overlap of each segment is - specified with noverlap. + specified with *noverlap*. Parameters ---------- @@ -751,13 +760,13 @@ def specgram(x, NFFT=None, Fs=None, detrend=None, window=None, -------- psd : differs in the overlap and in the return values. complex_spectrum : similar, but with complex valued frequencies. - magnitude_spectrum : similar single segment when mode is 'magnitude'. - angle_spectrum : similar to single segment when mode is 'angle'. - phase_spectrum : similar to single segment when mode is 'phase'. + magnitude_spectrum : similar single segment when *mode* is 'magnitude'. + angle_spectrum : similar to single segment when *mode* is 'angle'. + phase_spectrum : similar to single segment when *mode* is 'phase'. Notes ----- - detrend and scale_by_freq only apply when *mode* is set to 'psd'. + *detrend* and *scale_by_freq* only apply when *mode* is set to 'psd'. """ if noverlap is None: @@ -781,7 +790,7 @@ def specgram(x, NFFT=None, Fs=None, detrend=None, window=None, return spec, freqs, t -@docstring.dedent_interpd +@_docstring.dedent_interpd def cohere(x, y, NFFT=256, Fs=2, detrend=detrend_none, window=window_hanning, noverlap=0, pad_to=None, sides='default', scale_by_freq=None): r""" @@ -840,7 +849,6 @@ class GaussianKDE: dataset : array-like Datapoints to estimate from. In case of univariate data this is a 1-D array, otherwise a 2D array with shape (# of dims, # of data). - bw_method : str, scalar or callable, optional The method used to calculate the estimator bandwidth. This can be 'scott', 'silverman', a scalar constant or a callable. If a @@ -851,22 +859,17 @@ class GaussianKDE: Attributes ---------- dataset : ndarray - The dataset with which `gaussian_kde` was initialized. - + The dataset passed to the constructor. dim : int Number of dimensions. - num_dp : int Number of datapoints. - factor : float The bandwidth factor, obtained from `kde.covariance_factor`, with which the covariance matrix is multiplied. - covariance : ndarray The covariance matrix of *dataset*, scaled by the calculated bandwidth (`kde.factor`). - inv_cov : ndarray The inverse of *covariance*. @@ -874,10 +877,8 @@ class GaussianKDE: ------- kde.evaluate(points) : ndarray Evaluate the estimated pdf on a provided set of points. - kde(points) : ndarray Same as kde.evaluate(points) - """ # This implementation with minor modification was too good to pass up. diff --git a/lib/matplotlib/mpl-data/images/matplotlib_128.ppm b/lib/matplotlib/mpl-data/images/matplotlib_128.ppm deleted file mode 100644 index d9a647b08a5a..000000000000 --- a/lib/matplotlib/mpl-data/images/matplotlib_128.ppm +++ /dev/null @@ -1,4 +0,0 @@ -P6 -128 128 -255 -ܩϑÄ}}éᚷj 1: - sep = (total - sum(w_list)) / (len(w_list) - 1) + if len(widths) > 1: + sep = (total - sum(widths)) / (len(widths) - 1) else: sep = 0 - offsets_ = np.cumsum([0] + [w + sep for w in w_list]) + offsets_ = np.cumsum([0] + [w + sep for w in widths]) offsets = offsets_[:-1] return total, offsets elif mode == "equal": - maxh = max(w_list) + maxh = max(widths) if total is None: if sep is None: raise ValueError("total and sep cannot both be None when " "using layout mode 'equal'") - total = (maxh + sep) * len(w_list) + total = (maxh + sep) * len(widths) else: - sep = total / len(w_list) - maxh - offsets = (maxh + sep) * np.arange(len(w_list)) + sep = total / len(widths) - maxh + offsets = (maxh + sep) * np.arange(len(widths)) return total, offsets -def _get_aligned_offsets(hd_list, height, align="baseline"): +def _get_aligned_offsets(yspans, height, align="baseline"): """ - Align boxes each specified by their ``(height, descent)`` pair. + Align boxes each specified by their ``(y0, y1)`` spans. For simplicity of the description, the terminology used here assumes a horizontal layout (i.e., vertical alignment), but the function works @@ -140,46 +164,45 @@ def _get_aligned_offsets(hd_list, height, align="baseline"): Parameters ---------- - hd_list - List of (height, xdescent) of boxes to be aligned. + yspans + List of (y0, y1) spans of boxes to be aligned. height : float or None - Intended total height. If None, the maximum of the heights in *hd_list* - is used. + Intended total height. If None, the maximum of the heights + (``y1 - y0``) in *yspans* is used. align : {'baseline', 'left', 'top', 'right', 'bottom', 'center'} The alignment anchor of the boxes. Returns ------- - height - The total height of the packing (if a value was originally passed in, - it is returned without checking that it is actually large enough). + (y0, y1) + y range spanned by the packing. If a *height* was originally passed + in, then for all alignments other than "baseline", a span of ``(0, + height)`` is used without checking that it is actually large enough). descent The descent of the packing. offsets The bottom offsets of the boxes. """ - if height is None: - height = max(h for h, d in hd_list) _api.check_in_list( ["baseline", "left", "top", "right", "bottom", "center"], align=align) + if height is None: + height = max(y1 - y0 for y0, y1 in yspans) if align == "baseline": - height_descent = max(h - d for h, d in hd_list) - descent = max(d for h, d in hd_list) - height = height_descent + descent - offsets = [0. for h, d in hd_list] - elif align in ["left", "top"]: - descent = 0. - offsets = [d for h, d in hd_list] - elif align in ["right", "bottom"]: - descent = 0. - offsets = [height - h + d for h, d in hd_list] + yspan = (min(y0 for y0, y1 in yspans), max(y1 for y0, y1 in yspans)) + offsets = [0] * len(yspans) + elif align in ["left", "bottom"]: + yspan = (0, height) + offsets = [-y0 for y0, y1 in yspans] + elif align in ["right", "top"]: + yspan = (0, height) + offsets = [height - y1 for y0, y1 in yspans] elif align == "center": - descent = 0. - offsets = [(height - h) * .5 + d for h, d in hd_list] + yspan = (0, height) + offsets = [(height - (y1 - y0)) * .5 - y0 for y0, y1 in yspans] - return height, descent, offsets + return yspan, offsets class OffsetBox(martist.Artist): @@ -193,7 +216,7 @@ class OffsetBox(martist.Artist): """ def __init__(self, *args, **kwargs): super().__init__(*args) - self.update(kwargs) + self._internal_update(kwargs) # Clipping has not been implemented in the OffsetBox family, so # disable the clip flag for consistency. It can always be turned back # on to zero effect. @@ -272,7 +295,8 @@ def offset(width, height, xdescent, ydescent, renderer) \ self._offset = xy self.stale = True - def get_offset(self, width, height, xdescent, ydescent, renderer): + @_compat_get_offset + def get_offset(self, bbox, renderer): """ Return the offset as a tuple (x, y). @@ -282,14 +306,13 @@ def get_offset(self, width, height, xdescent, ydescent, renderer): Parameters ---------- - width, height, xdescent, ydescent - Extent parameters. + bbox : `.Bbox` renderer : `.RendererBase` subclass - """ - return (self._offset(width, height, xdescent, ydescent, renderer) - if callable(self._offset) - else self._offset) + return ( + self._offset(bbox.width, bbox.height, -bbox.x0, -bbox.y0, renderer) + if callable(self._offset) + else self._offset) def set_width(self, width): """ @@ -321,6 +344,30 @@ def get_children(self): r"""Return a list of the child `.Artist`\s.""" return self._children + def _get_bbox_and_child_offsets(self, renderer): + """ + Return the bbox of the offsetbox and the child offsets. + + The bbox should satisfy ``x0 <= x1 and y0 <= y1``. + + Parameters + ---------- + renderer : `.RendererBase` subclass + + Returns + ------- + bbox + list of (xoffset, yoffset) pairs + """ + raise NotImplementedError( + "get_bbox_and_offsets must be overridden in derived classes") + + def get_bbox(self, renderer): + """Return the bbox of the offsetbox, ignoring parent offsets.""" + bbox, offsets = self._get_bbox_and_child_offsets(renderer) + return bbox + + @_api.deprecated("3.7", alternative="get_bbox and child.get_offset") def get_extent_offsets(self, renderer): """ Update offset of the children and return the extent of the box. @@ -337,48 +384,50 @@ def get_extent_offsets(self, renderer): ydescent list of (xoffset, yoffset) pairs """ - raise NotImplementedError( - "get_extent_offsets must be overridden in derived classes.") + bbox, offsets = self._get_bbox_and_child_offsets(renderer) + return bbox.width, bbox.height, -bbox.x0, -bbox.y0, offsets + @_api.deprecated("3.7", alternative="get_bbox") def get_extent(self, renderer): """Return a tuple ``width, height, xdescent, ydescent`` of the box.""" - w, h, xd, yd, offsets = self.get_extent_offsets(renderer) - return w, h, xd, yd + bbox = self.get_bbox(renderer) + return bbox.width, bbox.height, -bbox.x0, -bbox.y0 - def get_window_extent(self, renderer): + def get_window_extent(self, renderer=None): # docstring inherited - w, h, xd, yd, offsets = self.get_extent_offsets(renderer) - px, py = self.get_offset(w, h, xd, yd, renderer) - return mtransforms.Bbox.from_bounds(px - xd, py - yd, w, h) + if renderer is None: + renderer = self.figure._get_renderer() + bbox = self.get_bbox(renderer) + try: # Some subclasses redefine get_offset to take no args. + px, py = self.get_offset(bbox, renderer) + except TypeError: + px, py = self.get_offset() + return bbox.translated(px, py) def draw(self, renderer): """ Update the location of children if necessary and draw them to the given *renderer*. """ - width, height, xdescent, ydescent, offsets = self.get_extent_offsets( - renderer) - - px, py = self.get_offset(width, height, xdescent, ydescent, renderer) - + bbox, offsets = self._get_bbox_and_child_offsets(renderer) + px, py = self.get_offset(bbox, renderer) for c, (ox, oy) in zip(self.get_visible_children(), offsets): c.set_offset((px + ox, py + oy)) c.draw(renderer) - - bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) + _bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) self.stale = False class PackerBase(OffsetBox): - def __init__(self, pad=None, sep=None, width=None, height=None, + def __init__(self, pad=0., sep=0., width=None, height=None, align="baseline", mode="fixed", children=None): """ Parameters ---------- - pad : float, optional + pad : float, default: 0.0 The boundary padding in points. - sep : float, optional + sep : float, default: 0.0 The spacing between items in points. width, height : float, optional @@ -422,7 +471,7 @@ class VPacker(PackerBase): relative positions at draw time. """ - def get_extent_offsets(self, renderer): + def _get_bbox_and_child_offsets(self, renderer): # docstring inherited dpicor = renderer.points_to_pixels(1.) pad = self.pad * dpicor @@ -433,28 +482,19 @@ def get_extent_offsets(self, renderer): if isinstance(c, PackerBase) and c.mode == "expand": c.set_width(self.width) - whd_list = [c.get_extent(renderer) - for c in self.get_visible_children()] - whd_list = [(w, h, xd, (h - yd)) for w, h, xd, yd in whd_list] - - wd_list = [(w, xd) for w, h, xd, yd in whd_list] - width, xdescent, xoffsets = _get_aligned_offsets(wd_list, - self.width, - self.align) - - pack_list = [(h, yd) for w, h, xd, yd in whd_list] - height, yoffsets_ = _get_packed_offsets(pack_list, self.height, - sep, self.mode) - - yoffsets = yoffsets_ + [yd for w, h, xd, yd in whd_list] - ydescent = height - yoffsets[0] - yoffsets = height - yoffsets + bboxes = [c.get_bbox(renderer) for c in self.get_visible_children()] + (x0, x1), xoffsets = _get_aligned_offsets( + [bbox.intervalx for bbox in bboxes], self.width, self.align) + height, yoffsets = _get_packed_offsets( + [bbox.height for bbox in bboxes], self.height, sep, self.mode) + yoffsets = height - (yoffsets + [bbox.y1 for bbox in bboxes]) + ydescent = yoffsets[0] yoffsets = yoffsets - ydescent - return (width + 2 * pad, height + 2 * pad, - xdescent + pad, ydescent + pad, - list(zip(xoffsets, yoffsets))) + return ( + Bbox.from_bounds(x0, -ydescent, x1 - x0, height).padded(pad), + [*zip(xoffsets, yoffsets)]) class HPacker(PackerBase): @@ -463,36 +503,26 @@ class HPacker(PackerBase): relative positions at draw time. """ - def get_extent_offsets(self, renderer): + def _get_bbox_and_child_offsets(self, renderer): # docstring inherited dpicor = renderer.points_to_pixels(1.) pad = self.pad * dpicor sep = self.sep * dpicor - whd_list = [c.get_extent(renderer) - for c in self.get_visible_children()] - - if not whd_list: - return 2 * pad, 2 * pad, pad, pad, [] + bboxes = [c.get_bbox(renderer) for c in self.get_visible_children()] + if not bboxes: + return Bbox.from_bounds(0, 0, 0, 0).padded(pad), [] - hd_list = [(h, yd) for w, h, xd, yd in whd_list] - height, ydescent, yoffsets = _get_aligned_offsets(hd_list, - self.height, - self.align) + (y0, y1), yoffsets = _get_aligned_offsets( + [bbox.intervaly for bbox in bboxes], self.height, self.align) + width, xoffsets = _get_packed_offsets( + [bbox.width for bbox in bboxes], self.width, sep, self.mode) - pack_list = [(w, xd) for w, h, xd, yd in whd_list] + x0 = bboxes[0].x0 + xoffsets -= ([bbox.x0 for bbox in bboxes] - x0) - width, xoffsets_ = _get_packed_offsets(pack_list, self.width, - sep, self.mode) - - xoffsets = xoffsets_ + [xd for w, h, xd, yd in whd_list] - - xdescent = whd_list[0][2] - xoffsets = xoffsets - xdescent - - return (width + 2 * pad, height + 2 * pad, - xdescent + pad, ydescent + pad, - list(zip(xoffsets, yoffsets))) + return (Bbox.from_bounds(x0, y0, width, y1 - y0).padded(pad), + [*zip(xoffsets, yoffsets)]) class PaddedBox(OffsetBox): @@ -502,15 +532,17 @@ class PaddedBox(OffsetBox): The `.PaddedBox` contains a `.FancyBboxPatch` that is used to visualize it when rendering. """ - def __init__(self, child, pad=None, draw_frame=False, patch_attrs=None): + + @_api.make_keyword_only("3.6", name="draw_frame") + def __init__(self, child, pad=0., draw_frame=False, patch_attrs=None): """ Parameters ---------- child : `~matplotlib.artist.Artist` The contained `.Artist`. - pad : float + pad : float, default: 0.0 The padding in points. This will be scaled with the renderer dpi. - In contrast *width* and *height* are in *pixels* and thus not + In contrast, *width* and *height* are in *pixels* and thus not scaled. draw_frame : bool Whether to draw the contained `.FancyBboxPatch`. @@ -531,21 +563,15 @@ def __init__(self, child, pad=None, draw_frame=False, patch_attrs=None): if patch_attrs is not None: self.patch.update(patch_attrs) - def get_extent_offsets(self, renderer): + def _get_bbox_and_child_offsets(self, renderer): # docstring inherited. - dpicor = renderer.points_to_pixels(1.) - pad = self.pad * dpicor - w, h, xd, yd = self._children[0].get_extent(renderer) - return (w + 2 * pad, h + 2 * pad, xd + pad, yd + pad, - [(0, 0)]) + pad = self.pad * renderer.points_to_pixels(1.) + return (self._children[0].get_bbox(renderer).padded(pad), [(0, 0)]) def draw(self, renderer): # docstring inherited - width, height, xdescent, ydescent, offsets = self.get_extent_offsets( - renderer) - - px, py = self.get_offset(width, height, xdescent, ydescent, renderer) - + bbox, offsets = self._get_bbox_and_child_offsets(renderer) + px, py = self.get_offset(bbox, renderer) for c, (ox, oy) in zip(self.get_visible_children(), offsets): c.set_offset((px + ox, py + oy)) @@ -557,7 +583,7 @@ def draw(self, renderer): self.stale = False def update_frame(self, bbox, fontsize=None): - self.patch.set_bounds(bbox.x0, bbox.y0, bbox.width, bbox.height) + self.patch.set_bounds(bbox.bounds) if fontsize: self.patch.set_mutation_scale(fontsize) self.stale = True @@ -576,8 +602,7 @@ class DrawingArea(OffsetBox): boundaries of the parent. """ - def __init__(self, width, height, xdescent=0., - ydescent=0., clip=False): + def __init__(self, width, height, xdescent=0., ydescent=0., clip=False): """ Parameters ---------- @@ -639,18 +664,12 @@ def get_offset(self): """Return offset of the container.""" return self._offset - def get_window_extent(self, renderer): + def get_bbox(self, renderer): # docstring inherited - w, h, xd, yd = self.get_extent(renderer) - ox, oy = self.get_offset() # w, h, xd, yd) - - return mtransforms.Bbox.from_bounds(ox - xd, oy - yd, w, h) - - def get_extent(self, renderer): - """Return width, height, xdescent, ydescent of box.""" dpi_cor = renderer.points_to_pixels(1.) - return (self.width * dpi_cor, self.height * dpi_cor, - self.xdescent * dpi_cor, self.ydescent * dpi_cor) + return Bbox.from_bounds( + -self.xdescent * dpi_cor, -self.ydescent * dpi_cor, + self.width * dpi_cor, self.height * dpi_cor) def add_artist(self, a): """Add an `.Artist` to the container box.""" @@ -683,7 +702,7 @@ def draw(self, renderer): c.set_clip_path(tpath) c.draw(renderer) - bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) + _bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) self.stale = False @@ -696,11 +715,10 @@ class TextArea(OffsetBox): child text. """ - @_api.delete_parameter("3.4", "minimumdescent") + @_api.make_keyword_only("3.6", name="textprops") def __init__(self, s, textprops=None, multilinebaseline=False, - minimumdescent=True, ): """ Parameters @@ -713,9 +731,6 @@ def __init__(self, s, multilinebaseline : bool, default: False Whether the baseline for multiline text is adjusted so that it is (approximately) center-aligned with single-line text. - minimumdescent : bool, default: True - If `True`, the box has a minimum descent of "p". This is now - effectively always True. """ if textprops is None: textprops = {} @@ -727,7 +742,6 @@ def __init__(self, s, self._text.set_transform(self.offset_transform + self._baseline_transform) self._multilinebaseline = multilinebaseline - self._minimumdescent = minimumdescent def set_text(self, s): """Set the text of this area as a string.""" @@ -756,26 +770,6 @@ def get_multilinebaseline(self): """ return self._multilinebaseline - @_api.deprecated("3.4") - def set_minimumdescent(self, t): - """ - Set minimumdescent. - - If True, extent of the single line text is adjusted so that - its descent is at least the one of the glyph "p". - """ - # The current implementation of Text._get_layout always behaves as if - # this is True. - self._minimumdescent = t - self.stale = True - - @_api.deprecated("3.4") - def get_minimumdescent(self): - """ - Get minimumdescent. - """ - return self._minimumdescent - def set_transform(self, t): """ set_transform is ignored. @@ -799,19 +793,13 @@ def get_offset(self): """Return offset of the container.""" return self._offset - def get_window_extent(self, renderer): - # docstring inherited - w, h, xd, yd = self.get_extent(renderer) - ox, oy = self.get_offset() - return mtransforms.Bbox.from_bounds(ox - xd, oy - yd, w, h) - - def get_extent(self, renderer): + def get_bbox(self, renderer): _, h_, d_ = renderer.get_text_width_height_descent( "lp", self._text._fontproperties, ismath="TeX" if self._text.get_usetex() else False) bbox, info, yd = self._text._get_layout(renderer) - w, h = bbox.width, bbox.height + w, h = bbox.size self._baseline_transform.clear() @@ -824,19 +812,14 @@ def get_extent(self, renderer): h = h_d + yd ha = self._text.get_horizontalalignment() - if ha == 'left': - xd = 0 - elif ha == 'center': - xd = w / 2 - elif ha == 'right': - xd = w + x0 = {"left": 0, "center": -w / 2, "right": -w}[ha] - return w, h, xd, yd + return Bbox.from_bounds(x0, -yd, w, h) def draw(self, renderer): # docstring inherited self._text.draw(renderer) - bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) + _bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) self.stale = False @@ -900,32 +883,25 @@ def get_offset(self): """Return offset of the container.""" return self._offset - def get_window_extent(self, renderer): - # docstring inherited - w, h, xd, yd = self.get_extent(renderer) - ox, oy = self.get_offset() # w, h, xd, yd) - return mtransforms.Bbox.from_bounds(ox - xd, oy - yd, w, h) - - def get_extent(self, renderer): + def get_bbox(self, renderer): # clear the offset transforms _off = self.offset_transform.get_matrix() # to be restored later self.ref_offset_transform.clear() self.offset_transform.clear() # calculate the extent bboxes = [c.get_window_extent(renderer) for c in self._children] - ub = mtransforms.Bbox.union(bboxes) + ub = Bbox.union(bboxes) # adjust ref_offset_transform self.ref_offset_transform.translate(-ub.x0, -ub.y0) # restore offset transform self.offset_transform.set_matrix(_off) - - return ub.width, ub.height, 0., 0. + return Bbox.from_bounds(0, 0, ub.width, ub.height) def draw(self, renderer): # docstring inherited for c in self._children: c.draw(renderer) - bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) + _bbox_artist(self, renderer, fill=False, props=dict(pad=0.)) self.stale = False @@ -953,6 +929,7 @@ class AnchoredOffsetbox(OffsetBox): 'center': 10, } + @_api.make_keyword_only("3.6", name="pad") def __init__(self, loc, pad=0.4, borderpad=0.5, child=None, prop=None, frameon=True, @@ -966,7 +943,7 @@ def __init__(self, loc, The box location. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. pad : float, default: 0.4 @@ -1004,11 +981,11 @@ def __init__(self, loc, self.pad = pad if prop is None: - self.prop = FontProperties(size=rcParams["legend.fontsize"]) + self.prop = FontProperties(size=mpl.rcParams["legend.fontsize"]) else: self.prop = FontProperties._from_any(prop) if isinstance(prop, dict) and "size" not in prop: - self.prop.set_size(rcParams["legend.fontsize"]) + self.prop.set_size(mpl.rcParams["legend.fontsize"]) self.patch = FancyBboxPatch( xy=(0.0, 0.0), width=1., height=1., @@ -1034,17 +1011,11 @@ def get_children(self): """Return the list of children.""" return [self._child] - def get_extent(self, renderer): - """ - Return the extent of the box as (width, height, x, y). - - This is the extent of the child plus the padding. - """ - w, h, xd, yd = self.get_child().get_extent(renderer) + def get_bbox(self, renderer): + # docstring inherited fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) pad = self.pad * fontsize - - return w + 2 * pad, h + 2 * pad, xd + pad, yd + pad + return self.get_child().get_bbox(renderer).padded(pad) def get_bbox_to_anchor(self): """Return the bbox that the box is anchored to.""" @@ -1055,8 +1026,7 @@ def get_bbox_to_anchor(self): if transform is None: return self._bbox_to_anchor else: - return TransformedBbox(self._bbox_to_anchor, - transform) + return TransformedBbox(self._bbox_to_anchor, transform) def set_bbox_to_anchor(self, bbox, transform=None): """ @@ -1073,8 +1043,7 @@ def set_bbox_to_anchor(self, bbox, transform=None): try: l = len(bbox) except TypeError as err: - raise ValueError("Invalid argument for bbox : %s" % - str(bbox)) from err + raise ValueError(f"Invalid bbox: {bbox}") from err if l == 2: bbox = [bbox[0], bbox[1], 0, 0] @@ -1084,34 +1053,19 @@ def set_bbox_to_anchor(self, bbox, transform=None): self._bbox_to_anchor_transform = transform self.stale = True - def get_window_extent(self, renderer): + @_compat_get_offset + def get_offset(self, bbox, renderer): # docstring inherited - self._update_offset_func(renderer) - w, h, xd, yd = self.get_extent(renderer) - ox, oy = self.get_offset(w, h, xd, yd, renderer) - return Bbox.from_bounds(ox - xd, oy - yd, w, h) - - def _update_offset_func(self, renderer, fontsize=None): - """ - Update the offset func which depends on the dpi of the - renderer (because of the padding). - """ - if fontsize is None: - fontsize = renderer.points_to_pixels( - self.prop.get_size_in_points()) - - def _offset(w, h, xd, yd, renderer): - bbox = Bbox.from_bounds(0, 0, w, h) - borderpad = self.borderpad * fontsize - bbox_to_anchor = self.get_bbox_to_anchor() - x0, y0 = _get_anchored_bbox( - self.loc, bbox, bbox_to_anchor, borderpad) - return x0 + xd, y0 + yd - - self.set_offset(_offset) + pad = (self.borderpad + * renderer.points_to_pixels(self.prop.get_size_in_points())) + bbox_to_anchor = self.get_bbox_to_anchor() + x0, y0 = _get_anchored_bbox( + self.loc, Bbox.from_bounds(0, 0, bbox.width, bbox.height), + bbox_to_anchor, pad) + return x0 - bbox.x0, y0 - bbox.y0 def update_frame(self, bbox, fontsize=None): - self.patch.set_bounds(bbox.x0, bbox.y0, bbox.width, bbox.height) + self.patch.set_bounds(bbox.bounds) if fontsize: self.patch.set_mutation_scale(fontsize) @@ -1120,18 +1074,13 @@ def draw(self, renderer): if not self.get_visible(): return - fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) - self._update_offset_func(renderer, fontsize) - # update the location and size of the legend bbox = self.get_window_extent(renderer) + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) self.update_frame(bbox, fontsize) self.patch.draw(renderer) - width, height, xdescent, ydescent = self.get_extent(renderer) - - px, py = self.get_offset(width, height, xdescent, ydescent, renderer) - + px, py = self.get_offset(self.get_bbox(renderer), renderer) self.get_child().set_offset((px, py)) self.get_child().draw(renderer) self.stale = False @@ -1146,8 +1095,7 @@ def _get_anchored_bbox(loc, bbox, parentbbox, borderpad): # validated. If 0 (None), we just let ``bbox.anchored`` raise. c = [None, "NE", "NW", "SW", "SE", "E", "W", "E", "S", "N", "C"][loc] container = parentbbox.padded(-borderpad) - anchored_box = bbox.anchored(c, container=container) - return anchored_box.x0, anchored_box.y0 + return bbox.anchored(c, container=container).p0 class AnchoredText(AnchoredOffsetbox): @@ -1155,6 +1103,7 @@ class AnchoredText(AnchoredOffsetbox): AnchoredOffsetbox with Text. """ + @_api.make_keyword_only("3.6", name="pad") def __init__(self, s, loc, pad=0.4, borderpad=0.5, prop=None, **kwargs): """ Parameters @@ -1194,6 +1143,8 @@ def __init__(self, s, loc, pad=0.4, borderpad=0.5, prop=None, **kwargs): class OffsetImage(OffsetBox): + + @_api.make_keyword_only("3.6", name="zoom") def __init__(self, arr, zoom=1, cmap=None, @@ -1248,24 +1199,13 @@ def get_offset(self): def get_children(self): return [self.image] - def get_window_extent(self, renderer): - # docstring inherited - w, h, xd, yd = self.get_extent(renderer) - ox, oy = self.get_offset() - return mtransforms.Bbox.from_bounds(ox - xd, oy - yd, w, h) - - def get_extent(self, renderer): - if self._dpi_cor: # True, do correction - dpi_cor = renderer.points_to_pixels(1.) - else: - dpi_cor = 1. - + def get_bbox(self, renderer): + dpi_cor = renderer.points_to_pixels(1.) if self._dpi_cor else 1. zoom = self.get_zoom() data = self.get_data() ny, nx = data.shape[:2] w, h = dpi_cor * nx * zoom, dpi_cor * ny * zoom - - return w, h, 0, 0 + return Bbox.from_bounds(0, 0, w, h) def draw(self, renderer): # docstring inherited @@ -1288,7 +1228,8 @@ class AnnotationBbox(martist.Artist, mtext._AnnotationBase): def __str__(self): return "AnnotationBbox(%g,%g)" % (self.xy[0], self.xy[1]) - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="xycoords") def __init__(self, offsetbox, xy, xybox=None, xycoords='data', @@ -1313,18 +1254,30 @@ def __init__(self, offsetbox, xy, The position *(x, y)* to place the text at. The coordinate system is determined by *boxcoords*. - xycoords : str or `.Artist` or `.Transform` or callable or \ -(float, float), default: 'data' + xycoords : single or two-tuple of str or `.Artist` or `.Transform` or \ +callable, default: 'data' The coordinate system that *xy* is given in. See the parameter *xycoords* in `.Annotation` for a detailed description. - boxcoords : str or `.Artist` or `.Transform` or callable or \ -(float, float), default: value of *xycoords* + boxcoords : single or two-tuple of str or `.Artist` or `.Transform` \ +or callable, default: value of *xycoords* The coordinate system that *xybox* is given in. See the parameter *textcoords* in `.Annotation` for a detailed description. frameon : bool, default: True - Whether to draw a frame around the box. + By default, the text is surrounded by a white `.FancyBboxPatch` + (accessible as the ``patch`` attribute of the `.AnnotationBbox`). + If *frameon* is set to False, this patch is made invisible. + + annotation_clip: bool or None, default: None + Whether to clip (i.e. not draw) the annotation when the annotation + point *xy* is outside the axes area. + + - If *True*, the annotation will be clipped when *xy* is outside + the axes. + - If *False*, the annotation will always be drawn. + - If *None*, the annotation will be clipped when *xy* is outside + the axes and *xycoords* is 'data'. pad : float, default: 0.4 Padding around the offsetbox. @@ -1334,31 +1287,37 @@ def __init__(self, offsetbox, xy, the offset box w.r.t. the *boxcoords*. The lower-left corner is (0, 0) and upper-right corner is (1, 1). + bboxprops : dict, optional + A dictionary of properties to set for the annotation bounding box, + for example *boxstyle* and *alpha*. See `.FancyBboxPatch` for + details. + + arrowprops: dict, optional + Arrow properties, see `.Annotation` for description. + + fontsize: float or str, optional + Translated to points and passed as *mutation_scale* into + `.FancyBboxPatch` to scale attributes of the box style (e.g. pad + or rounding_size). The name is chosen in analogy to `.Text` where + *fontsize* defines the mutation scale as well. If not given, + :rc:`legend.fontsize` is used. See `.Text.set_fontsize` for valid + values. + **kwargs - Other parameters are identical to `.Annotation`. + Other `AnnotationBbox` properties. See `.AnnotationBbox.set` for + a list. """ martist.Artist.__init__(self) - mtext._AnnotationBase.__init__(self, - xy, - xycoords=xycoords, - annotation_clip=annotation_clip) + mtext._AnnotationBase.__init__( + self, xy, xycoords=xycoords, annotation_clip=annotation_clip) self.offsetbox = offsetbox - - self.arrowprops = arrowprops - + self.arrowprops = arrowprops.copy() if arrowprops is not None else None self.set_fontsize(fontsize) - - if xybox is None: - self.xybox = xy - else: - self.xybox = xybox - - if boxcoords is None: - self.boxcoords = xycoords - else: - self.boxcoords = boxcoords + self.xybox = xybox if xybox is not None else xy + self.boxcoords = boxcoords if boxcoords is not None else xycoords + self._box_alignment = box_alignment if arrowprops is not None: self._arrow_relpos = self.arrowprops.pop("relpos", (0.5, 0.5)) @@ -1368,10 +1327,7 @@ def __init__(self, offsetbox, xy, self._arrow_relpos = None self.arrow_patch = None - self._box_alignment = box_alignment - - # frame - self.patch = FancyBboxPatch( + self.patch = FancyBboxPatch( # frame xy=(0.0, 0.0), width=1., height=1., facecolor='w', edgecolor='k', mutation_scale=self.prop.get_size_in_points(), @@ -1382,7 +1338,7 @@ def __init__(self, offsetbox, xy, if bboxprops: self.patch.set(**bboxprops) - self.update(kwargs) + self._internal_update(kwargs) @property def xyann(self): @@ -1430,7 +1386,7 @@ def set_fontsize(self, s=None): If *s* is not given, reset to :rc:`legend.fontsize`. """ if s is None: - s = rcParams["legend.fontsize"] + s = mpl.rcParams["legend.fontsize"] self.prop = FontProperties(size=s) self.stale = True @@ -1439,36 +1395,21 @@ def get_fontsize(self): """Return the fontsize in points.""" return self.prop.get_size_in_points() - def get_window_extent(self, renderer): + def get_window_extent(self, renderer=None): # docstring inherited - bboxes = [child.get_window_extent(renderer) - for child in self.get_children()] + if renderer is None: + renderer = self.figure._get_renderer() + return Bbox.union([child.get_window_extent(renderer) + for child in self.get_children()]) - return Bbox.union(bboxes) - - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer=None): # docstring inherited - bboxes = [child.get_tightbbox(renderer) - for child in self.get_children()] - - return Bbox.union(bboxes) + return Bbox.union([child.get_tightbbox(renderer) + for child in self.get_children()]) def update_positions(self, renderer): """ - Update the pixel positions of the annotated point and the text. - """ - xy_pixel = self._get_position_xy(renderer) - self._update_position_xybox(renderer, xy_pixel) - - mutation_scale = renderer.points_to_pixels(self.get_fontsize()) - self.patch.set_mutation_scale(mutation_scale) - - if self.arrow_patch: - self.arrow_patch.set_mutation_scale(mutation_scale) - - def _update_position_xybox(self, renderer, xy_pixel): - """ - Update the pixel positions of the annotation text and the arrow patch. + Update pixel positions for the annotated point, the text and the arrow. """ x, y = self.xybox @@ -1480,46 +1421,36 @@ def _update_position_xybox(self, renderer, xy_pixel): else: ox0, oy0 = self._get_xy(renderer, x, y, self.boxcoords) - w, h, xd, yd = self.offsetbox.get_extent(renderer) - - _fw, _fh = self._box_alignment - self.offsetbox.set_offset((ox0 - _fw * w + xd, oy0 - _fh * h + yd)) + bbox = self.offsetbox.get_bbox(renderer) + fw, fh = self._box_alignment + self.offsetbox.set_offset( + (ox0 - fw*bbox.width - bbox.x0, oy0 - fh*bbox.height - bbox.y0)) - # update patch position bbox = self.offsetbox.get_window_extent(renderer) - self.patch.set_bounds(bbox.x0, bbox.y0, - bbox.width, bbox.height) + self.patch.set_bounds(bbox.bounds) - x, y = xy_pixel - - ox1, oy1 = x, y + mutation_scale = renderer.points_to_pixels(self.get_fontsize()) + self.patch.set_mutation_scale(mutation_scale) if self.arrowprops: - d = self.arrowprops.copy() - # Use FancyArrowPatch if self.arrowprops has "arrowstyle" key. - # adjust the starting point of the arrow relative to - # the textbox. - # TODO : Rotation needs to be accounted. - relpos = self._arrow_relpos - - ox0 = bbox.x0 + bbox.width * relpos[0] - oy0 = bbox.y0 + bbox.height * relpos[1] - - # The arrow will be drawn from (ox0, oy0) to (ox1, - # oy1). It will be first clipped by patchA and patchB. - # Then it will be shrunk by shrinkA and shrinkB - # (in points). If patch A is not set, self.bbox_patch - # is used. - - self.arrow_patch.set_positions((ox0, oy0), (ox1, oy1)) - fs = self.prop.get_size_in_points() - mutation_scale = d.pop("mutation_scale", fs) - mutation_scale = renderer.points_to_pixels(mutation_scale) + # Adjust the starting point of the arrow relative to the textbox. + # TODO: Rotation needs to be accounted. + arrow_begin = bbox.p0 + bbox.size * self._arrow_relpos + arrow_end = self._get_position_xy(renderer) + # The arrow (from arrow_begin to arrow_end) will be first clipped + # by patchA and patchB, then shrunk by shrinkA and shrinkB (in + # points). If patch A is not set, self.bbox_patch is used. + self.arrow_patch.set_positions(arrow_begin, arrow_end) + + if "mutation_scale" in self.arrowprops: + mutation_scale = renderer.points_to_pixels( + self.arrowprops["mutation_scale"]) + # Else, use fontsize-based mutation_scale defined above. self.arrow_patch.set_mutation_scale(mutation_scale) - patchA = d.pop("patchA", self.patch) + patchA = self.arrowprops.get("patchA", self.patch) self.arrow_patch.set_patchA(patchA) def draw(self, renderer): @@ -1528,6 +1459,7 @@ def draw(self, renderer): self._renderer = renderer if not self.get_visible() or not self._check_xy(renderer): return + renderer.open_group(self.__class__.__name__, gid=self.get_gid()) self.update_positions(renderer) if self.arrow_patch is not None: if self.arrow_patch.figure is None and self.figure is not None: @@ -1535,6 +1467,7 @@ def draw(self, renderer): self.arrow_patch.draw(renderer) self.patch.draw(renderer) self.offsetbox.draw(renderer) + renderer.close_group(self.__class__.__name__) self.stale = False @@ -1575,8 +1508,10 @@ def __init__(self, ref_artist, use_blit=False): self.canvas = self.ref_artist.figure.canvas self._use_blit = use_blit and self.canvas.supports_blit self.cids = [ - self.canvas.mpl_connect('pick_event', self.on_pick), - self.canvas.mpl_connect('button_release_event', self.on_release), + self.canvas.callbacks._connect_picklable( + 'pick_event', self.on_pick), + self.canvas.callbacks._connect_picklable( + 'button_release_event', self.on_release), ] def on_motion(self, evt): @@ -1586,7 +1521,8 @@ def on_motion(self, evt): self.update_offset(dx, dy) if self._use_blit: self.canvas.restore_region(self.background) - self.ref_artist.draw(self.ref_artist.figure._cachedRenderer) + self.ref_artist.draw( + self.ref_artist.figure._get_renderer()) self.canvas.blit() else: self.canvas.draw() @@ -1601,9 +1537,10 @@ def on_pick(self, evt): self.canvas.draw() self.background = \ self.canvas.copy_from_bbox(self.ref_artist.figure.bbox) - self.ref_artist.draw(self.ref_artist.figure._cachedRenderer) + self.ref_artist.draw( + self.ref_artist.figure._get_renderer()) self.canvas.blit() - self._c1 = self.canvas.mpl_connect( + self._c1 = self.canvas.callbacks._connect_picklable( "motion_notify_event", self.on_motion) self.save_offset() @@ -1651,9 +1588,8 @@ def __init__(self, ref_artist, offsetbox, use_blit=False): def save_offset(self): offsetbox = self.offsetbox - renderer = offsetbox.figure._cachedRenderer - w, h, xd, yd = offsetbox.get_extent(renderer) - offset = offsetbox.get_offset(w, h, xd, yd, renderer) + renderer = offsetbox.figure._get_renderer() + offset = offsetbox.get_offset(offsetbox.get_bbox(renderer), renderer) self.offsetbox_x, self.offsetbox_y = offset self.offsetbox.set_offset(offset) @@ -1663,10 +1599,10 @@ def update_offset(self, dx, dy): def get_loc_in_canvas(self): offsetbox = self.offsetbox - renderer = offsetbox.figure._cachedRenderer - w, h, xd, yd = offsetbox.get_extent(renderer) + renderer = offsetbox.figure._get_renderer() + bbox = offsetbox.get_bbox(renderer) ox, oy = offsetbox._offset - loc_in_canvas = (ox - xd, oy - yd) + loc_in_canvas = (ox + bbox.x0, oy + bbox.y0) return loc_in_canvas diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 3dc875e91004..de10fd47daf3 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -2,18 +2,19 @@ Patches are `.Artist`\s with a face color and an edge color. """ -import contextlib import functools import inspect import math from numbers import Number import textwrap +from types import SimpleNamespace from collections import namedtuple +from matplotlib.transforms import Affine2D import numpy as np import matplotlib as mpl -from . import (_api, artist, cbook, colors, docstring, hatch as mhatch, +from . import (_api, artist, cbook, colors, _docstring, hatch as mhatch, lines as mlines, transforms) from .bezier import ( NonIntersectingPathException, get_cos_sin, get_intersection, @@ -23,8 +24,8 @@ from ._enums import JoinStyle, CapStyle -@docstring.interpd -@cbook._define_aliases({ +@_docstring.interpd +@_api.define_aliases({ "antialiased": ["aa"], "edgecolor": ["ec"], "facecolor": ["fc"], @@ -40,22 +41,11 @@ class Patch(artist.Artist): """ zorder = 1 - @_api.deprecated("3.4") - @_api.classproperty - def validCap(cls): - with _api.suppress_matplotlib_deprecation_warning(): - return mlines.Line2D.validCap - - @_api.deprecated("3.4") - @_api.classproperty - def validJoin(cls): - with _api.suppress_matplotlib_deprecation_warning(): - return mlines.Line2D.validJoin - # Whether to draw an edge by default. Set on a # subclass-by-subclass basis. _edge_default = False + @_api.make_keyword_only("3.6", name="edgecolor") def __init__(self, edgecolor=None, facecolor=None, @@ -75,16 +65,12 @@ def __init__(self, """ super().__init__() - if linewidth is None: - linewidth = mpl.rcParams['patch.linewidth'] if linestyle is None: linestyle = "solid" if capstyle is None: capstyle = CapStyle.butt if joinstyle is None: joinstyle = JoinStyle.miter - if antialiased is None: - antialiased = mpl.rcParams['patch.antialiased'] self._hatch_color = colors.to_rgba(mpl.rcParams['hatch.color']) self._fill = True # needed for set_facecolor call @@ -97,9 +83,10 @@ def __init__(self, else: self.set_edgecolor(edgecolor) self.set_facecolor(facecolor) - # unscaled dashes. Needed to scale dash patterns by lw - self._us_dashes = None + self._linewidth = 0 + self._unscaled_dash_pattern = (0, None) # offset, dash + self._dash_pattern = (0, None) # offset, dash (scaled by linewidth) self.set_fill(fill) self.set_linestyle(linestyle) @@ -110,13 +97,13 @@ def __init__(self, self.set_joinstyle(joinstyle) if len(kwargs): - self.update(kwargs) + self._internal_update(kwargs) def get_verts(self): """ Return a copy of the vertices used in this patch. - If the patch contains Bezier curves, the curves will be interpolated by + If the patch contains Bézier curves, the curves will be interpolated by line segments. To access the curves as curves, use `get_path`. """ trans = self.get_transform() @@ -179,7 +166,7 @@ def contains_point(self, point, radius=None): ``self.get_transform()``. These are display coordinates for patches that are added to a figure or axes. radius : float, optional - Add an additional margin on the patch in target coordinates of + Additional margin on the patch in target coordinates of ``self.get_transform()``. See `.Path.contains_point` for further details. @@ -229,7 +216,7 @@ def contains_points(self, points, radius=None): ``self.get_transform()``. These are display coordinates for patches that are added to a figure or axes. Columns contain x and y values. radius : float, optional - Add an additional margin on the patch in target coordinates of + Additional margin on the patch in target coordinates of ``self.get_transform()``. See `.Path.contains_point` for further details. @@ -259,9 +246,8 @@ def update_from(self, other): self._fill = other._fill self._hatch = other._hatch self._hatch_color = other._hatch_color - # copy the unscaled dash pattern - self._us_dashes = other._us_dashes - self.set_linewidth(other._linewidth) # also sets dash properties + self._unscaled_dash_pattern = other._unscaled_dash_pattern + self.set_linewidth(other._linewidth) # also sets scaled dashes self.set_transform(other.get_data_transform()) # If the transform of other needs further initialization, then it will # be the case for this artist too. @@ -405,14 +391,9 @@ def set_linewidth(self, w): """ if w is None: w = mpl.rcParams['patch.linewidth'] - if w is None: - w = mpl.rcParams['axes.linewidth'] - self._linewidth = float(w) - # scale the dash pattern by the linewidth - offset, ls = self._us_dashes - self._dashoffset, self._dashes = mlines._scale_dashes( - offset, ls, self._linewidth) + self._dash_pattern = mlines._scale_dashes( + *self._unscaled_dash_pattern, w) self.stale = True def set_linestyle(self, ls): @@ -445,11 +426,9 @@ def set_linestyle(self, ls): if ls in [' ', '', 'none']: ls = 'None' self._linestyle = ls - # get the unscaled dash pattern - offset, ls = self._us_dashes = mlines._get_dash_pattern(ls) - # scale the dash pattern by the linewidth - self._dashoffset, self._dashes = mlines._scale_dashes( - offset, ls, self._linewidth) + self._unscaled_dash_pattern = mlines._get_dash_pattern(ls) + self._dash_pattern = mlines._scale_dashes( + *self._unscaled_dash_pattern, self._linewidth) self.stale = True def set_fill(self, b): @@ -474,7 +453,7 @@ def get_fill(self): # attribute. fill = property(get_fill, set_fill) - @docstring.interpd + @_docstring.interpd def set_capstyle(self, s): """ Set the `.CapStyle`. @@ -492,9 +471,9 @@ def set_capstyle(self, s): def get_capstyle(self): """Return the capstyle.""" - return self._capstyle + return self._capstyle.name - @docstring.interpd + @_docstring.interpd def set_joinstyle(self, s): """ Set the `.JoinStyle`. @@ -512,7 +491,7 @@ def set_joinstyle(self, s): def get_joinstyle(self): """Return the joinstyle.""" - return self._joinstyle + return self._joinstyle.name def set_hatch(self, hatch): r""" @@ -551,15 +530,15 @@ def get_hatch(self): """Return the hatching pattern.""" return self._hatch - @contextlib.contextmanager - def _bind_draw_path_function(self, renderer): + def _draw_paths_with_artist_properties( + self, renderer, draw_path_args_list): """ ``draw()`` helper factored out for sharing with `FancyArrowPatch`. - Yields a callable ``dp`` such that calling ``dp(*args, **kwargs)`` is - equivalent to calling ``renderer1.draw_path(gc, *args, **kwargs)`` - where ``renderer1`` and ``gc`` have been suitably set from ``renderer`` - and the artist's properties. + Configure *renderer* and the associated graphics context *gc* + from the artist properties, then repeatedly call + ``renderer.draw_path(gc, *draw_path_args)`` for each tuple + *draw_path_args* in *draw_path_args_list*. """ renderer.open_group('patch', self.get_gid()) @@ -571,7 +550,7 @@ def _bind_draw_path_function(self, renderer): if self._edgecolor[3] == 0 or self._linestyle == 'None': lw = 0 gc.set_linewidth(lw) - gc.set_dashes(self._dashoffset, self._dashes) + gc.set_dashes(*self._dash_pattern) gc.set_capstyle(self._capstyle) gc.set_joinstyle(self._joinstyle) @@ -593,11 +572,8 @@ def _bind_draw_path_function(self, renderer): from matplotlib.patheffects import PathEffectRenderer renderer = PathEffectRenderer(self.get_path_effects(), renderer) - # In `with _bind_draw_path_function(renderer) as draw_path: ...` - # (in the implementations of `draw()` below), calls to `draw_path(...)` - # will occur as if they took place here with `gc` inserted as - # additional first argument. - yield functools.partial(renderer.draw_path, gc) + for draw_path_args in draw_path_args_list: + renderer.draw_path(gc, *draw_path_args) gc.restore() renderer.close_group('patch') @@ -608,18 +584,17 @@ def draw(self, renderer): # docstring inherited if not self.get_visible(): return - # Patch has traditionally ignored the dashoffset. - with cbook._setattr_cm(self, _dashoffset=0), \ - self._bind_draw_path_function(renderer) as draw_path: - path = self.get_path() - transform = self.get_transform() - tpath = transform.transform_path_non_affine(path) - affine = transform.get_affine() - draw_path(tpath, affine, - # Work around a bug in the PDF and SVG renderers, which - # do not draw the hatches if the facecolor is fully - # transparent, but do if it is None. - self._facecolor if self._facecolor[3] else None) + path = self.get_path() + transform = self.get_transform() + tpath = transform.transform_path_non_affine(path) + affine = transform.get_affine() + self._draw_paths_with_artist_properties( + renderer, + [(tpath, affine, + # Work around a bug in the PDF and SVG renderers, which + # do not draw the hatches if the facecolor is fully + # transparent, but do if it is None. + self._facecolor if self._facecolor[3] else None)]) def get_path(self): """Return the path of this patch.""" @@ -637,9 +612,9 @@ def _convert_xy_units(self, xy): class Shadow(Patch): def __str__(self): - return "Shadow(%s)" % (str(self.patch)) + return f"Shadow({self.patch})" - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, patch, ox, oy, **kwargs): """ Create a shadow of the given *patch*. @@ -711,8 +686,10 @@ def __str__(self): fmt = "Rectangle(xy=(%g, %g), width=%g, height=%g, angle=%g)" return fmt % pars - @docstring.dedent_interpd - def __init__(self, xy, width, height, angle=0.0, **kwargs): + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="angle") + def __init__(self, xy, width, height, angle=0.0, *, + rotation_point='xy', **kwargs): """ Parameters ---------- @@ -723,7 +700,11 @@ def __init__(self, xy, width, height, angle=0.0, **kwargs): height : float Rectangle height. angle : float, default: 0 - Rotation in degrees anti-clockwise about *xy*. + Rotation in degrees anti-clockwise about the rotation point. + rotation_point : {'xy', 'center', (number, number)}, default: 'xy' + If ``'xy'``, rotate around the anchor point. If ``'center'`` rotate + around the center. If 2-tuple of number, rotate around this + coordinate. Other Parameters ---------------- @@ -736,6 +717,14 @@ def __init__(self, xy, width, height, angle=0.0, **kwargs): self._width = width self._height = height self.angle = float(angle) + self.rotation_point = rotation_point + # Required for RectangleSelector with axes aspect ratio != 1 + # The patch is defined in data coordinates and when changing the + # selector with square modifier and not in data coordinates, we need + # to correct for the aspect ratio difference between the data and + # display coordinate systems. Its value is typically provide by + # Axes._get_aspect_ratio() + self._aspect_ratio_correction = 1.0 self._convert_units() # Validate the inputs. def get_path(self): @@ -756,9 +745,36 @@ def get_patch_transform(self): # important to call the accessor method and not directly access the # transformation member variable. bbox = self.get_bbox() - return (transforms.BboxTransformTo(bbox) - + transforms.Affine2D().rotate_deg_around( - bbox.x0, bbox.y0, self.angle)) + if self.rotation_point == 'center': + width, height = bbox.x1 - bbox.x0, bbox.y1 - bbox.y0 + rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2. + elif self.rotation_point == 'xy': + rotation_point = bbox.x0, bbox.y0 + else: + rotation_point = self.rotation_point + return transforms.BboxTransformTo(bbox) \ + + transforms.Affine2D() \ + .translate(-rotation_point[0], -rotation_point[1]) \ + .scale(1, self._aspect_ratio_correction) \ + .rotate_deg(self.angle) \ + .scale(1, 1 / self._aspect_ratio_correction) \ + .translate(*rotation_point) + + @property + def rotation_point(self): + """The rotation point of the patch.""" + return self._rotation_point + + @rotation_point.setter + def rotation_point(self, value): + if value in ['center', 'xy'] or ( + isinstance(value, tuple) and len(value) == 2 and + isinstance(value[0], Number) and isinstance(value[1], Number) + ): + self._rotation_point = value + else: + raise ValueError("`rotation_point` must be one of " + "{'xy', 'center', (number, number)}.") def get_x(self): """Return the left coordinate of the rectangle.""" @@ -772,6 +788,18 @@ def get_xy(self): """Return the left and bottom coords of the rectangle as a tuple.""" return self._x0, self._y0 + def get_corners(self): + """ + Return the corners of the rectangle, moving anti-clockwise from + (x0, y0). + """ + return self.get_patch_transform().transform( + [(0, 0), (1, 0), (1, 1), (0, 1)]) + + def get_center(self): + """Return the centre of the rectangle.""" + return self.get_patch_transform().transform((0.5, 0.5)) + def get_width(self): """Return the width of the rectangle.""" return self._width @@ -861,7 +889,8 @@ def __str__(self): return s % (self.xy[0], self.xy[1], self.numvertices, self.radius, self.orientation) - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="radius") def __init__(self, xy, numVertices, radius=5, orientation=0, **kwargs): """ @@ -911,10 +940,10 @@ def __str__(self): s = "PathPatch%d((%g, %g) ...)" return s % (len(self._path.vertices), *tuple(self._path.vertices[0])) - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, path, **kwargs): """ - *path* is a `~.path.Path` object. + *path* is a `.Path` object. Valid keyword arguments are: @@ -934,13 +963,13 @@ class StepPatch(PathPatch): """ A path patch describing a stepwise constant function. - By default the path is not closed and starts and stops at + By default, the path is not closed and starts and stops at baseline value. """ _edge_default = False - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, values, edges, *, orientation='vertical', baseline=0, **kwargs): """ @@ -1048,7 +1077,8 @@ def __str__(self): else: return "Polygon0()" - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="closed") def __init__(self, xy, closed=True, **kwargs): """ *xy* is a numpy array with shape Nx2. @@ -1109,7 +1139,7 @@ def set_xy(self, xy): Notes ----- - Unlike `~.path.Path`, we do not ignore the last input vertex. If the + Unlike `.Path`, we do not ignore the last input vertex. If the polygon is meant to be closed, and the last point of the polygon is not equal to the first, we assume that the user has not explicitly passed a ``CLOSEPOLY`` vertex, and add it ourselves. @@ -1144,7 +1174,8 @@ def __str__(self): fmt = "Wedge(center=(%g, %g), r=%g, theta1=%g, theta2=%g, width=%s)" return fmt % pars - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="width") def __init__(self, center, r, theta1, theta2, width=None, **kwargs): """ A wedge centered at *x*, *y* center with radius *r* that @@ -1180,21 +1211,15 @@ def _recompute_path(self): # followed by a reversed and scaled inner ring v1 = arc.vertices v2 = arc.vertices[::-1] * (self.r - self.width) / self.r - v = np.concatenate([v1, v2, [v1[0, :], (0, 0)]]) - c = np.concatenate([ - arc.codes, arc.codes, [connector, Path.CLOSEPOLY]]) - c[len(arc.codes)] = connector + v = np.concatenate([v1, v2, [(0, 0)]]) + c = [*arc.codes, connector, *arc.codes[1:], Path.CLOSEPOLY] else: # Wedge doesn't need an inner ring - v = np.concatenate([ - arc.vertices, [(0, 0), arc.vertices[0, :], (0, 0)]]) - c = np.concatenate([ - arc.codes, [connector, connector, Path.CLOSEPOLY]]) + v = np.concatenate([arc.vertices, [(0, 0), (0, 0)]]) + c = [*arc.codes, connector, Path.CLOSEPOLY] # Shift and scale the wedge to the final location. - v *= self.r - v += np.asarray(self.center) - self._path = Path(v, c) + self._path = Path(v * self.r + self.center, c) def set_center(self, center): self._path = None @@ -1234,13 +1259,12 @@ class Arrow(Patch): def __str__(self): return "Arrow()" - _path = Path([[0.0, 0.1], [0.0, -0.1], - [0.8, -0.1], [0.8, -0.3], - [1.0, 0.0], [0.8, 0.3], - [0.8, 0.1], [0.0, 0.1]], - closed=True) + _path = Path._create_closed([ + [0.0, 0.1], [0.0, -0.1], [0.8, -0.1], [0.8, -0.3], [1.0, 0.0], + [0.8, 0.3], [0.8, 0.1]]) - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="width") def __init__(self, x, y, dx, dy, width=1.0, **kwargs): """ Draws an arrow from (*x*, *y*) to (*x* + *dx*, *y* + *dy*). @@ -1295,7 +1319,8 @@ class FancyArrow(Polygon): def __str__(self): return "FancyArrow()" - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="width") def __init__(self, x, y, dx, dy, width=0.001, length_includes_head=False, head_width=None, head_length=None, shape='full', overhang=0, head_starts_at_zero=False, **kwargs): @@ -1364,13 +1389,13 @@ def set_data(self, *, x=None, y=None, dx=None, dy=None, width=None, dx, dy : float or None, default: None The length of the arrow along x and y direction. - width: float or None, default: None + width : float or None, default: None Width of full arrow tail. - head_width: float or None, default: None + head_width : float or None, default: None Total width of the full arrow head. - head_length: float or None, default: None + head_length : float or None, default: None Length of arrow head. """ if x is not None: @@ -1439,7 +1464,7 @@ def _make_verts(self): coords = np.concatenate([left_half_arrow[:-1], right_half_arrow[-2::-1]]) else: - raise ValueError("Got unknown shape: %s" % self.shape) + raise ValueError(f"Got unknown shape: {self._shape!r}") if distance != 0: cx = self._dx / distance sx = self._dy / distance @@ -1453,7 +1478,7 @@ def _make_verts(self): ] -docstring.interpd.update( +_docstring.interpd.update( FancyArrow="\n".join( (inspect.getdoc(FancyArrow.__init__) or "").splitlines()[2:])) @@ -1465,7 +1490,8 @@ def __str__(self): s = "CirclePolygon((%g, %g), radius=%g, resolution=%d)" return s % (self.xy[0], self.xy[1], self.radius, self.numvertices) - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="resolution") def __init__(self, xy, radius=5, resolution=20, # the number of vertices ** kwargs): @@ -1479,7 +1505,8 @@ def __init__(self, xy, radius=5, %(Patch:kwdoc)s """ - super().__init__(xy, resolution, radius, orientation=0, **kwargs) + super().__init__( + xy, resolution, radius=radius, orientation=0, **kwargs) class Ellipse(Patch): @@ -1491,7 +1518,8 @@ def __str__(self): fmt = "Ellipse(xy=(%s, %s), width=%s, height=%s, angle=%s)" return fmt % pars - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="angle") def __init__(self, xy, width, height, angle=0, **kwargs): """ Parameters @@ -1517,6 +1545,12 @@ def __init__(self, xy, width, height, angle=0, **kwargs): self._width, self._height = width, height self._angle = angle self._path = Path.unit_circle() + # Required for EllipseSelector with axes aspect ratio != 1 + # The patch is defined in data coordinates and when changing the + # selector with square modifier and not in data coordinates, we need + # to correct for the aspect ratio difference between the data and + # display coordinate systems. + self._aspect_ratio_correction = 1.0 # Note: This cannot be calculated until this is added to an Axes self._patch_transform = transforms.IdentityTransform() @@ -1534,8 +1568,9 @@ def _recompute_transform(self): width = self.convert_xunits(self._width) height = self.convert_yunits(self._height) self._patch_transform = transforms.Affine2D() \ - .scale(width * 0.5, height * 0.5) \ + .scale(width * 0.5, height * 0.5 * self._aspect_ratio_correction) \ .rotate_deg(self.angle) \ + .scale(1, 1 / self._aspect_ratio_correction) \ .translate(*center) def get_path(self): @@ -1616,13 +1651,23 @@ def get_angle(self): angle = property(get_angle, set_angle) + def get_corners(self): + """ + Return the corners of the ellipse bounding box. + + The bounding box orientation is moving anti-clockwise from the + lower left corner defined before rotation. + """ + return self.get_patch_transform().transform( + [(-1, -1), (1, -1), (1, 1), (-1, 1)]) + class Annulus(Patch): """ An elliptical annulus. """ - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, xy, r, width, angle=0.0, **kwargs): """ Parameters @@ -1813,7 +1858,7 @@ def __str__(self): fmt = "Circle(xy=(%g, %g), radius=%g)" return fmt % pars - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, xy, radius=5, **kwargs): """ Create a true circle at center *xy* = (*x*, *y*) with given *radius*. @@ -1850,14 +1895,9 @@ class Arc(Ellipse): """ An elliptical arc, i.e. a segment of an ellipse. - Due to internal optimizations, there are certain restrictions on using Arc: - - - The arc cannot be filled. - - - The arc must be used in an `~.axes.Axes` instance. It can not be added - directly to a `.Figure` because it is optimized to only render the - segments that are inside the axes bounding box with high resolution. + Due to internal optimizations, the arc cannot be filled. """ + def __str__(self): pars = (self.center[0], self.center[1], self.width, self.height, self.angle, self.theta1, self.theta2) @@ -1865,7 +1905,8 @@ def __str__(self): "height=%g, angle=%g, theta1=%g, theta2=%g)") return fmt % pars - @docstring.dedent_interpd + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="angle") def __init__(self, xy, width, height, angle=0.0, theta1=0.0, theta2=360.0, **kwargs): """ @@ -1897,8 +1938,7 @@ def __init__(self, xy, width, height, angle=0.0, ---------------- **kwargs : `.Patch` properties Most `.Patch` properties are supported as keyword arguments, - with the exception of *fill* and *facecolor* because filling is - not supported. + except *fill* and *facecolor* because filling is not supported. %(Patch:kwdoc)s """ @@ -1906,10 +1946,13 @@ def __init__(self, xy, width, height, angle=0.0, if fill: raise ValueError("Arc objects can not be filled") - super().__init__(xy, width, height, angle, **kwargs) + super().__init__(xy, width, height, angle=angle, **kwargs) self.theta1 = theta1 self.theta2 = theta2 + (self._theta1, self._theta2, self._stretched_width, + self._stretched_height) = self._theta_stretch() + self._path = Path.arc(self._theta1, self._theta2) @artist.allow_rasterization def draw(self, renderer): @@ -1939,12 +1982,11 @@ def draw(self, renderer): with each visible arc using a fixed number of spline segments (8). The algorithm proceeds as follows: - 1. The points where the ellipse intersects the axes bounding - box are located. (This is done be performing an inverse - transformation on the axes bbox such that it is relative - to the unit circle -- this makes the intersection - calculation much easier than doing rotated ellipse - intersection directly). + 1. The points where the ellipse intersects the axes (or figure) + bounding box are located. (This is done by performing an inverse + transformation on the bbox such that it is relative to the unit + circle -- this makes the intersection calculation much easier than + doing rotated ellipse intersection directly.) This uses the "line intersecting a circle" algorithm from: @@ -1958,43 +2000,12 @@ def draw(self, renderer): pairs of vertices are drawn using the Bezier arc approximation technique implemented in `.Path.arc`. """ - if not hasattr(self, 'axes'): - raise RuntimeError('Arcs can only be used in Axes instances') if not self.get_visible(): return self._recompute_transform() - width = self.convert_xunits(self.width) - height = self.convert_yunits(self.height) - - # If the width and height of ellipse are not equal, take into account - # stretching when calculating angles to draw between - def theta_stretch(theta, scale): - theta = np.deg2rad(theta) - x = np.cos(theta) - y = np.sin(theta) - stheta = np.rad2deg(np.arctan2(scale * y, x)) - # arctan2 has the range [-pi, pi], we expect [0, 2*pi] - return (stheta + 360) % 360 - - theta1 = self.theta1 - theta2 = self.theta2 - - if ( - # if we need to stretch the angles because we are distorted - width != height - # and we are not doing a full circle. - # - # 0 and 360 do not exactly round-trip through the angle - # stretching (due to both float precision limitations and - # the difference between the range of arctan2 [-pi, pi] and - # this method [0, 360]) so avoid doing it if we don't have to. - and not (theta1 != theta2 and theta1 % 360 == theta2 % 360) - ): - theta1 = theta_stretch(self.theta1, width / height) - theta2 = theta_stretch(self.theta2, width / height) - + self._update_path() # Get width and height in pixels we need to use # `self.get_data_transform` rather than `self.get_transform` # because we want the transform from dataspace to the @@ -2003,12 +2014,13 @@ def theta_stretch(theta, scale): # `self.get_transform()` goes from an idealized unit-radius # space to screen space). data_to_screen_trans = self.get_data_transform() - pwidth, pheight = (data_to_screen_trans.transform((width, height)) - - data_to_screen_trans.transform((0, 0))) + pwidth, pheight = ( + data_to_screen_trans.transform((self._stretched_width, + self._stretched_height)) - + data_to_screen_trans.transform((0, 0))) inv_error = (1.0 / 1.89818e-6) * 0.5 if pwidth < inv_error and pheight < inv_error: - self._path = Path.arc(theta1, theta2) return Patch.draw(self, renderer) def line_circle_intersect(x0, y0, x1, y1): @@ -2046,10 +2058,12 @@ def segment_circle_intersect(x0, y0, x1, y1): & (y0e - epsilon < ys) & (ys < y1e + epsilon) ] - # Transforms the axes box_path so that it is relative to the unit - # circle in the same way that it is relative to the desired ellipse. - box_path_transform = (transforms.BboxTransformTo(self.axes.bbox) - + self.get_transform().inverted()) + # Transform the axes (or figure) box_path so that it is relative to + # the unit circle in the same way that it is relative to the desired + # ellipse. + box_path_transform = ( + transforms.BboxTransformTo((self.axes or self.figure).bbox) + - self.get_transform()) box_path = Path.unit_rectangle().transformed(box_path_transform) thetas = set() @@ -2060,10 +2074,11 @@ def segment_circle_intersect(x0, y0, x1, y1): # arctan2 return [-pi, pi), the rest of our angles are in # [0, 360], adjust as needed. theta = (np.rad2deg(np.arctan2(y, x)) + 360) % 360 - thetas.update(theta[(theta1 < theta) & (theta < theta2)]) - thetas = sorted(thetas) + [theta2] - last_theta = theta1 - theta1_rad = np.deg2rad(theta1) + thetas.update( + theta[(self._theta1 < theta) & (theta < self._theta2)]) + thetas = sorted(thetas) + [self._theta2] + last_theta = self._theta1 + theta1_rad = np.deg2rad(self._theta1) inside = box_path.contains_point( (np.cos(theta1_rad), np.sin(theta1_rad)) ) @@ -2082,6 +2097,46 @@ def segment_circle_intersect(x0, y0, x1, y1): # restore original path self._path = path_original + def _update_path(self): + # Compute new values and update and set new _path if any value changed + stretched = self._theta_stretch() + if any(a != b for a, b in zip( + stretched, (self._theta1, self._theta2, self._stretched_width, + self._stretched_height))): + (self._theta1, self._theta2, self._stretched_width, + self._stretched_height) = stretched + self._path = Path.arc(self._theta1, self._theta2) + + def _theta_stretch(self): + # If the width and height of ellipse are not equal, take into account + # stretching when calculating angles to draw between + def theta_stretch(theta, scale): + theta = np.deg2rad(theta) + x = np.cos(theta) + y = np.sin(theta) + stheta = np.rad2deg(np.arctan2(scale * y, x)) + # arctan2 has the range [-pi, pi], we expect [0, 2*pi] + return (stheta + 360) % 360 + + width = self.convert_xunits(self.width) + height = self.convert_yunits(self.height) + if ( + # if we need to stretch the angles because we are distorted + width != height + # and we are not doing a full circle. + # + # 0 and 360 do not exactly round-trip through the angle + # stretching (due to both float precision limitations and + # the difference between the range of arctan2 [-pi, pi] and + # this method [0, 360]) so avoid doing it if we don't have to. + and not (self.theta1 != self.theta2 and + self.theta1 % 360 == self.theta2 % 360) + ): + theta1 = theta_stretch(self.theta1, width / height) + theta2 = theta_stretch(self.theta2, width / height) + return theta1, theta2, width, height + return self.theta1, self.theta2, width, height + def bbox_artist(artist, renderer, props=None, fill=True): """ @@ -2112,50 +2167,56 @@ def draw_bbox(bbox, renderer, color='k', trans=None): box returned by an artist's `.Artist.get_window_extent` to test whether the artist is returning the correct bbox. """ - r = Rectangle(xy=(bbox.x0, bbox.y0), width=bbox.width, height=bbox.height, + r = Rectangle(xy=bbox.p0, width=bbox.width, height=bbox.height, edgecolor=color, fill=False, clip_on=False) if trans is not None: r.set_transform(trans) r.draw(renderer) -def _simpleprint_styles(_styles): - """ - A helper function for the _Style class. Given the dictionary of - {stylename: styleclass}, return a string rep of the list of keys. - Used to update the documentation. - """ - return "[{}]".format("|".join(map(" '{}' ".format, _styles))) - - class _Style: """ A base class for the Styles. It is meant to be a container class, where actual styles are declared as subclass of it, and it provides some helper functions. """ - def __new__(cls, stylename, **kw): - """Return the instance of the subclass with the given style name.""" + def __init_subclass__(cls): + # Automatically perform docstring interpolation on the subclasses: + # This allows listing the supported styles via + # - %(BoxStyle:table)s + # - %(ConnectionStyle:table)s + # - %(ArrowStyle:table)s + # and additionally adding .. ACCEPTS: blocks via + # - %(BoxStyle:table_and_accepts)s + # - %(ConnectionStyle:table_and_accepts)s + # - %(ArrowStyle:table_and_accepts)s + _docstring.interpd.update({ + f"{cls.__name__}:table": cls.pprint_styles(), + f"{cls.__name__}:table_and_accepts": ( + cls.pprint_styles() + + "\n\n .. ACCEPTS: [" + + "|".join(map(" '{}' ".format, cls._style_list)) + + "]") + }) + + def __new__(cls, stylename, **kwargs): + """Return the instance of the subclass with the given style name.""" # The "class" should have the _style_list attribute, which is a mapping # of style names to style classes. - _list = stylename.replace(" ", "").split(",") _name = _list[0].lower() try: _cls = cls._style_list[_name] except KeyError as err: - raise ValueError("Unknown style : %s" % stylename) from err - + raise ValueError(f"Unknown style: {stylename!r}") from err try: _args_pair = [cs.split("=") for cs in _list[1:]] _args = {k: float(v) for k, v in _args_pair} except ValueError as err: - raise ValueError("Incorrect style argument : %s" % - stylename) from err - _args.update(kw) - - return _cls(**_args) + raise ValueError( + f"Incorrect style argument: {stylename!r}") from err + return _cls(**{**_args, **kwargs}) @classmethod def get_styles(cls): @@ -2183,7 +2244,6 @@ def pprint_styles(cls): *[' '.join(cell.ljust(cl) for cell, cl in zip(row, col_len)) for row in table[1:]], table_formatstr, - '', ]) return textwrap.indent(rst_table, prefix=' ' * 4) @@ -2204,6 +2264,7 @@ def _register_style(style_list, cls=None, *, name=None): return cls +@_docstring.dedent_interpd class BoxStyle(_Style): """ `BoxStyle` is a container class which defines several @@ -2223,97 +2284,44 @@ class BoxStyle(_Style): The following boxstyle classes are defined. - %(AvailableBoxstyles)s + %(BoxStyle:table)s - An instance of any boxstyle class is an callable object, - whose call signature is:: + An instance of a boxstyle class is a callable object, with the signature :: - __call__(self, x0, y0, width, height, mutation_size) + __call__(self, x0, y0, width, height, mutation_size) -> Path - and returns a `.Path` instance. *x0*, *y0*, *width* and - *height* specify the location and size of the box to be - drawn. *mutation_scale* determines the overall size of the - mutation (by which I mean the transformation of the rectangle to - the fancy box). + *x0*, *y0*, *width* and *height* specify the location and size of the box + to be drawn; *mutation_size* scales the outline properties such as padding. """ _style_list = {} - @_api.deprecated("3.4") - class _Base: - """ - Abstract base class for styling of `.FancyBboxPatch`. - - This class is not an artist itself. The `__call__` method returns the - `~matplotlib.path.Path` for outlining the fancy box. The actual drawing - is handled in `.FancyBboxPatch`. - - Subclasses may only use parameters with default values in their - ``__init__`` method because they must be able to be initialized - without arguments. - - Subclasses must implement the `__call__` method. It receives the - enclosing rectangle *x0, y0, width, height* as well as the - *mutation_size*, which scales the outline properties such as padding. - It returns the outline of the fancy box as `.path.Path`. - """ - - @_api.deprecated("3.4") - def transmute(self, x0, y0, width, height, mutation_size): - """Return the `~.path.Path` outlining the given rectangle.""" - return self(self, x0, y0, width, height, mutation_size, 1) - - # This can go away once the deprecation period elapses, leaving _Base - # as a fully abstract base class just providing docstrings, no logic. - def __init_subclass__(cls): - transmute = _api.deprecate_method_override( - __class__.transmute, cls, since="3.4") - if transmute: - cls.__call__ = transmute - return - - __call__ = cls.__call__ - - @_api.delete_parameter("3.4", "mutation_aspect") - def call_wrapper( - self, x0, y0, width, height, mutation_size, - mutation_aspect=_api.deprecation._deprecated_parameter): - if mutation_aspect is _api.deprecation._deprecated_parameter: - # Don't trigger deprecation warning internally. - return __call__(self, x0, y0, width, height, mutation_size) - else: - # Squeeze the given height by the aspect_ratio. - y0, height = y0 / mutation_aspect, height / mutation_aspect - path = self(x0, y0, width, height, mutation_size, - mutation_aspect) - vertices, codes = path.vertices, path.codes - # Restore the height. - vertices[:, 1] = vertices[:, 1] * mutation_aspect - return Path(vertices, codes) - - cls.__call__ = call_wrapper + @_register_style(_style_list) + class Square: + """A square box.""" - def __call__(self, x0, y0, width, height, mutation_size): + def __init__(self, pad=0.3): """ - Given the location and size of the box, return the path of - the box around it. - Parameters ---------- - x0, y0, width, height : float - Location and size of the box. - mutation_size : float - A reference scale for the mutation. - - Returns - ------- - `~matplotlib.path.Path` + pad : float, default: 0.3 + The amount of padding around the original box. """ - raise NotImplementedError('Derived must override') + self.pad = pad + + def __call__(self, x0, y0, width, height, mutation_size): + pad = mutation_size * self.pad + # width and height with padding added. + width, height = width + 2 * pad, height + 2 * pad + # boundary of the padded box + x0, y0 = x0 - pad, y0 - pad + x1, y1 = x0 + width, y0 + height + return Path._create_closed( + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) @_register_style(_style_list) - class Square(_Base): - """A square box.""" + class Circle: + """A circular box.""" def __init__(self, pad=0.3): """ @@ -2326,17 +2334,19 @@ def __init__(self, pad=0.3): def __call__(self, x0, y0, width, height, mutation_size): pad = mutation_size * self.pad - # width and height with padding added. width, height = width + 2 * pad, height + 2 * pad # boundary of the padded box x0, y0 = x0 - pad, y0 - pad - x1, y1 = x0 + width, y0 + height - return Path([(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], - closed=True) + return Path.circle((x0 + width / 2, y0 + height / 2), + max(width, height) / 2) @_register_style(_style_list) - class Circle(_Base): - """A circular box.""" + class Ellipse: + """ + An elliptical box. + + .. versionadded:: 3.7 + """ def __init__(self, pad=0.3): """ @@ -2352,11 +2362,14 @@ def __call__(self, x0, y0, width, height, mutation_size): width, height = width + 2 * pad, height + 2 * pad # boundary of the padded box x0, y0 = x0 - pad, y0 - pad - return Path.circle((x0 + width / 2, y0 + height / 2), - max(width, height) / 2) + a = width / math.sqrt(2) + b = height / math.sqrt(2) + trans = Affine2D().scale(a, b).translate(x0 + width / 2, + y0 + height / 2) + return trans.transform_path(Path.unit_circle()) @_register_style(_style_list) - class LArrow(_Base): + class LArrow: """A box in the shape of a left-pointing arrow.""" def __init__(self, pad=0.3): @@ -2381,11 +2394,11 @@ def __call__(self, x0, y0, width, height, mutation_size): dxx = dx / 2 x0 = x0 + pad / 1.4 # adjust by ~sqrt(2) - return Path([(x0 + dxx, y0), (x1, y0), (x1, y1), (x0 + dxx, y1), - (x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx), - (x0 + dxx, y0 - dxx), # arrow - (x0 + dxx, y0), (x0 + dxx, y0)], - closed=True) + return Path._create_closed( + [(x0 + dxx, y0), (x1, y0), (x1, y1), (x0 + dxx, y1), + (x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx), + (x0 + dxx, y0 - dxx), # arrow + (x0 + dxx, y0)]) @_register_style(_style_list) class RArrow(LArrow): @@ -2398,7 +2411,7 @@ def __call__(self, x0, y0, width, height, mutation_size): return p @_register_style(_style_list) - class DArrow(_Base): + class DArrow: """A box in the shape of a two-way arrow.""" # Modified from LArrow to add a right arrow to the bbox. @@ -2425,17 +2438,17 @@ def __call__(self, x0, y0, width, height, mutation_size): dxx = dx / 2 x0 = x0 + pad / 1.4 # adjust by ~sqrt(2) - return Path([(x0 + dxx, y0), (x1, y0), # bot-segment - (x1, y0 - dxx), (x1 + dx + dxx, y0 + dx), - (x1, y1 + dxx), # right-arrow - (x1, y1), (x0 + dxx, y1), # top-segment - (x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx), - (x0 + dxx, y0 - dxx), # left-arrow - (x0 + dxx, y0), (x0 + dxx, y0)], # close-poly - closed=True) + return Path._create_closed([ + (x0 + dxx, y0), (x1, y0), # bot-segment + (x1, y0 - dxx), (x1 + dx + dxx, y0 + dx), + (x1, y1 + dxx), # right-arrow + (x1, y1), (x0 + dxx, y1), # top-segment + (x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx), + (x0 + dxx, y0 - dxx), # left-arrow + (x0 + dxx, y0)]) @_register_style(_style_list) - class Round(_Base): + class Round: """A box with round corners.""" def __init__(self, pad=0.3, rounding_size=None): @@ -2490,12 +2503,10 @@ def __call__(self, x0, y0, width, height, mutation_size): Path.CURVE3, Path.CURVE3, Path.CLOSEPOLY] - path = Path(cp, com) - - return path + return Path(cp, com) @_register_style(_style_list) - class Round4(_Base): + class Round4: """A box with rounded edges.""" def __init__(self, pad=0.3, rounding_size=None): @@ -2541,12 +2552,10 @@ def __call__(self, x0, y0, width, height, mutation_size): Path.CURVE4, Path.CURVE4, Path.CURVE4, Path.CLOSEPOLY] - path = Path(cp, com) - - return path + return Path(cp, com) @_register_style(_style_list) - class Sawtooth(_Base): + class Sawtooth: """A box with a sawtooth outline.""" def __init__(self, pad=0.3, tooth_size=None): @@ -2572,74 +2581,39 @@ def _get_sawtooth_vertices(self, x0, y0, width, height, mutation_size): else: tooth_size = self.tooth_size * mutation_size - tooth_size2 = tooth_size / 2 + hsz = tooth_size / 2 width = width + 2 * pad - tooth_size height = height + 2 * pad - tooth_size # the sizes of the vertical and horizontal sawtooth are # separately adjusted to fit the given box size. - dsx_n = int(round((width - tooth_size) / (tooth_size * 2))) * 2 + dsx_n = round((width - tooth_size) / (tooth_size * 2)) * 2 dsx = (width - tooth_size) / dsx_n - dsy_n = int(round((height - tooth_size) / (tooth_size * 2))) * 2 + dsy_n = round((height - tooth_size) / (tooth_size * 2)) * 2 dsy = (height - tooth_size) / dsy_n - x0, y0 = x0 - pad + tooth_size2, y0 - pad + tooth_size2 + x0, y0 = x0 - pad + hsz, y0 - pad + hsz x1, y1 = x0 + width, y0 + height - bottom_saw_x = [ - x0, - *(x0 + tooth_size2 + dsx * .5 * np.arange(dsx_n * 2)), - x1 - tooth_size2, - ] - bottom_saw_y = [ - y0, - *([y0 - tooth_size2, y0, y0 + tooth_size2, y0] * dsx_n), - y0 - tooth_size2, - ] - right_saw_x = [ - x1, - *([x1 + tooth_size2, x1, x1 - tooth_size2, x1] * dsx_n), - x1 + tooth_size2, - ] - right_saw_y = [ - y0, - *(y0 + tooth_size2 + dsy * .5 * np.arange(dsy_n * 2)), - y1 - tooth_size2, - ] - top_saw_x = [ - x1, - *(x1 - tooth_size2 - dsx * .5 * np.arange(dsx_n * 2)), - x0 + tooth_size2, - ] - top_saw_y = [ - y1, - *([y1 + tooth_size2, y1, y1 - tooth_size2, y1] * dsx_n), - y1 + tooth_size2, - ] - left_saw_x = [ - x0, - *([x0 - tooth_size2, x0, x0 + tooth_size2, x0] * dsy_n), - x0 - tooth_size2, + xs = [ + x0, *np.linspace(x0 + hsz, x1 - hsz, 2 * dsx_n + 1), # bottom + *([x1, x1 + hsz, x1, x1 - hsz] * dsy_n)[:2*dsy_n+2], # right + x1, *np.linspace(x1 - hsz, x0 + hsz, 2 * dsx_n + 1), # top + *([x0, x0 - hsz, x0, x0 + hsz] * dsy_n)[:2*dsy_n+2], # left ] - left_saw_y = [ - y1, - *(y1 - tooth_size2 - dsy * .5 * np.arange(dsy_n * 2)), - y0 + tooth_size2, + ys = [ + *([y0, y0 - hsz, y0, y0 + hsz] * dsx_n)[:2*dsx_n+2], # bottom + y0, *np.linspace(y0 + hsz, y1 - hsz, 2 * dsy_n + 1), # right + *([y1, y1 + hsz, y1, y1 - hsz] * dsx_n)[:2*dsx_n+2], # top + y1, *np.linspace(y1 - hsz, y0 + hsz, 2 * dsy_n + 1), # left ] - saw_vertices = [*zip(bottom_saw_x, bottom_saw_y), - *zip(right_saw_x, right_saw_y), - *zip(top_saw_x, top_saw_y), - *zip(left_saw_x, left_saw_y), - (bottom_saw_x[0], bottom_saw_y[0])] - - return saw_vertices + return [*zip(xs, ys), (xs[0], ys[0])] def __call__(self, x0, y0, width, height, mutation_size): saw_vertices = self._get_sawtooth_vertices(x0, y0, width, height, mutation_size) - path = Path(saw_vertices, closed=True) - return path + return Path(saw_vertices, closed=True) @_register_style(_style_list) class Roundtooth(Sawtooth): @@ -2657,6 +2631,7 @@ def __call__(self, x0, y0, width, height, mutation_size): return Path(saw_vertices, codes) +@_docstring.dedent_interpd class ConnectionStyle(_Style): """ `ConnectionStyle` is a container class which defines @@ -2677,9 +2652,9 @@ class ConnectionStyle(_Style): The following classes are defined - %(AvailableConnectorstyles)s + %(ConnectionStyle:table)s - An instance of any connection style class is an callable object, + An instance of any connection style class is a callable object, whose call signature is:: __call__(self, posA, posB, @@ -2709,59 +2684,35 @@ class _Base: helper methods. """ + @_api.deprecated("3.7") class SimpleEvent: def __init__(self, xy): self.x, self.y = xy - def _clip(self, path, patchA, patchB): + def _in_patch(self, patch): """ - Clip the path to the boundary of the patchA and patchB. - The starting point of the path needed to be inside of the - patchA and the end point inside the patch B. The *contains* - methods of each patch object is utilized to test if the point - is inside the path. + Return a predicate function testing whether a point *xy* is + contained in *patch*. """ + return lambda xy: patch.contains( + SimpleNamespace(x=xy[0], y=xy[1]))[0] - if patchA: - def insideA(xy_display): - xy_event = ConnectionStyle._Base.SimpleEvent(xy_display) - return patchA.contains(xy_event)[0] - - try: - left, right = split_path_inout(path, insideA) - except ValueError: - right = path - - path = right - - if patchB: - def insideB(xy_display): - xy_event = ConnectionStyle._Base.SimpleEvent(xy_display) - return patchB.contains(xy_event)[0] - - try: - left, right = split_path_inout(path, insideB) - except ValueError: - left = path - - path = left - - return path - - def _shrink(self, path, shrinkA, shrinkB): + def _clip(self, path, in_start, in_stop): """ - Shrink the path by fixed size (in points) with shrinkA and shrinkB. + Clip *path* at its start by the region where *in_start* returns + True, and at its stop by the region where *in_stop* returns True. + + The original path is assumed to start in the *in_start* region and + to stop in the *in_stop* region. """ - if shrinkA: - insideA = inside_circle(*path.vertices[0], shrinkA) + if in_start: try: - left, path = split_path_inout(path, insideA) + _, path = split_path_inout(path, in_start) except ValueError: pass - if shrinkB: - insideB = inside_circle(*path.vertices[-1], shrinkB) + if in_stop: try: - path, right = split_path_inout(path, insideB) + path, _ = split_path_inout(path, in_stop) except ValueError: pass return path @@ -2773,14 +2724,22 @@ def __call__(self, posA, posB, *posB*; then clip and shrink the path. """ path = self.connect(posA, posB) - clipped_path = self._clip(path, patchA, patchB) - shrunk_path = self._shrink(clipped_path, shrinkA, shrinkB) - return shrunk_path + path = self._clip( + path, + self._in_patch(patchA) if patchA else None, + self._in_patch(patchB) if patchB else None, + ) + path = self._clip( + path, + inside_circle(*path.vertices[0], shrinkA) if shrinkA else None, + inside_circle(*path.vertices[-1], shrinkB) if shrinkB else None + ) + return path @_register_style(_style_list) class Arc3(_Base): """ - Creates a simple quadratic Bezier curve between two + Creates a simple quadratic Bézier curve between two points. The curve is created so that the middle control point (C1) is located at the same distance from the start (C0) and end points(C2) and the distance of the C1 to the line @@ -2789,8 +2748,10 @@ class Arc3(_Base): def __init__(self, rad=0.): """ - *rad* - curvature of the curve. + Parameters + ---------- + rad : float + Curvature of the curve. """ self.rad = rad @@ -2816,19 +2777,21 @@ def connect(self, posA, posB): @_register_style(_style_list) class Angle3(_Base): """ - Creates a simple quadratic Bezier curve between two - points. The middle control points is placed at the - intersecting point of two lines which cross the start and - end point, and have a slope of angleA and angleB, respectively. + Creates a simple quadratic Bézier curve between two points. The middle + control point is placed at the intersecting point of two lines which + cross the start and end point, and have a slope of *angleA* and + *angleB*, respectively. """ def __init__(self, angleA=90, angleB=0): """ - *angleA* - starting angle of the path + Parameters + ---------- + angleA : float + Starting angle of the path. - *angleB* - ending angle of the path + angleB : float + Ending angle of the path. """ self.angleA = angleA @@ -2854,23 +2817,25 @@ def connect(self, posA, posB): @_register_style(_style_list) class Angle(_Base): """ - Creates a piecewise continuous quadratic Bezier path between - two points. The path has a one passing-through point placed at - the intersecting point of two lines which cross the start - and end point, and have a slope of angleA and angleB, respectively. + Creates a piecewise continuous quadratic Bézier path between two + points. The path has a one passing-through point placed at the + intersecting point of two lines which cross the start and end point, + and have a slope of *angleA* and *angleB*, respectively. The connecting edges are rounded with *rad*. """ def __init__(self, angleA=90, angleB=0, rad=0.): """ - *angleA* - starting angle of the path + Parameters + ---------- + angleA : float + Starting angle of the path. - *angleB* - ending angle of the path + angleB : float + Ending angle of the path. - *rad* - rounding radius of the edge + rad : float + Rounding radius of the edge. """ self.angleA = angleA @@ -2916,29 +2881,31 @@ def connect(self, posA, posB): @_register_style(_style_list) class Arc(_Base): """ - Creates a piecewise continuous quadratic Bezier path between - two points. The path can have two passing-through points, a - point placed at the distance of armA and angle of angleA from + Creates a piecewise continuous quadratic Bézier path between two + points. The path can have two passing-through points, a + point placed at the distance of *armA* and angle of *angleA* from point A, another point with respect to point B. The edges are rounded with *rad*. """ def __init__(self, angleA=0, angleB=0, armA=None, armB=None, rad=0.): """ - *angleA* : - starting angle of the path + Parameters + ---------- + angleA : float + Starting angle of the path. - *angleB* : - ending angle of the path + angleB : float + Ending angle of the path. - *armA* : - length of the starting arm + armA : float or None + Length of the starting arm. - *armB* : - length of the ending arm + armB : float or None + Length of the ending arm. - *rad* : - rounding radius of the edges + rad : float + Rounding radius of the edges. """ self.angleA = angleA @@ -3010,10 +2977,10 @@ def connect(self, posA, posB): @_register_style(_style_list) class Bar(_Base): """ - A line with *angle* between A and B with *armA* and - *armB*. One of the arms is extended so that they are connected in - a right angle. The length of armA is determined by (*armA* - + *fraction* x AB distance). Same for armB. + A line with *angle* between A and B with *armA* and *armB*. One of the + arms is extended so that they are connected in a right angle. The + length of *armA* is determined by (*armA* + *fraction* x AB distance). + Same for *armB*. """ def __init__(self, armA=0., armB=0., fraction=0.3, angle=None): @@ -3021,18 +2988,17 @@ def __init__(self, armA=0., armB=0., fraction=0.3, angle=None): Parameters ---------- armA : float - minimum length of armA + Minimum length of armA. armB : float - minimum length of armB + Minimum length of armB. fraction : float - a fraction of the distance between two points that - will be added to armA and armB. + A fraction of the distance between two points that will be + added to armA and armB. angle : float or None - angle of the connecting line (if None, parallel - to A and B) + Angle of the connecting line (if None, parallel to A and B). """ self.armA = armA self.armB = armB @@ -3093,13 +3059,14 @@ def _point_along_a_line(x0, y0, x1, y1, d): return x2, y2 +@_docstring.dedent_interpd class ArrowStyle(_Style): """ `ArrowStyle` is a container class which defines several arrowstyle classes, which is used to create an arrow path along a given path. These are mainly used with `FancyArrowPatch`. - A arrowstyle object can be either created as:: + An arrowstyle object can be either created as:: ArrowStyle.Fancy(head_length=.4, head_width=.4, tail_width=.4) @@ -3113,7 +3080,7 @@ class ArrowStyle(_Style): The following classes are defined - %(AvailableArrowstyles)s + %(ArrowStyle:table)s An instance of any arrow style class is a callable object, whose call signature is:: @@ -3127,6 +3094,14 @@ class ArrowStyle(_Style): stroked. This is meant to be used to correct the location of the head so that it does not overshoot the destination point, but not all classes support it. + + Notes + ----- + *angleA* and *angleB* specify the orientation of the bracket, as either a + clockwise or counterclockwise angle depending on the arrow type. 0 degrees + means perpendicular to the line connecting the arrow's head and tail. + + .. plot:: gallery/text_labels_and_annotations/angles_on_bracket_arrows.py """ _style_list = {} @@ -3151,9 +3126,9 @@ class is not an artist and actual drawing of the fancy arrow is def ensure_quadratic_bezier(path): """ Some ArrowStyle classes only works with a simple quadratic - Bezier curve (created with `.ConnectionStyle.Arc3` or + Bézier curve (created with `.ConnectionStyle.Arc3` or `.ConnectionStyle.Angle3`). This static method checks if the - provided path is a simple quadratic Bezier curve and returns its + provided path is a simple quadratic Bézier curve and returns its control points if true. """ segments = list(path.iter_segments()) @@ -3166,14 +3141,14 @@ def ensure_quadratic_bezier(path): def transmute(self, path, mutation_size, linewidth): """ The transmute method is the very core of the ArrowStyle class and - must be overridden in the subclasses. It receives the path object - along which the arrow will be drawn, and the mutation_size, with - which the arrow head etc. will be scaled. The linewidth may be - used to adjust the path so that it does not pass beyond the given - points. It returns a tuple of a Path instance and a boolean. The - boolean value indicate whether the path can be filled or not. The - return value can also be a list of paths and list of booleans of a - same length. + must be overridden in the subclasses. It receives the *path* + object along which the arrow will be drawn, and the + *mutation_size*, with which the arrow head etc. will be scaled. + The *linewidth* may be used to adjust the path so that it does not + pass beyond the given points. It returns a tuple of a `.Path` + instance and a boolean. The boolean value indicate whether the + path can be filled or not. The return value can also be a list of + paths and list of booleans of the same length. """ raise NotImplementedError('Derived must override') @@ -3193,11 +3168,9 @@ def __call__(self, path, mutation_size, linewidth, mutation_size, linewidth) if np.iterable(fillable): - path_list = [] - for p in path_mutated: - # Restore the height - path_list.append( - Path(p.vertices * [1, aspect_ratio], p.codes)) + # Restore the height + path_list = [Path(p.vertices * [1, aspect_ratio], p.codes) + for p in path_mutated] return path_list, fillable else: return path_mutated, fillable @@ -3208,12 +3181,11 @@ class _Curve(_Base): """ A simple arrow which will work with any path instance. The returned path is the concatenation of the original path, and at - most two paths representing the arrow head or bracket at the begin + most two paths representing the arrow head or bracket at the start point and at the end point. The arrow heads can be either open or closed. """ - beginarrow = endarrow = None # Whether arrows are drawn. arrow = "-" fillbegin = fillend = False # Whether arrows are filled. @@ -3276,18 +3248,6 @@ def __init__(self, head_length=.4, head_width=.2, widthA=1., widthB=1., elif beginarrow in ("]", "|"): self._beginarrow_head = False self._beginarrow_bracket = True - elif self.beginarrow is True: - self._beginarrow_head = True - self._beginarrow_bracket = False - - _api.warn_deprecated('3.5', name="beginarrow", - alternative="arrow") - elif self.beginarrow is False: - self._beginarrow_head = False - self._beginarrow_bracket = False - - _api.warn_deprecated('3.5', name="beginarrow", - alternative="arrow") if endarrow == ">": self._endarrow_head = True @@ -3299,18 +3259,6 @@ def __init__(self, head_length=.4, head_width=.2, widthA=1., widthB=1., elif endarrow in ("[", "|"): self._endarrow_head = False self._endarrow_bracket = True - elif self.endarrow is True: - self._endarrow_head = True - self._endarrow_bracket = False - - _api.warn_deprecated('3.5', name="endarrow", - alternative="arrow") - elif self.endarrow is False: - self._endarrow_head = False - self._endarrow_bracket = False - - _api.warn_deprecated('3.5', name="endarrow", - alternative="arrow") super().__init__() @@ -3383,7 +3331,7 @@ def _get_bracket(self, x0, y0, return vertices_arrow, codes_arrow def transmute(self, path, mutation_size, linewidth): - + # docstring inherited if self._beginarrow_head or self._endarrow_head: head_length = self.head_length * mutation_size head_width = self.head_width * mutation_size @@ -3421,22 +3369,20 @@ def transmute(self, path, mutation_size, linewidth): # This simple code will not work if ddx, ddy is greater than the # separation between vertices. - _path = [Path(np.concatenate([[(x0 + ddxA, y0 + ddyA)], + paths = [Path(np.concatenate([[(x0 + ddxA, y0 + ddyA)], path.vertices[1:-1], [(x3 + ddxB, y3 + ddyB)]]), path.codes)] - _fillable = [False] + fills = [False] if has_begin_arrow: if self.fillbegin: - p = np.concatenate([verticesA, [verticesA[0], - verticesA[0]], ]) - c = np.concatenate([codesA, [Path.LINETO, Path.CLOSEPOLY]]) - _path.append(Path(p, c)) - _fillable.append(True) + paths.append( + Path([*verticesA, (0, 0)], [*codesA, Path.CLOSEPOLY])) + fills.append(True) else: - _path.append(Path(verticesA, codesA)) - _fillable.append(False) + paths.append(Path(verticesA, codesA)) + fills.append(False) elif self._beginarrow_bracket: x0, y0 = path.vertices[0] x1, y1 = path.vertices[1] @@ -3445,19 +3391,17 @@ def transmute(self, path, mutation_size, linewidth): self.lengthA * scaleA, self.angleA) - _path.append(Path(verticesA, codesA)) - _fillable.append(False) + paths.append(Path(verticesA, codesA)) + fills.append(False) if has_end_arrow: if self.fillend: - _fillable.append(True) - p = np.concatenate([verticesB, [verticesB[0], - verticesB[0]], ]) - c = np.concatenate([codesB, [Path.LINETO, Path.CLOSEPOLY]]) - _path.append(Path(p, c)) + fills.append(True) + paths.append( + Path([*verticesB, (0, 0)], [*codesB, Path.CLOSEPOLY])) else: - _fillable.append(False) - _path.append(Path(verticesB, codesB)) + fills.append(False) + paths.append(Path(verticesB, codesB)) elif self._endarrow_bracket: x0, y0 = path.vertices[-1] x1, y1 = path.vertices[-2] @@ -3466,10 +3410,10 @@ def transmute(self, path, mutation_size, linewidth): self.lengthB * scaleB, self.angleB) - _path.append(Path(verticesB, codesB)) - _fillable.append(False) + paths.append(Path(verticesB, codesB)) + fills.append(False) - return _path, _fillable + return paths, fills @_register_style(_style_list, name="-") class Curve(_Curve): @@ -3482,7 +3426,7 @@ def __init__(self): # hide head_length, head_width @_register_style(_style_list, name="<-") class CurveA(_Curve): - """An arrow with a head at its begin point.""" + """An arrow with a head at its start point.""" arrow = "<-" @_register_style(_style_list, name="->") @@ -3492,12 +3436,12 @@ class CurveB(_Curve): @_register_style(_style_list, name="<->") class CurveAB(_Curve): - """An arrow with heads both at the begin and the end point.""" + """An arrow with heads both at the start and the end point.""" arrow = "<->" @_register_style(_style_list, name="<|-") class CurveFilledA(_Curve): - """An arrow with filled triangle head at the begin.""" + """An arrow with filled triangle head at the start.""" arrow = "<|-" @_register_style(_style_list, name="-|>") @@ -3634,7 +3578,7 @@ def __init__(self, widthB=1., lengthB=0.2, angleB=None): @_register_style(_style_list) class Simple(_Base): - """A simple arrow. Only works with a quadratic Bezier curve.""" + """A simple arrow. Only works with a quadratic Bézier curve.""" def __init__(self, head_length=.5, head_width=.5, tail_width=.2): """ @@ -3654,7 +3598,7 @@ def __init__(self, head_length=.5, head_width=.5, tail_width=.2): super().__init__() def transmute(self, path, mutation_size, linewidth): - + # docstring inherited x0, y0, x1, y1, x2, y2 = self.ensure_quadratic_bezier(path) # divide the path into a head and a tail @@ -3664,8 +3608,7 @@ def transmute(self, path, mutation_size, linewidth): try: arrow_out, arrow_in = \ - split_bezier_intersecting_with_closedpath( - arrow_path, in_f, tolerance=0.01) + split_bezier_intersecting_with_closedpath(arrow_path, in_f) except NonIntersectingPathException: # if this happens, make a straight line of the head_length # long. @@ -3714,7 +3657,7 @@ def transmute(self, path, mutation_size, linewidth): @_register_style(_style_list) class Fancy(_Base): - """A fancy arrow. Only works with a quadratic Bezier curve.""" + """A fancy arrow. Only works with a quadratic Bézier curve.""" def __init__(self, head_length=.4, head_width=.4, tail_width=.4): """ @@ -3734,7 +3677,7 @@ def __init__(self, head_length=.4, head_width=.4, tail_width=.4): super().__init__() def transmute(self, path, mutation_size, linewidth): - + # docstring inherited x0, y0, x1, y1, x2, y2 = self.ensure_quadratic_bezier(path) # divide the path into a head and a tail @@ -3745,7 +3688,7 @@ def transmute(self, path, mutation_size, linewidth): in_f = inside_circle(x2, y2, head_length) try: path_out, path_in = split_bezier_intersecting_with_closedpath( - arrow_path, in_f, tolerance=0.01) + arrow_path, in_f) except NonIntersectingPathException: # if this happens, make a straight line of the head_length # long. @@ -3759,7 +3702,7 @@ def transmute(self, path, mutation_size, linewidth): # path for head in_f = inside_circle(x2, y2, head_length * .8) path_out, path_in = split_bezier_intersecting_with_closedpath( - arrow_path, in_f, tolerance=0.01) + arrow_path, in_f) path_tail = path_out # head @@ -3777,7 +3720,7 @@ def transmute(self, path, mutation_size, linewidth): # path for head in_f = inside_circle(x0, y0, tail_width * .3) path_in, path_out = split_bezier_intersecting_with_closedpath( - arrow_path, in_f, tolerance=0.01) + arrow_path, in_f) tail_start = path_in[-1] head_right, head_left = head_r, head_l @@ -3803,9 +3746,9 @@ def transmute(self, path, mutation_size, linewidth): @_register_style(_style_list) class Wedge(_Base): """ - Wedge(?) shape. Only works with a quadratic Bezier curve. The - begin point has a width of the tail_width and the end point has a - width of 0. At the middle, the width is shrink_factor*tail_width. + Wedge(?) shape. Only works with a quadratic Bézier curve. The + start point has a width of the *tail_width* and the end point has a + width of 0. At the middle, the width is *shrink_factor*x*tail_width*. """ def __init__(self, tail_width=.3, shrink_factor=0.5): @@ -3823,7 +3766,7 @@ def __init__(self, tail_width=.3, shrink_factor=0.5): super().__init__() def transmute(self, path, mutation_size, linewidth): - + # docstring inherited x0, y0, x1, y1, x2, y2 = self.ensure_quadratic_bezier(path) arrow_path = [(x0, y0), (x1, y1), (x2, y2)] @@ -3845,17 +3788,6 @@ def transmute(self, path, mutation_size, linewidth): return path, True -docstring.interpd.update( - AvailableBoxstyles=BoxStyle.pprint_styles(), - ListBoxstyles=_simpleprint_styles(BoxStyle._style_list), - AvailableArrowstyles=ArrowStyle.pprint_styles(), - AvailableConnectorstyles=ConnectionStyle.pprint_styles(), -) -docstring.dedent_interpd(BoxStyle) -docstring.dedent_interpd(ArrowStyle) -docstring.dedent_interpd(ConnectionStyle) - - class FancyBboxPatch(Patch): """ A fancy box around a rectangle with lower left at *xy* = (*x*, *y*) @@ -3872,12 +3804,9 @@ def __str__(self): s = self.__class__.__name__ + "((%g, %g), width=%g, height=%g)" return s % (self._x, self._y, self._width, self._height) - @docstring.dedent_interpd - @_api.delete_parameter("3.4", "bbox_transmuter", alternative="boxstyle") - def __init__(self, xy, width, height, - boxstyle="round", bbox_transmuter=None, - mutation_scale=1, mutation_aspect=1, - **kwargs): + @_docstring.dedent_interpd + def __init__(self, xy, width, height, boxstyle="round", *, + mutation_scale=1, mutation_aspect=1, **kwargs): """ Parameters ---------- @@ -3893,13 +3822,13 @@ def __init__(self, xy, width, height, boxstyle : str or `matplotlib.patches.BoxStyle` The style of the fancy box. This can either be a `.BoxStyle` instance or a string of the style name and optionally comma - seprarated attributes (e.g. "Round, pad=0.2"). This string is + separated attributes (e.g. "Round, pad=0.2"). This string is passed to `.BoxStyle` to construct a `.BoxStyle` object. See there for a full documentation. The following box styles are available: - %(AvailableBoxstyles)s + %(BoxStyle:table)s mutation_scale : float, default: 1 Scaling factor applied to the attributes of the box style @@ -3919,35 +3848,19 @@ def __init__(self, xy, width, height, """ super().__init__(**kwargs) - - self._x = xy[0] - self._y = xy[1] + self._x, self._y = xy self._width = width self._height = height - - if boxstyle == "custom": - _api.warn_deprecated( - "3.4", message="Support for boxstyle='custom' is deprecated " - "since %(since)s and will be removed %(removal)s; directly " - "pass a boxstyle instance as the boxstyle parameter instead.") - if bbox_transmuter is None: - raise ValueError("bbox_transmuter argument is needed with " - "custom boxstyle") - self._bbox_transmuter = bbox_transmuter - else: - self.set_boxstyle(boxstyle) - + self.set_boxstyle(boxstyle) self._mutation_scale = mutation_scale self._mutation_aspect = mutation_aspect - self.stale = True - @docstring.dedent_interpd + @_docstring.dedent_interpd def set_boxstyle(self, boxstyle=None, **kwargs): """ - Set the box style. + Set the box style, possibly with further attributes. - Most box styles can be further configured using attributes. Attributes from the previous box style are not reused. Without argument (or with ``boxstyle=None``), the available box styles @@ -3956,17 +3869,14 @@ def set_boxstyle(self, boxstyle=None, **kwargs): Parameters ---------- boxstyle : str or `matplotlib.patches.BoxStyle` - The style of the fancy box. This can either be a `.BoxStyle` - instance or a string of the style name and optionally comma - seprarated attributes (e.g. "Round, pad=0.2"). This string is - passed to `.BoxStyle` to construct a `.BoxStyle` object. See - there for a full documentation. + The style of the box: either a `.BoxStyle` instance, or a string, + which is the style name and optionally comma separated attributes + (e.g. "Round,pad=0.2"). Such a string is used to construct a + `.BoxStyle` object, as documented in that class. The following box styles are available: - %(AvailableBoxstyles)s - - .. ACCEPTS: %(ListBoxstyles)s + %(BoxStyle:table_and_accepts)s **kwargs Additional attributes for the box style. See the table above for @@ -3976,19 +3886,20 @@ def set_boxstyle(self, boxstyle=None, **kwargs): -------- :: - set_boxstyle("round,pad=0.2") + set_boxstyle("Round,pad=0.2") set_boxstyle("round", pad=0.2) - """ if boxstyle is None: return BoxStyle.pprint_styles() - - if isinstance(boxstyle, BoxStyle._Base) or callable(boxstyle): - self._bbox_transmuter = boxstyle - else: - self._bbox_transmuter = BoxStyle(boxstyle, **kwargs) + self._bbox_transmuter = ( + BoxStyle(boxstyle, **kwargs) + if isinstance(boxstyle, str) else boxstyle) self.stale = True + def get_boxstyle(self): + """Return the boxstyle object.""" + return self._bbox_transmuter + def set_mutation_scale(self, scale): """ Set the mutation scale. @@ -4020,37 +3931,15 @@ def get_mutation_aspect(self): return (self._mutation_aspect if self._mutation_aspect is not None else 1) # backcompat. - def get_boxstyle(self): - """Return the boxstyle object.""" - return self._bbox_transmuter - def get_path(self): """Return the mutated path of the rectangle.""" boxstyle = self.get_boxstyle() - x = self._x - y = self._y - width = self._width - height = self._height - m_scale = self.get_mutation_scale() m_aspect = self.get_mutation_aspect() - # Squeeze the given height by the aspect_ratio. - y, height = y / m_aspect, height / m_aspect - # Call boxstyle with squeezed height. - try: - inspect.signature(boxstyle).bind(x, y, width, height, m_scale) - except TypeError: - # Don't apply aspect twice. - path = boxstyle(x, y, width, height, m_scale, 1) - _api.warn_deprecated( - "3.4", message="boxstyles must be callable without the " - "'mutation_aspect' parameter since %(since)s; support for the " - "old call signature will be removed %(removal)s.") - else: - path = boxstyle(x, y, width, height, m_scale) - vertices, codes = path.vertices, path.codes - # Restore the height. - vertices[:, 1] = vertices[:, 1] * m_aspect - return Path(vertices, codes) + # Call boxstyle with y, height squeezed by aspect_ratio. + path = boxstyle(self._x, self._y / m_aspect, + self._width, self._height / m_aspect, + self.get_mutation_scale()) + return Path(path.vertices * [1, m_aspect], path.codes) # Unsqueeze y. # Following methods are borrowed from the Rectangle class. @@ -4163,14 +4052,13 @@ def __str__(self): else: return f"{type(self).__name__}({self._path_original})" - @docstring.dedent_interpd - @_api.delete_parameter("3.4", "dpi_cor") + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="path") def __init__(self, posA=None, posB=None, path=None, arrowstyle="simple", connectionstyle="arc3", patchA=None, patchB=None, shrinkA=2, shrinkB=2, mutation_scale=1, mutation_aspect=1, - dpi_cor=1, **kwargs): """ There are two ways for defining an arrow: @@ -4200,7 +4088,7 @@ def __init__(self, posA=None, posB=None, path=None, meant to be scaled with the *mutation_scale*. The following arrow styles are available: - %(AvailableArrowstyles)s + %(ArrowStyle:table)s connectionstyle : str or `.ConnectionStyle` or None, optional, \ default: 'arc3' @@ -4209,7 +4097,7 @@ def __init__(self, posA=None, posB=None, path=None, names, with optional comma-separated attributes. The following connection styles are available: - %(AvailableConnectorstyles)s + %(ConnectionStyle:table)s patchA, patchB : `.Patch`, default: None Head and tail patches, respectively. @@ -4226,10 +4114,6 @@ def __init__(self, posA=None, posB=None, path=None, the mutation and the mutated box will be stretched by the inverse of it. - dpi_cor : float, default: 1 - dpi_cor is currently used for linewidth-related things and shrink - factor. Mutation scale is affected by this. Deprecated. - Other Parameters ---------------- **kwargs : `.Patch` properties, optional @@ -4270,36 +4154,11 @@ def __init__(self, posA=None, posB=None, path=None, self._mutation_scale = mutation_scale self._mutation_aspect = mutation_aspect - self._dpi_cor = dpi_cor - - @_api.deprecated("3.4") - def set_dpi_cor(self, dpi_cor): - """ - dpi_cor is currently used for linewidth-related things and - shrink factor. Mutation scale is affected by this. - - Parameters - ---------- - dpi_cor : float - """ - self._dpi_cor = dpi_cor - self.stale = True - - @_api.deprecated("3.4") - def get_dpi_cor(self): - """ - dpi_cor is currently used for linewidth-related things and - shrink factor. Mutation scale is affected by this. - - Returns - ------- - scalar - """ - return self._dpi_cor + self._dpi_cor = 1.0 def set_positions(self, posA, posB): """ - Set the begin and end positions of the connecting path. + Set the start and end positions of the connecting path. Parameters ---------- @@ -4335,67 +4194,87 @@ def set_patchB(self, patchB): self.patchB = patchB self.stale = True - def set_connectionstyle(self, connectionstyle, **kw): + @_docstring.dedent_interpd + def set_connectionstyle(self, connectionstyle=None, **kwargs): """ - Set the connection style. Old attributes are forgotten. + Set the connection style, possibly with further attributes. + + Attributes from the previous connection style are not reused. + + Without argument (or with ``connectionstyle=None``), the available box + styles are returned as a human-readable string. Parameters ---------- - connectionstyle : str or `.ConnectionStyle` or None, optional - Can be a string with connectionstyle name with - optional comma-separated attributes, e.g.:: + connectionstyle : str or `matplotlib.patches.ConnectionStyle` + The style of the connection: either a `.ConnectionStyle` instance, + or a string, which is the style name and optionally comma separated + attributes (e.g. "Arc,armA=30,rad=10"). Such a string is used to + construct a `.ConnectionStyle` object, as documented in that class. - set_connectionstyle("arc,angleA=0,armA=30,rad=10") + The following connection styles are available: - Alternatively, the attributes can be provided as keywords, e.g.:: + %(ConnectionStyle:table_and_accepts)s - set_connectionstyle("arc", angleA=0,armA=30,rad=10) + **kwargs + Additional attributes for the connection style. See the table above + for supported parameters. - Without any arguments (or with ``connectionstyle=None``), return - available styles as a list of strings. - """ + Examples + -------- + :: + set_connectionstyle("Arc,armA=30,rad=10") + set_connectionstyle("arc", armA=30, rad=10) + """ if connectionstyle is None: return ConnectionStyle.pprint_styles() - - if (isinstance(connectionstyle, ConnectionStyle._Base) or - callable(connectionstyle)): - self._connector = connectionstyle - else: - self._connector = ConnectionStyle(connectionstyle, **kw) + self._connector = ( + ConnectionStyle(connectionstyle, **kwargs) + if isinstance(connectionstyle, str) else connectionstyle) self.stale = True def get_connectionstyle(self): """Return the `ConnectionStyle` used.""" return self._connector - def set_arrowstyle(self, arrowstyle=None, **kw): + def set_arrowstyle(self, arrowstyle=None, **kwargs): """ - Set the arrow style. Old attributes are forgotten. Without arguments - (or with ``arrowstyle=None``) returns available box styles as a list of - strings. + Set the arrow style, possibly with further attributes. + + Attributes from the previous arrow style are not reused. + + Without argument (or with ``arrowstyle=None``), the available box + styles are returned as a human-readable string. Parameters ---------- - arrowstyle : None or ArrowStyle or str, default: None - Can be a string with arrowstyle name with optional comma-separated - attributes, e.g.:: + arrowstyle : str or `matplotlib.patches.ArrowStyle` + The style of the arrow: either a `.ArrowStyle` instance, or a + string, which is the style name and optionally comma separated + attributes (e.g. "Fancy,head_length=0.2"). Such a string is used to + construct a `.ArrowStyle` object, as documented in that class. - set_arrowstyle("Fancy,head_length=0.2") + The following arrow styles are available: - Alternatively attributes can be provided as keywords, e.g.:: + %(ArrowStyle:table_and_accepts)s - set_arrowstyle("fancy", head_length=0.2) + **kwargs + Additional attributes for the arrow style. See the table above for + supported parameters. - """ + Examples + -------- + :: + set_arrowstyle("Fancy,head_length=0.2") + set_arrowstyle("fancy", head_length=0.2) + """ if arrowstyle is None: return ArrowStyle.pprint_styles() - - if isinstance(arrowstyle, ArrowStyle._Base): - self._arrow_transmuter = arrowstyle - else: - self._arrow_transmuter = ArrowStyle(arrowstyle, **kw) + self._arrow_transmuter = ( + ArrowStyle(arrowstyle, **kwargs) + if isinstance(arrowstyle, str) else arrowstyle) self.stale = True def get_arrowstyle(self): @@ -4473,33 +4352,26 @@ def _get_path_in_displaycoord(self): return _path, fillable - get_path_in_displaycoord = _api.deprecate_privatize_attribute( - "3.5", - alternative="self.get_transform().transform_path(self.get_path())") - def draw(self, renderer): if not self.get_visible(): return - with self._bind_draw_path_function(renderer) as draw_path: + # FIXME: dpi_cor is for the dpi-dependency of the linewidth. There + # could be room for improvement. Maybe _get_path_in_displaycoord could + # take a renderer argument, but get_path should be adapted too. + self._dpi_cor = renderer.points_to_pixels(1.) + path, fillable = self._get_path_in_displaycoord() - # FIXME : dpi_cor is for the dpi-dependency of the linewidth. There - # could be room for improvement. Maybe _get_path_in_displaycoord - # could take a renderer argument, but get_path should be adapted - # too. - self._dpi_cor = renderer.points_to_pixels(1.) - path, fillable = self._get_path_in_displaycoord() + if not np.iterable(fillable): + path = [path] + fillable = [fillable] - if not np.iterable(fillable): - path = [path] - fillable = [fillable] + affine = transforms.IdentityTransform() - affine = transforms.IdentityTransform() - - for p, f in zip(path, fillable): - draw_path( - p, affine, - self._facecolor if f and self._facecolor[3] else None) + self._draw_paths_with_artist_properties( + renderer, + [(p, affine, self._facecolor if f and self._facecolor[3] else None) + for p, f in zip(path, fillable)]) class ConnectionPatch(FancyArrowPatch): @@ -4509,8 +4381,8 @@ def __str__(self): return "ConnectionPatch((%g, %g), (%g, %g))" % \ (self.xy1[0], self.xy1[1], self.xy2[0], self.xy2[1]) - @docstring.dedent_interpd - @_api.delete_parameter("3.4", "dpi_cor") + @_docstring.dedent_interpd + @_api.make_keyword_only("3.6", name="axesA") def __init__(self, xyA, xyB, coordsA, coordsB=None, axesA=None, axesB=None, arrowstyle="-", @@ -4522,7 +4394,6 @@ def __init__(self, xyA, xyB, coordsA, coordsB=None, mutation_scale=10., mutation_aspect=None, clip_on=False, - dpi_cor=1., **kwargs): """ Connect point *xyA* in *coordsA* with point *xyB* in *coordsB*. @@ -4612,8 +4483,6 @@ def __init__(self, xyA, xyB, coordsA, coordsB=None, mutation_aspect=mutation_aspect, clip_on=clip_on, **kwargs) - self._dpi_cor = dpi_cor - # if True, draw annotation only if self.xy is inside the axes self._annotation_clip = None @@ -4676,18 +4545,16 @@ def _get_xy(self, xy, s, axes=None): def set_annotation_clip(self, b): """ - Set the clipping behavior. + Set the annotation's clipping behavior. Parameters ---------- b : bool or None - - - *False*: The annotation will always be drawn regardless of its - position. - - *True*: The annotation will only be drawn if ``self.xy`` is - inside the axes. - - *None*: The annotation will only be drawn if ``self.xy`` is - inside the axes and ``self.xycoords == "data"``. + - True: The annotation will be clipped when ``self.xy`` is + outside the axes. + - False: The annotation will always be drawn. + - None: The annotation will be clipped when ``self.xy`` is + outside the axes and ``self.xycoords == "data"``. """ self._annotation_clip = b self.stale = True diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 4280d55eeacd..1f65632c2d62 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -48,11 +48,11 @@ class Path: Draw a line from the current position to the given vertex. - ``CURVE3`` : 1 control point, 1 endpoint - Draw a quadratic Bezier curve from the current position, with the given + Draw a quadratic Bézier curve from the current position, with the given control point, to the given end point. - ``CURVE4`` : 2 control points, 1 endpoint - Draw a cubic Bezier curve from the current position, with the given + Draw a cubic Bézier curve from the current position, with the given control points, to the given end point. - ``CLOSEPOLY`` : 1 vertex (ignored) @@ -188,6 +188,17 @@ def _fast_from_codes_and_verts(cls, verts, codes, internals_from=None): pth._interpolation_steps = 1 return pth + @classmethod + def _create_closed(cls, vertices): + """ + Create a closed polygonal path going through *vertices*. + + Unlike ``Path(..., closed=True)``, *vertices* should **not** end with + an entry for the CLOSEPATH; this entry is added by `._create_closed`. + """ + v = _to_unmasked_float_array(vertices) + return cls(np.concatenate([v, v[:1]]), closed=True) + def _update_values(self): self._simplify_threshold = mpl.rcParams['path.simplify_threshold'] self._should_simplify = ( @@ -218,7 +229,7 @@ def codes(self): code is one of `STOP`, `MOVETO`, `LINETO`, `CURVE3`, `CURVE4` or `CLOSEPOLY`. For codes that correspond to more than one vertex (`CURVE3` and `CURVE4`), that code will be repeated so - that the length of `self.vertices` and `self.codes` is always + that the length of `vertices` and `codes` is always the same. """ return self._codes @@ -272,28 +283,25 @@ def __deepcopy__(self, memo=None): Return a deepcopy of the `Path`. The `Path` will not be readonly, even if the source `Path` is. """ - try: - codes = self.codes.copy() - except AttributeError: - codes = None - return self.__class__( - self.vertices.copy(), codes, - _interpolation_steps=self._interpolation_steps) + # Deepcopying arrays (vertices, codes) strips the writeable=False flag. + p = copy.deepcopy(super(), memo) + p._readonly = False + return p deepcopy = __deepcopy__ @classmethod def make_compound_path_from_polys(cls, XY): """ - Make a compound path object to draw a number - of polygons with equal numbers of sides XY is a (numpolys x - numsides x 2) numpy array of vertices. Return object is a - :class:`Path`. + Make a compound `Path` object to draw a number of polygons with equal + numbers of sides. .. plot:: gallery/misc/histogram_path.py + Parameters + ---------- + XY : (numpolys, numsides, 2) array """ - # for each poly: 1 for the MOVETO, (numsides-1) for the LINETO, 1 for # the CLOSEPOLY; the vert for the closepoly is ignored but we still # need it to keep the codes aligned with the vertices @@ -308,7 +316,6 @@ def make_compound_path_from_polys(cls, XY): codes[numsides::stride] = cls.CLOSEPOLY for i in range(numsides): verts[i::stride] = XY[:, i] - return cls(verts, codes) @classmethod @@ -411,7 +418,7 @@ def iter_segments(self, transform=None, remove_nans=True, clip=None, def iter_bezier(self, **kwargs): """ - Iterate over each bezier curve (lines included) in a Path. + Iterate over each Bézier curve (lines included) in a Path. Parameters ---------- @@ -421,13 +428,13 @@ def iter_bezier(self, **kwargs): Yields ------ B : matplotlib.bezier.BezierSegment - The bezier curves that make up the current path. Note in particular - that freestanding points are bezier curves of order 0, and lines - are bezier curves of order 1 (with two control points). + The Bézier curves that make up the current path. Note in particular + that freestanding points are Bézier curves of order 0, and lines + are Bézier curves of order 1 (with two control points). code : Path.code_type The code describing what kind of curve is being returned. Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE4 correspond to - bezier curves with 1, 2, 3, and 4 control points (respectively). + Bézier curves with 1, 2, 3, and 4 control points (respectively). Path.CLOSEPOLY is a Path.LINETO with the control points correctly chosen based on the start/end points of the current stroke. """ @@ -453,7 +460,7 @@ def iter_bezier(self, **kwargs): elif code == Path.STOP: return else: - raise ValueError("Invalid Path.code_type: " + str(code)) + raise ValueError(f"Invalid Path.code_type: {code}") prev_vert = verts[-2:] def cleaned(self, transform=None, remove_nans=False, clip=None, @@ -505,7 +512,7 @@ def contains_point(self, point, transform=None, radius=0.0): by *transform*; i.e. for a correct check, *transform* should transform the path into the coordinate system of *point*. radius : float, default: 0 - Add an additional margin on the path in coordinates of *point*. + Additional margin on the path in coordinates of *point*. The path is extended tangentially by *radius/2*; i.e. if you would draw the path with a linewidth of *radius*, all points on the line would still be considered to be contained in the area. Conversely, @@ -555,7 +562,7 @@ def contains_points(self, points, transform=None, radius=0.0): by *transform*; i.e. for a correct check, *transform* should transform the path into the coordinate system of *points*. radius : float, default: 0 - Add an additional margin on the path in coordinates of *points*. + Additional margin on the path in coordinates of *points*. The path is extended tangentially by *radius/2*; i.e. if you would draw the path with a linewidth of *radius*, all points on the line would still be considered to be contained in the area. Conversely, @@ -680,7 +687,7 @@ def to_polygons(self, transform=None, width=0, height=0, closed_only=True): polygon/polyline is an Nx2 array of vertices. In other words, each polygon has no ``MOVETO`` instructions or curves. This is useful for displaying in backends that do not support - compound paths or Bezier curves. + compound paths or Bézier curves. If *width* and *height* are both non-zero then the lines will be simplified so that vertices outside of (0, 0), (width, @@ -819,7 +826,7 @@ def circle(cls, center=(0., 0.), radius=1., readonly=False): Notes ----- - The circle is approximated using 8 cubic Bezier curves, as described in + The circle is approximated using 8 cubic Bézier curves, as described in Lancaster, Don. `Approximating a Circle or an Ellipse Using Four Bezier Cubic Splines `_. @@ -930,7 +937,7 @@ def arc(cls, theta1, theta2, n=None, is_wedge=False): Masionobe, L. 2003. `Drawing an elliptical arc using polylines, quadratic or cubic Bezier curves - `_. + `_. """ halfpi = np.pi * 0.5 @@ -1033,7 +1040,6 @@ def clip_to_bbox(self, bbox, inside=True): If *inside* is `True`, clip to the inside of the box, otherwise to the outside of the box. """ - # Use make_compound_path_from_polys verts = _path.clip_path_to_rect(self, bbox, inside) paths = [Path(poly) for poly in verts] return self.make_compound_path(*paths) diff --git a/lib/matplotlib/patheffects.py b/lib/matplotlib/patheffects.py index 416a26d3f651..191f62b8d2be 100644 --- a/lib/matplotlib/patheffects.py +++ b/lib/matplotlib/patheffects.py @@ -232,7 +232,6 @@ def __init__(self, offset=(2, -2), The shadow color. alpha : float, default: 0.3 The alpha transparency of the created shadow patch. - http://matplotlib.1069221.n5.nabble.com/path-effects-question-td27630.html rho : float, default: 0.3 A scale factor to apply to the rgbFace color if *shadow_rgbFace* is not specified. diff --git a/lib/matplotlib/projections/__init__.py b/lib/matplotlib/projections/__init__.py index 566492dc5368..16a5651da1d1 100644 --- a/lib/matplotlib/projections/__init__.py +++ b/lib/matplotlib/projections/__init__.py @@ -52,7 +52,7 @@ `matplotlib.projections.polar` may also be of interest. """ -from .. import axes, docstring +from .. import axes, _docstring from .geo import AitoffAxes, HammerAxes, LambertAxes, MollweideAxes from .polar import PolarAxes from mpl_toolkits.mplot3d import Axes3D @@ -111,4 +111,4 @@ def get_projection_class(projection=None): get_projection_names = projection_registry.get_projection_names -docstring.interpd.update(projection_names=get_projection_names()) +_docstring.interpd.update(projection_names=get_projection_names()) diff --git a/lib/matplotlib/projections/geo.py b/lib/matplotlib/projections/geo.py index 13b0b63da7e6..75e582e81028 100644 --- a/lib/matplotlib/projections/geo.py +++ b/lib/matplotlib/projections/geo.py @@ -1,6 +1,7 @@ import numpy as np -from matplotlib import _api, rcParams +import matplotlib as mpl +from matplotlib import _api from matplotlib.axes import Axes import matplotlib.axis as maxis from matplotlib.patches import Circle @@ -34,10 +35,10 @@ def _init_axis(self): # Do not register xaxis or yaxis with spines -- as done in # Axes._init_axis() -- until GeoAxes.xaxis.clear() works. # self.spines['geo'].register_axis(self.yaxis) - self._update_transScale() - def cla(self): - super().cla() + def clear(self): + # docstring inherited + super().clear() self.set_longitude_grid(30) self.set_latitude_grid(15) @@ -50,7 +51,7 @@ def cla(self): # Why do we need to turn on yaxis tick labels, but # xaxis tick labels are already on? - self.grid(rcParams['axes.grid']) + self.grid(mpl.rcParams['axes.grid']) Axes.set_xlim(self, -np.pi, np.pi) Axes.set_ylim(self, -np.pi / 2.0, np.pi / 2.0) @@ -156,14 +157,8 @@ def set_xlim(self, *args, **kwargs): def format_coord(self, lon, lat): """Return a format string formatting the coordinate.""" lon, lat = np.rad2deg([lon, lat]) - if lat >= 0.0: - ns = 'N' - else: - ns = 'S' - if lon >= 0.0: - ew = 'E' - else: - ew = 'W' + ns = 'N' if lat >= 0.0 else 'S' + ew = 'E' if lon >= 0.0 else 'W' return ('%f\N{DEGREE SIGN}%s, %f\N{DEGREE SIGN}%s' % (abs(lat), ns, abs(lon), ew)) @@ -203,17 +198,17 @@ def get_data_ratio(self): def can_zoom(self): """ - Return whether this axes supports the zoom box button functionality. + Return whether this Axes supports the zoom box button functionality. - This axes object does not support interactive zoom box. + This Axes object does not support interactive zoom box. """ return False def can_pan(self): """ - Return whether this axes supports the pan/zoom button functionality. + Return whether this Axes supports the pan/zoom button functionality. - This axes object does not support interactive pan/zoom. + This Axes object does not support interactive pan/zoom. """ return False @@ -290,7 +285,7 @@ def __init__(self, *args, **kwargs): self._longitude_cap = np.pi / 2.0 super().__init__(*args, **kwargs) self.set_aspect(0.5, adjustable='box', anchor='C') - self.cla() + self.clear() def _get_core_transform(self, resolution): return self.AitoffTransform(resolution) @@ -335,7 +330,7 @@ def __init__(self, *args, **kwargs): self._longitude_cap = np.pi / 2.0 super().__init__(*args, **kwargs) self.set_aspect(0.5, adjustable='box', anchor='C') - self.cla() + self.clear() def _get_core_transform(self, resolution): return self.HammerTransform(resolution) @@ -405,7 +400,7 @@ def __init__(self, *args, **kwargs): self._longitude_cap = np.pi / 2.0 super().__init__(*args, **kwargs) self.set_aspect(0.5, adjustable='box', anchor='C') - self.cla() + self.clear() def _get_core_transform(self, resolution): return self.MollweideTransform(resolution) @@ -490,10 +485,11 @@ def __init__(self, *args, center_longitude=0, center_latitude=0, **kwargs): self._center_latitude = center_latitude super().__init__(*args, **kwargs) self.set_aspect('equal', adjustable='box', anchor='C') - self.cla() + self.clear() - def cla(self): - super().cla() + def clear(self): + # docstring inherited + super().clear() self.yaxis.set_major_formatter(NullFormatter()) def _get_core_transform(self, resolution): diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 7ce6292fb897..3243c76d8661 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -1,10 +1,10 @@ -from collections import OrderedDict import math import types import numpy as np -from matplotlib import _api, cbook, rcParams +import matplotlib as mpl +from matplotlib import _api, cbook from matplotlib.axes import Axes import matplotlib.axis as maxis import matplotlib.markers as mmarkers @@ -12,42 +12,69 @@ from matplotlib.path import Path import matplotlib.ticker as mticker import matplotlib.transforms as mtransforms -import matplotlib.spines as mspines +from matplotlib.spines import Spine class PolarTransform(mtransforms.Transform): + r""" + The base polar transform. + + This transform maps polar coordinates :math:`\theta, r` into Cartesian + coordinates :math:`x, y = r \cos(\theta), r \sin(\theta)` + (but does not fully transform into Axes coordinates or + handle positioning in screen space). + + This transformation is designed to be applied to data after any scaling + along the radial axis (e.g. log-scaling) has been applied to the input + data. + + Path segments at a fixed radius are automatically transformed to circular + arcs as long as ``path._interpolation_steps > 1``. """ - The base polar transform. This handles projection *theta* and - *r* into Cartesian coordinate space *x* and *y*, but does not - perform the ultimate affine transformation into the correct - position. - """ + input_dims = output_dims = 2 def __init__(self, axis=None, use_rmin=True, - _apply_theta_transforms=True): + _apply_theta_transforms=True, *, scale_transform=None): + """ + Parameters + ---------- + axis : `~matplotlib.axis.Axis`, optional + Axis associated with this transform. This is used to get the + minimum radial limit. + use_rmin : `bool`, optional + If ``True``, subtract the minimum radial axis limit before + transforming to Cartesian coordinates. *axis* must also be + specified for this to take effect. + """ super().__init__() self._axis = axis self._use_rmin = use_rmin self._apply_theta_transforms = _apply_theta_transforms + self._scale_transform = scale_transform __str__ = mtransforms._make_str_method( "_axis", use_rmin="_use_rmin", _apply_theta_transforms="_apply_theta_transforms") + def _get_rorigin(self): + # Get lower r limit after being scaled by the radial scale transform + return self._scale_transform.transform( + (0, self._axis.get_rorigin()))[1] + def transform_non_affine(self, tr): # docstring inherited - t, r = np.transpose(tr) + theta, r = np.transpose(tr) # PolarAxes does not use the theta transforms here, but apply them for # backwards-compatibility if not being used by it. if self._apply_theta_transforms and self._axis is not None: - t *= self._axis.get_theta_direction() - t += self._axis.get_theta_offset() + theta *= self._axis.get_theta_direction() + theta += self._axis.get_theta_offset() if self._use_rmin and self._axis is not None: - r = (r - self._axis.get_rorigin()) * self._axis.get_rsign() + r = (r - self._get_rorigin()) * self._axis.get_rsign() r = np.where(r >= 0, r, np.nan) - return np.column_stack([r * np.cos(t), r * np.sin(t)]) + return np.column_stack([r * np.cos(theta), r * np.sin(theta)]) def transform_path_non_affine(self, path): # docstring inherited @@ -69,7 +96,7 @@ def transform_path_non_affine(self, path): # that behavior here. last_td, td = np.rad2deg([last_t, t]) if self._use_rmin and self._axis is not None: - r = ((r - self._axis.get_rorigin()) + r = ((r - self._get_rorigin()) * self._axis.get_rsign()) if last_td <= td: while td - last_td > 360: @@ -110,15 +137,30 @@ def inverted(self): class PolarAffine(mtransforms.Affine2DBase): - """ - The affine part of the polar projection. Scales the output so - that maximum radius rests on the edge of the axes circle. + r""" + The affine part of the polar projection. + + Scales the output so that maximum radius rests on the edge of the axes + circle and the origin is mapped to (0.5, 0.5). The transform applied is + the same to x and y components and given by: + + .. math:: + + x_{1} = 0.5 \left [ \frac{x_{0}}{(r_{\max} - r_{\min})} + 1 \right ] + + :math:`r_{\min}, r_{\max}` are the minimum and maximum radial limits after + any scaling (e.g. log scaling) has been removed. """ def __init__(self, scale_transform, limits): """ - *limits* is the view limit of the data. The only part of - its bounds that is used is the y limits (for the radius limits). - The theta range is handled by the non-affine transform. + Parameters + ---------- + scale_transform : `~matplotlib.transforms.Transform` + Scaling transform for the data. This is used to remove any scaling + from the radial view limits. + limits : `~matplotlib.transforms.BboxBase` + View limits of the data. The only part of its bounds that is used + is the y limits (for the radius limits). """ super().__init__() self._scale_transform = scale_transform @@ -151,6 +193,17 @@ class InvertedPolarTransform(mtransforms.Transform): def __init__(self, axis=None, use_rmin=True, _apply_theta_transforms=True): + """ + Parameters + ---------- + axis : `~matplotlib.axis.Axis`, optional + Axis associated with this transform. This is used to get the + minimum radial limit. + use_rmin : `bool`, optional + If ``True`` add the minimum radial axis limit after + transforming from Cartesian coordinates. *axis* must also be + specified for this to take effect. + """ super().__init__() self._axis = axis self._use_rmin = use_rmin @@ -193,7 +246,7 @@ def __call__(self, x, pos=None): vmin, vmax = self.axis.get_view_interval() d = np.rad2deg(abs(vmax - vmin)) digits = max(-int(np.log10(d) - 1.5), 0) - # Use unicode rather than mathtext with \circ, so that it will work + # Use Unicode rather than mathtext with \circ, so that it will work # correctly with any arbitrary font (assuming it has a degree sign), # whereas $5\circ$ will only work correctly with one of the supported # math fonts (Computer Modern and STIX). @@ -248,10 +301,6 @@ def __call__(self): else: return np.deg2rad(self.base()) - def refresh(self): - # docstring inherited - return self.base.refresh() - def view_limits(self, vmin, vmax): vmin, vmax = np.rad2deg((vmin, vmax)) return np.deg2rad(self.base.view_limits(vmin, vmax)) @@ -284,9 +333,8 @@ def __init__(self, axes, *args, **kwargs): rotation_mode='anchor', transform=self.label2.get_transform() + self._text2_translate) - def _apply_params(self, **kw): - super()._apply_params(**kw) - + def _apply_params(self, **kwargs): + super()._apply_params(**kwargs) # Ensure transform is correct; sometimes this gets reset. trans = self.label1.get_transform() if not trans.contains_branch(self._text1_translate): @@ -359,13 +407,7 @@ class ThetaAxis(maxis.XAxis): """ __name__ = 'thetaaxis' axis_name = 'theta' #: Read-only name identifying the axis. - - def _get_tick(self, major): - if major: - tick_kw = self._major_tick_kw - else: - tick_kw = self._minor_tick_kw - return ThetaTick(self.axes, 0, major=major, **tick_kw) + _tick_class = ThetaTick def _wrap_locator_formatter(self): self.set_major_locator(ThetaLocator(self.get_major_locator())) @@ -374,14 +416,11 @@ def _wrap_locator_formatter(self): self.isDefault_majfmt = True def clear(self): + # docstring inherited super().clear() self.set_ticks_position('none') self._wrap_locator_formatter() - @_api.deprecated("3.4", alternative="ThetaAxis.clear()") - def cla(self): - self.clear() - def _set_scale(self, value, **kwargs): if value != 'linear': raise NotImplementedError( @@ -431,14 +470,25 @@ def __call__(self): return [tick for tick in self.base() if tick > rorigin] return self.base() + def _zero_in_bounds(self): + """ + Return True if zero is within the valid values for the + scale of the radial axis. + """ + vmin, vmax = self._axes.yaxis._scale.limit_range_for_scale(0, 1, 1e-5) + return vmin == 0 + def nonsingular(self, vmin, vmax): # docstring inherited - return ((0, 1) if (vmin, vmax) == (-np.inf, np.inf) # Init. limits. - else self.base.nonsingular(vmin, vmax)) + if self._zero_in_bounds() and (vmin, vmax) == (-np.inf, np.inf): + # Initial view limits + return (0, 1) + else: + return self.base.nonsingular(vmin, vmax) def view_limits(self, vmin, vmax): vmin, vmax = self.base.view_limits(vmin, vmax) - if vmax > vmin: + if self._zero_in_bounds() and vmax > vmin: # this allows inverted r/y-lims vmin = min(0, vmin) return mtransforms.nonsingular(vmin, vmax) @@ -654,32 +704,23 @@ class RadialAxis(maxis.YAxis): """ __name__ = 'radialaxis' axis_name = 'radius' #: Read-only name identifying the axis. + _tick_class = RadialTick def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.sticky_edges.y.append(0) - def _get_tick(self, major): - if major: - tick_kw = self._major_tick_kw - else: - tick_kw = self._minor_tick_kw - return RadialTick(self.axes, 0, major=major, **tick_kw) - def _wrap_locator_formatter(self): self.set_major_locator(RadialLocator(self.get_major_locator(), self.axes)) self.isDefault_majloc = True def clear(self): + # docstring inherited super().clear() self.set_ticks_position('none') self._wrap_locator_formatter() - @_api.deprecated("3.4", alternative="RadialAxis.clear()") - def cla(self): - self.clear() - def _set_scale(self, value, **kwargs): super()._set_scale(value, **kwargs) self._wrap_locator_formatter() @@ -777,10 +818,11 @@ def __init__(self, *args, super().__init__(*args, **kwargs) self.use_sticky_edges = True self.set_aspect('equal', adjustable='box', anchor='C') - self.cla() + self.clear() - def cla(self): - super().cla() + def clear(self): + # docstring inherited + super().clear() self.title.set_y(1.05) @@ -792,7 +834,7 @@ def cla(self): end.set_visible(False) self.set_xlim(0.0, 2 * np.pi) - self.grid(rcParams['polaraxes.grid']) + self.grid(mpl.rcParams['polaraxes.grid']) inner = self.spines.get('inner', None) if inner: inner.set_visible(False) @@ -805,11 +847,9 @@ def _init_axis(self): # This is moved out of __init__ because non-separable axes don't use it self.xaxis = ThetaAxis(self) self.yaxis = RadialAxis(self) - # Calling polar_axes.xaxis.clear() or polar_axes.xaxis.clear() - # results in weird artifacts. Therefore we disable this for - # now. + # Calling polar_axes.xaxis.clear() or polar_axes.yaxis.clear() + # results in weird artifacts. Therefore we disable this for now. # self.spines['polar'].register_axis(self.yaxis) - self._update_transScale() def _set_lim_and_transforms(self): # A view limit where the minimum radius can be locked if the user @@ -848,7 +888,9 @@ def _set_lim_and_transforms(self): # data. This one is aware of rmin self.transProjection = self.PolarTransform( self, - _apply_theta_transforms=False) + _apply_theta_transforms=False, + scale_transform=self.transScale + ) # Add dependency on rorigin. self.transProjection.set_children(self._originViewLim) @@ -859,9 +901,25 @@ def _set_lim_and_transforms(self): # The complete data transformation stack -- from data all the # way to display coordinates + # + # 1. Remove any radial axis scaling (e.g. log scaling) + # 2. Shift data in the theta direction + # 3. Project the data from polar to cartesian values + # (with the origin in the same place) + # 4. Scale and translate the cartesian values to Axes coordinates + # (here the origin is moved to the lower left of the Axes) + # 5. Move and scale to fill the Axes + # 6. Convert from Axes coordinates to Figure coordinates self.transData = ( - self.transScale + self.transShift + self.transProjection + - (self.transProjectionAffine + self.transWedge + self.transAxes)) + self.transScale + + self.transShift + + self.transProjection + + ( + self.transProjectionAffine + + self.transWedge + + self.transAxes + ) + ) # This is the transform for theta-axis ticks. It is # equivalent to transData, except it always puts r == 0.0 and r == 1.0 @@ -982,14 +1040,12 @@ def _gen_axes_patch(self): return mpatches.Wedge((0.5, 0.5), 0.5, 0.0, 360.0) def _gen_axes_spines(self): - spines = OrderedDict([ - ('polar', mspines.Spine.arc_spine(self, 'top', - (0.5, 0.5), 0.5, 0.0, 360.0)), - ('start', mspines.Spine.linear_spine(self, 'left')), - ('end', mspines.Spine.linear_spine(self, 'right')), - ('inner', mspines.Spine.arc_spine(self, 'bottom', - (0.5, 0.5), 0.0, 0.0, 360.0)) - ]) + spines = { + 'polar': Spine.arc_spine(self, 'top', (0.5, 0.5), 0.5, 0, 360), + 'start': Spine.linear_spine(self, 'left'), + 'end': Spine.linear_spine(self, 'right'), + 'inner': Spine.arc_spine(self, 'bottom', (0.5, 0.5), 0.0, 0, 360), + } spines['polar'].set_transform(self.transWedge + self.transAxes) spines['inner'].set_transform(self.transWedge + self.transAxes) spines['start'].set_transform(self._yaxis_transform) @@ -1025,7 +1081,7 @@ def set_thetalim(self, *args, **kwargs): where minval and maxval are the minimum and maximum limits. Values are wrapped in to the range :math:`[0, 2\pi]` (in radians), so for example it is possible to do ``set_thetalim(-np.pi / 2, np.pi / 2)`` to have - an axes symmetric around 0. A ValueError is raised if the absolute + an axis symmetric around 0. A ValueError is raised if the absolute angle difference is larger than a full circle. """ orig_lim = self.get_xlim() # in radians @@ -1173,9 +1229,17 @@ def get_rorigin(self): def get_rsign(self): return np.sign(self._originViewLim.y1 - self._originViewLim.y0) + @_api.make_keyword_only("3.6", "emit") def set_rlim(self, bottom=None, top=None, emit=True, auto=False, **kwargs): """ - See `~.polar.PolarAxes.set_ylim`. + Set the radial axis view limits. + + This function behaves like `.Axes.set_ylim`, but additionally supports + *rmin* and *rmax* as aliases for *bottom* and *top*. + + See Also + -------- + .Axes.set_ylim """ if 'rmin' in kwargs: if bottom is None: @@ -1192,58 +1256,6 @@ def set_rlim(self, bottom=None, top=None, emit=True, auto=False, **kwargs): return self.set_ylim(bottom=bottom, top=top, emit=emit, auto=auto, **kwargs) - def set_ylim(self, bottom=None, top=None, emit=True, auto=False, - *, ymin=None, ymax=None): - """ - Set the data limits for the radial axis. - - Parameters - ---------- - bottom : float, optional - The bottom limit (default: None, which leaves the bottom - limit unchanged). - The bottom and top ylims may be passed as the tuple - (*bottom*, *top*) as the first positional argument (or as - the *bottom* keyword argument). - - top : float, optional - The top limit (default: None, which leaves the top limit - unchanged). - - emit : bool, default: True - Whether to notify observers of limit change. - - auto : bool or None, default: False - Whether to turn on autoscaling of the y-axis. True turns on, - False turns off, None leaves unchanged. - - ymin, ymax : float, optional - These arguments are deprecated and will be removed in a future - version. They are equivalent to *bottom* and *top* respectively, - and it is an error to pass both *ymin* and *bottom* or - *ymax* and *top*. - - Returns - ------- - bottom, top : (float, float) - The new y-axis limits in data coordinates. - """ - if ymin is not None: - if bottom is not None: - raise ValueError('Cannot supply both positional "bottom" ' - 'argument and kwarg "ymin"') - else: - bottom = ymin - if ymax is not None: - if top is not None: - raise ValueError('Cannot supply both positional "top" ' - 'argument and kwarg "ymax"') - else: - top = ymax - if top is None and np.iterable(bottom): - bottom, top = bottom[0], bottom[1] - return super().set_ylim(bottom=bottom, top=top, emit=emit, auto=auto) - def get_rlabel_position(self): """ Returns @@ -1322,7 +1334,7 @@ def set_thetagrids(self, angles, labels=None, fmt=None, **kwargs): elif fmt is not None: self.xaxis.set_major_formatter(mticker.FormatStrFormatter(fmt)) for t in self.xaxis.get_ticklabels(): - t.update(kwargs) + t._internal_update(kwargs) return self.xaxis.get_ticklines(), self.xaxis.get_ticklabels() def set_rgrids(self, radii, labels=None, angle=None, fmt=None, **kwargs): @@ -1377,7 +1389,7 @@ def set_rgrids(self, radii, labels=None, angle=None, fmt=None, **kwargs): angle = self.get_rlabel_position() self.set_rlabel_position(angle) for t in self.yaxis.get_ticklabels(): - t.update(kwargs) + t._internal_update(kwargs) return self.yaxis.get_gridlines(), self.yaxis.get_ticklabels() def format_coord(self, theta, r): @@ -1422,17 +1434,17 @@ def get_data_ratio(self): def can_zoom(self): """ - Return whether this axes supports the zoom box button functionality. + Return whether this Axes supports the zoom box button functionality. - Polar axes do not support zoom boxes. + A polar Axes does not support zoom boxes. """ return False def can_pan(self): """ - Return whether this axes supports the pan/zoom button functionality. + Return whether this Axes supports the pan/zoom button functionality. - For polar axes, this is slightly misleading. Both panning and + For a polar Axes, this is slightly misleading. Both panning and zooming are performed by the same button. Panning is performed in azimuth while zooming is done along the radial. """ @@ -1489,9 +1501,12 @@ def drag_pan(self, button, key, x, y): self.set_rmax(p.rmax / scale) -# to keep things all self contained, we can put aliases to the Polar classes +# To keep things all self-contained, we can put aliases to the Polar classes # defined above. This isn't strictly necessary, but it makes some of the -# code more readable (and provides a backwards compatible Polar API) +# code more readable, and provides a backwards compatible Polar API. In +# particular, this is used by the :doc:`/gallery/specialty_plots/radar_chart` +# example to override PolarTransform on a PolarAxes subclass, so make sure that +# that example is unaffected before changing this. PolarAxes.PolarTransform = PolarTransform PolarAxes.PolarAffine = PolarAffine PolarAxes.InvertedPolarTransform = InvertedPolarTransform diff --git a/lib/matplotlib/pylab.py b/lib/matplotlib/pylab.py index a9e30cfcd185..289aa9050e0c 100644 --- a/lib/matplotlib/pylab.py +++ b/lib/matplotlib/pylab.py @@ -16,8 +16,7 @@ import matplotlib as mpl from matplotlib.dates import ( - date2num, num2date, datestr2num, drange, epoch2num, - num2epoch, DateFormatter, DateLocator, + date2num, num2date, datestr2num, drange, DateFormatter, DateLocator, RRuleLocator, YearLocator, MonthLocator, WeekdayLocator, DayLocator, HourLocator, MinuteLocator, SecondLocator, rrule, MO, TU, WE, TH, FR, SA, SU, YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY, diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 2120a0120749..04b9bdb79ab2 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -16,11 +16,11 @@ y = np.sin(x) plt.plot(x, y) -The explicit (object-oriented) API is recommended for complex plots, though +The explicit object-oriented API is recommended for complex plots, though pyplot is still usually used to create the figure and often the axes in the figure. See `.pyplot.figure`, `.pyplot.subplots`, and `.pyplot.subplot_mosaic` to create figures, and -:doc:`Axes API <../axes_api>` for the plotting methods on an axes:: +:doc:`Axes API ` for the plotting methods on an Axes:: import numpy as np import matplotlib.pyplot as plt @@ -29,8 +29,14 @@ y = np.sin(x) fig, ax = plt.subplots() ax.plot(x, y) + + +See :ref:`api_interfaces` for an explanation of the tradeoffs between the +implicit and explicit interfaces. """ +from contextlib import ExitStack +from enum import Enum import functools import importlib import inspect @@ -38,11 +44,8 @@ from numbers import Number import re import sys +import threading import time -try: - import threading -except ImportError: - import dummy_threading as threading from cycler import cycler import matplotlib @@ -52,9 +55,10 @@ from matplotlib import rcsetup, style from matplotlib import _pylab_helpers, interactive from matplotlib import cbook -from matplotlib import docstring -from matplotlib.backend_bases import FigureCanvasBase, MouseButton -from matplotlib.figure import Figure, figaspect +from matplotlib import _docstring +from matplotlib.backend_bases import ( + FigureCanvasBase, FigureManagerBase, MouseButton) +from matplotlib.figure import Figure, FigureBase, figaspect from matplotlib.gridspec import GridSpec, SubplotSpec from matplotlib import rcParams, rcParamsDefault, get_backend, rcParamsOrig from matplotlib.rcsetup import interactive_bk as _interactive_bk @@ -65,7 +69,8 @@ from matplotlib.scale import get_scale_names from matplotlib import cm -from matplotlib.cm import _colormaps as colormaps, get_cmap, register_cmap +from matplotlib.cm import _colormaps as colormaps, register_cmap +from matplotlib.colors import _color_sequences as color_sequences import numpy as np @@ -88,7 +93,7 @@ def _copy_docstring_and_deprecators(method, func=None): if func is None: return functools.partial(_copy_docstring_and_deprecators, method) - decorators = [docstring.copy(method)] + decorators = [_docstring.copy(method)] # Check whether the definition of *method* includes @_api.rename_parameter # or @_api.make_keyword_only decorators; if so, propagate them to the # pyplot wrapper as well. @@ -104,44 +109,46 @@ def _copy_docstring_and_deprecators(method, func=None): ## Global ## -_IP_REGISTERED = None -_INSTALL_FIG_OBSERVER = False + +# The state controlled by {,un}install_repl_displayhook(). +_ReplDisplayHook = Enum("_ReplDisplayHook", ["NONE", "PLAIN", "IPYTHON"]) +_REPL_DISPLAYHOOK = _ReplDisplayHook.NONE + + +def _draw_all_if_interactive(): + if matplotlib.is_interactive(): + draw_all() def install_repl_displayhook(): """ - Install a repl display hook so that any stale figure are automatically - redrawn when control is returned to the repl. + Connect to the display hook of the current shell. + + The display hook gets called when the read-evaluate-print-loop (REPL) of + the shell has finished the execution of a command. We use this callback + to be able to automatically update a figure in interactive mode. This works both with IPython and with vanilla python shells. """ - global _IP_REGISTERED - global _INSTALL_FIG_OBSERVER + global _REPL_DISPLAYHOOK - if _IP_REGISTERED: + if _REPL_DISPLAYHOOK is _ReplDisplayHook.IPYTHON: return + # See if we have IPython hooks around, if so use them. # Use ``sys.modules.get(name)`` rather than ``name in sys.modules`` as # entries can also have been explicitly set to None. mod_ipython = sys.modules.get("IPython") if not mod_ipython: - _INSTALL_FIG_OBSERVER = True + _REPL_DISPLAYHOOK = _ReplDisplayHook.PLAIN return ip = mod_ipython.get_ipython() if not ip: - _INSTALL_FIG_OBSERVER = True + _REPL_DISPLAYHOOK = _ReplDisplayHook.PLAIN return - def post_execute(): - if matplotlib.is_interactive(): - draw_all() - - try: # IPython >= 2 - ip.events.register("post_execute", post_execute) - except AttributeError: # IPython 1.x - ip.register_post_execute(post_execute) - _IP_REGISTERED = post_execute - _INSTALL_FIG_OBSERVER = False + ip.events.register("post_execute", _draw_all_if_interactive) + _REPL_DISPLAYHOOK = _ReplDisplayHook.IPYTHON from IPython.core.pylabtools import backend2gui # trigger IPython's eventloop integration, if available @@ -151,35 +158,13 @@ def post_execute(): def uninstall_repl_displayhook(): - """ - Uninstall the Matplotlib display hook. - - .. warning:: - - Need IPython >= 2 for this to work. For IPython < 2 will raise a - ``NotImplementedError`` - - .. warning:: - - If you are using vanilla python and have installed another - display hook, this will reset ``sys.displayhook`` to what ever - function was there when Matplotlib installed its displayhook, - possibly discarding your changes. - """ - global _IP_REGISTERED - global _INSTALL_FIG_OBSERVER - if _IP_REGISTERED: + """Disconnect from the display hook of the current shell.""" + global _REPL_DISPLAYHOOK + if _REPL_DISPLAYHOOK is _ReplDisplayHook.IPYTHON: from IPython import get_ipython ip = get_ipython() - try: - ip.events.unregister('post_execute', _IP_REGISTERED) - except AttributeError as err: - raise NotImplementedError("Can not unregister events " - "in IPython < 2.0") from err - _IP_REGISTERED = None - - if _INSTALL_FIG_OBSERVER: - _INSTALL_FIG_OBSERVER = False + ip.events.unregister("post_execute", _draw_all_if_interactive) + _REPL_DISPLAYHOOK = _ReplDisplayHook.NONE draw_all = _pylab_helpers.Gcf.draw_all @@ -198,8 +183,14 @@ def findobj(o=None, match=None, include_self=True): def _get_required_interactive_framework(backend_mod): - return getattr( - backend_mod.FigureCanvas, "required_interactive_framework", None) + if not hasattr(getattr(backend_mod, "FigureCanvas", None), + "required_interactive_framework"): + _api.warn_deprecated( + "3.6", name="Support for FigureCanvases without a " + "required_interactive_framework attribute") + return None + # Inline this once the deprecation elapses. + return backend_mod.FigureCanvas.required_interactive_framework _backend_mod = None @@ -211,36 +202,33 @@ def _get_backend_mod(): This is currently private, but may be made public in the future. """ if _backend_mod is None: - # Use __getitem__ here to avoid going through the fallback logic (which - # will (re)import pyplot and then call switch_backend if we need to - # resolve the auto sentinel) - switch_backend(dict.__getitem__(rcParams, "backend")) - # Just to be safe. Interactive mode can be turned on without calling - # `plt.ion()` so register it again here. This is safe because multiple - # calls to `install_repl_displayhook` are no-ops and the registered - # function respects `mpl.is_interactive()` to determine if it should - # trigger a draw. - install_repl_displayhook() + # Use rcParams._get("backend") to avoid going through the fallback + # logic (which will (re)import pyplot and then call switch_backend if + # we need to resolve the auto sentinel) + switch_backend(rcParams._get("backend")) return _backend_mod def switch_backend(newbackend): """ - Close all open figures and set the Matplotlib backend. + Set the pyplot backend. + + Switching to an interactive backend is possible only if no event loop for + another interactive backend has started. Switching to and from + non-interactive backends is always possible. - The argument is case-insensitive. Switching to an interactive backend is - possible only if no event loop for another interactive backend has started. - Switching to and from non-interactive backends is always possible. + If the new backend is different than the current backend then all open + Figures will be closed via ``plt.close('all')``. Parameters ---------- newbackend : str - The name of the backend to use. + The case-insensitive name of the backend to use. + """ global _backend_mod # make sure the init is pulled up so we can assign to it later import matplotlib.backends - close("all") if newbackend is rcsetup._auto_backend_sentinel: current_framework = cbook._get_running_interactive_framework() @@ -277,16 +265,11 @@ def switch_backend(newbackend): switch_backend("agg") rcParamsOrig["backend"] = "agg" return + # have to escape the switch on access logic + old_backend = dict.__getitem__(rcParams, 'backend') - # Backends are implemented as modules, but "inherit" default method - # implementations from backend_bases._Backend. This is achieved by - # creating a "class" that inherits from backend_bases._Backend and whose - # body is filled with the module's globals. - - backend_name = cbook._backend_module_name(newbackend) - - class backend_mod(matplotlib.backend_bases._Backend): - locals().update(vars(importlib.import_module(backend_name))) + backend_mod = importlib.import_module( + cbook._backend_module_name(newbackend)) required_framework = _get_required_interactive_framework(backend_mod) if required_framework is not None: @@ -298,6 +281,60 @@ class backend_mod(matplotlib.backend_bases._Backend): "framework, as {!r} is currently running".format( newbackend, required_framework, current_framework)) + # Load the new_figure_manager() and show() functions from the backend. + + # Classically, backends can directly export these functions. This should + # keep working for backcompat. + new_figure_manager = getattr(backend_mod, "new_figure_manager", None) + show = getattr(backend_mod, "show", None) + + # In that classical approach, backends are implemented as modules, but + # "inherit" default method implementations from backend_bases._Backend. + # This is achieved by creating a "class" that inherits from + # backend_bases._Backend and whose body is filled with the module globals. + class backend_mod(matplotlib.backend_bases._Backend): + locals().update(vars(backend_mod)) + + # However, the newer approach for defining new_figure_manager and + # show is to derive them from canvas methods. In that case, also + # update backend_mod accordingly; also, per-backend customization of + # draw_if_interactive is disabled. + if new_figure_manager is None: + # Only try to get the canvas class if have opted into the new scheme. + canvas_class = backend_mod.FigureCanvas + + def new_figure_manager_given_figure(num, figure): + return canvas_class.new_manager(figure, num) + + def new_figure_manager(num, *args, FigureClass=Figure, **kwargs): + fig = FigureClass(*args, **kwargs) + return new_figure_manager_given_figure(num, fig) + + def draw_if_interactive(): + if matplotlib.is_interactive(): + manager = _pylab_helpers.Gcf.get_active() + if manager: + manager.canvas.draw_idle() + + backend_mod.new_figure_manager_given_figure = \ + new_figure_manager_given_figure + backend_mod.new_figure_manager = new_figure_manager + backend_mod.draw_if_interactive = draw_if_interactive + + # If the manager explicitly overrides pyplot_show, use it even if a global + # show is already present, as the latter may be here for backcompat. + manager_class = getattr(getattr(backend_mod, "FigureCanvas", None), + "manager_class", None) + # We can't compare directly manager_class.pyplot_show and FMB.pyplot_show + # because pyplot_show is a classmethod so the above constructs are bound + # classmethods, & thus always different (being bound to different classes). + manager_pyplot_show = vars(manager_class).get("pyplot_show") + base_pyplot_show = vars(FigureManagerBase).get("pyplot_show") + if (show is None + or (manager_pyplot_show is not None + and manager_pyplot_show != base_pyplot_show)): + backend_mod.show = manager_class.pyplot_show + _log.debug("Loaded backend %s version %s.", newbackend, backend_mod.backend_version) @@ -310,11 +347,30 @@ class backend_mod(matplotlib.backend_bases._Backend): # Need to keep a global reference to the backend for compatibility reasons. # See https://github.com/matplotlib/matplotlib/issues/6092 matplotlib.backends.backend = newbackend + if not cbook._str_equal(old_backend, newbackend): + close("all") + + # make sure the repl display hook is installed in case we become + # interactive + install_repl_displayhook() def _warn_if_gui_out_of_main_thread(): - if (_get_required_interactive_framework(_get_backend_mod()) - and threading.current_thread() is not threading.main_thread()): + warn = False + if _get_required_interactive_framework(_get_backend_mod()): + if hasattr(threading, 'get_native_id'): + # This compares native thread ids because even if Python-level + # Thread objects match, the underlying OS thread (which is what + # really matters) may be different on Python implementations with + # green threads. + if threading.get_native_id() != threading.main_thread().native_id: + warn = True + else: + # Fall back to Python-level Thread if native IDs are unavailable, + # mainly for PyPy. + if threading.current_thread() is not threading.main_thread(): + warn = True + if warn: _api.warn_external( "Starting a Matplotlib GUI outside of the main thread will likely " "fail.") @@ -419,58 +475,6 @@ def isinteractive(): return matplotlib.is_interactive() -class _IoffContext: - """ - Context manager for `.ioff`. - - The state is changed in ``__init__()`` instead of ``__enter__()``. The - latter is a no-op. This allows using `.ioff` both as a function and - as a context. - """ - - def __init__(self): - self.wasinteractive = isinteractive() - matplotlib.interactive(False) - uninstall_repl_displayhook() - - def __enter__(self): - pass - - def __exit__(self, exc_type, exc_value, traceback): - if self.wasinteractive: - matplotlib.interactive(True) - install_repl_displayhook() - else: - matplotlib.interactive(False) - uninstall_repl_displayhook() - - -class _IonContext: - """ - Context manager for `.ion`. - - The state is changed in ``__init__()`` instead of ``__enter__()``. The - latter is a no-op. This allows using `.ion` both as a function and - as a context. - """ - - def __init__(self): - self.wasinteractive = isinteractive() - matplotlib.interactive(True) - install_repl_displayhook() - - def __enter__(self): - pass - - def __exit__(self, exc_type, exc_value, traceback): - if not self.wasinteractive: - matplotlib.interactive(False) - uninstall_repl_displayhook() - else: - matplotlib.interactive(True) - install_repl_displayhook() - - def ioff(): """ Disable interactive mode. @@ -500,11 +504,15 @@ def ioff(): fig2 = plt.figure() # ... - To enable usage as a context manager, this function returns an - ``_IoffContext`` object. The return value is not intended to be stored - or accessed by the user. + To enable optional usage as a context manager, this function returns a + `~contextlib.ExitStack` object, which is not intended to be stored or + accessed by the user. """ - return _IoffContext() + stack = ExitStack() + stack.callback(ion if isinteractive() else ioff) + matplotlib.interactive(False) + uninstall_repl_displayhook() + return stack def ion(): @@ -536,11 +544,15 @@ def ion(): fig2 = plt.figure() # ... - To enable usage as a context manager, this function returns an - ``_IonContext`` object. The return value is not intended to be stored - or accessed by the user. + To enable optional usage as a context manager, this function returns a + `~contextlib.ExitStack` object, which is not intended to be stored or + accessed by the user. """ - return _IonContext() + stack = ExitStack() + stack.callback(ion if isinteractive() else ioff) + matplotlib.interactive(True) + install_repl_displayhook() + return stack def pause(interval): @@ -639,50 +651,43 @@ def xkcd(scale=1, length=100, randomness=2): # This figure will be in regular style fig2 = plt.figure() """ - return _xkcd(scale, length, randomness) - - -class _xkcd: - # This cannot be implemented in terms of rc_context() because this needs to - # work as a non-contextmanager too. - - def __init__(self, scale, length, randomness): - self._orig = rcParams.copy() - - if rcParams['text.usetex']: - raise RuntimeError( - "xkcd mode is not compatible with text.usetex = True") - - from matplotlib import patheffects - rcParams.update({ - 'font.family': ['xkcd', 'xkcd Script', 'Humor Sans', 'Comic Neue', - 'Comic Sans MS'], - 'font.size': 14.0, - 'path.sketch': (scale, length, randomness), - 'path.effects': [ - patheffects.withStroke(linewidth=4, foreground="w")], - 'axes.linewidth': 1.5, - 'lines.linewidth': 2.0, - 'figure.facecolor': 'white', - 'grid.linewidth': 0.0, - 'axes.grid': False, - 'axes.unicode_minus': False, - 'axes.edgecolor': 'black', - 'xtick.major.size': 8, - 'xtick.major.width': 3, - 'ytick.major.size': 8, - 'ytick.major.width': 3, - }) - - def __enter__(self): - return self - - def __exit__(self, *args): - dict.update(rcParams, self._orig) + # This cannot be implemented in terms of contextmanager() or rc_context() + # because this needs to work as a non-contextmanager too. + + if rcParams['text.usetex']: + raise RuntimeError( + "xkcd mode is not compatible with text.usetex = True") + + stack = ExitStack() + stack.callback(dict.update, rcParams, rcParams.copy()) + + from matplotlib import patheffects + rcParams.update({ + 'font.family': ['xkcd', 'xkcd Script', 'Humor Sans', 'Comic Neue', + 'Comic Sans MS'], + 'font.size': 14.0, + 'path.sketch': (scale, length, randomness), + 'path.effects': [ + patheffects.withStroke(linewidth=4, foreground="w")], + 'axes.linewidth': 1.5, + 'lines.linewidth': 2.0, + 'figure.facecolor': 'white', + 'grid.linewidth': 0.0, + 'axes.grid': False, + 'axes.unicode_minus': False, + 'axes.edgecolor': 'black', + 'xtick.major.size': 8, + 'xtick.major.width': 3, + 'ytick.major.size': 8, + 'ytick.major.width': 3, + }) + + return stack ## Figures ## +@_api.make_keyword_only("3.6", "facecolor") def figure(num=None, # autoincrement if None, else integer from 1-N figsize=None, # defaults to rc figure.figsize dpi=None, # defaults to rc figure.dpi @@ -698,7 +703,7 @@ def figure(num=None, # autoincrement if None, else integer from 1-N Parameters ---------- - num : int or str or `.Figure`, optional + num : int or str or `.Figure` or `.SubFigure`, optional A unique identifier for the figure. If a figure with that identifier already exists, this figure is made @@ -710,7 +715,8 @@ def figure(num=None, # autoincrement if None, else integer from 1-N will be used for the ``Figure.number`` attribute, otherwise, an auto-generated integer value is used (starting at 1 and incremented for each new figure). If *num* is a string, the figure label and the - window title is set to this value. + window title is set to this value. If num is a ``SubFigure``, its + parent ``Figure`` is activated. figsize : (float, float), default: :rc:`figure.figsize` Width, height in inches. @@ -728,40 +734,38 @@ def figure(num=None, # autoincrement if None, else integer from 1-N If False, suppress drawing the figure frame. FigureClass : subclass of `~matplotlib.figure.Figure` - Optionally use a custom `.Figure` instance. + If set, an instance of this subclass will be created, rather than a + plain `.Figure`. clear : bool, default: False If True and the figure already exists, then it is cleared. - tight_layout : bool or dict, default: :rc:`figure.autolayout` - If ``False`` use *subplotpars*. If ``True`` adjust subplot - parameters using `.tight_layout` with default padding. - When providing a dict containing the keys ``pad``, ``w_pad``, - ``h_pad``, and ``rect``, the default `.tight_layout` paddings - will be overridden. - - constrained_layout : bool, default: :rc:`figure.constrained_layout.use` - If ``True`` use constrained layout to adjust positioning of plot - elements. Like ``tight_layout``, but designed to be more - flexible. See - :doc:`/tutorials/intermediate/constrainedlayout_guide` - for examples. (Note: does not work with `add_subplot` or - `~.pyplot.subplot2grid`.) + layout : {'constrained', 'tight', `.LayoutEngine`, None}, default: None + The layout mechanism for positioning of plot elements to avoid + overlapping Axes decorations (labels, ticks, etc). Note that layout + managers can measurably slow down figure display. Defaults to *None* + (but see the documentation of the `.Figure` constructor regarding the + interaction with rcParams). - - **kwargs : optional - See `~.matplotlib.figure.Figure` for other possible arguments. + **kwargs + Additional keyword arguments are passed to the `.Figure` constructor. Returns ------- `~matplotlib.figure.Figure` - The `.Figure` instance returned will also be passed to - new_figure_manager in the backends, which allows to hook custom - `.Figure` classes into the pyplot interface. Additional kwargs will be - passed to the `.Figure` init function. Notes ----- + A newly created figure is passed to the `~.FigureCanvasBase.new_manager` + method or the `new_figure_manager` function provided by the current + backend, which install a canvas and a manager on the figure. + + Once this is done, :rc:`figure.hooks` are called, one at a time, on the + figure; these hooks allow arbitrary customization of the figure (e.g., + attaching callbacks) or of associated elements (e.g., modifying the + toolbar). See :doc:`/gallery/user_interfaces/mplcvd` for an example of + toolbar customization. + If you are creating many figures, make sure you explicitly call `.pyplot.close` on the figures you are not using, because this will enable pyplot to properly clean up the memory. @@ -769,11 +773,11 @@ def figure(num=None, # autoincrement if None, else integer from 1-N `~matplotlib.rcParams` defines the default values, which can be modified in the matplotlibrc file. """ - if isinstance(num, Figure): + if isinstance(num, FigureBase): if num.canvas.manager is None: raise ValueError("The passed figure is not managed by pyplot") _pylab_helpers.Gcf.set_active(num.canvas.manager) - return num + return num.figure allnums = get_fignums() next_num = max(allnums) + 1 if allnums else 1 @@ -802,7 +806,8 @@ def figure(num=None, # autoincrement if None, else integer from 1-N f"Figures created through the pyplot interface " f"(`matplotlib.pyplot.figure`) are retained until explicitly " f"closed and may consume too much memory. (To control this " - f"warning, see the rcParam `figure.max_open_warning`).", + f"warning, see the rcParam `figure.max_open_warning`). " + f"Consider using `matplotlib.pyplot.close()`.", RuntimeWarning) manager = new_figure_manager( @@ -813,6 +818,13 @@ def figure(num=None, # autoincrement if None, else integer from 1-N if fig_label: fig.set_label(fig_label) + for hookspecs in rcParams["figure.hooks"]: + module_name, dotted_name = hookspecs.split(":") + obj = importlib.import_module(module_name) + for part in dotted_name.split("."): + obj = getattr(obj, part) + obj(fig) + _pylab_helpers.Gcf._set_new_active_manager(manager) # make sure backends (inline) that we don't ship that expect this @@ -821,7 +833,7 @@ def figure(num=None, # autoincrement if None, else integer from 1-N # FigureManager base class. draw_if_interactive() - if _INSTALL_FIG_OBSERVER: + if _REPL_DISPLAYHOOK is _ReplDisplayHook.PLAIN: fig.stale_callback = _auto_draw_if_interactive if clear: @@ -969,6 +981,11 @@ def draw(): This is equivalent to calling ``fig.canvas.draw_idle()``, where ``fig`` is the current figure. + + See Also + -------- + .FigureCanvasBase.draw_idle + .FigureCanvasBase.draw """ gcf().canvas.draw_idle() @@ -977,7 +994,7 @@ def draw(): def savefig(*args, **kwargs): fig = gcf() res = fig.savefig(*args, **kwargs) - fig.canvas.draw_idle() # need this if 'transparent=True' to reset colors + fig.canvas.draw_idle() # Need this if 'transparent=True', to reset colors. return res @@ -987,15 +1004,18 @@ def savefig(*args, **kwargs): def figlegend(*args, **kwargs): return gcf().legend(*args, **kwargs) if Figure.legend.__doc__: - figlegend.__doc__ = Figure.legend.__doc__.replace("legend(", "figlegend(") + figlegend.__doc__ = Figure.legend.__doc__ \ + .replace(" legend(", " figlegend(") \ + .replace("fig.legend(", "plt.figlegend(") \ + .replace("ax.plot(", "plt.plot(") ## Axes ## -@docstring.dedent_interpd +@_docstring.dedent_interpd def axes(arg=None, **kwargs): """ - Add an axes to the current figure and make it the current axes. + Add an Axes to the current figure and make it the current Axes. Call signatures:: @@ -1008,10 +1028,10 @@ def axes(arg=None, **kwargs): arg : None or 4-tuple The exact behavior of this function depends on the type: - - *None*: A new full window axes is added using + - *None*: A new full window Axes is added using ``subplot(**kwargs)``. - 4-tuple of floats *rect* = ``[left, bottom, width, height]``. - A new axes is added with dimensions *rect* in normalized + A new Axes is added with dimensions *rect* in normalized (0, 1) units using `~.Figure.add_axes` on the current figure. projection : {None, 'aitoff', 'hammer', 'lambert', 'mollweide', \ @@ -1026,10 +1046,10 @@ def axes(arg=None, **kwargs): sharex, sharey : `~.axes.Axes`, optional Share the x or y `~matplotlib.axis` with sharex and/or sharey. The axis will have the same limits, ticks, and scale as the axis - of the shared axes. + of the shared Axes. label : str - A label for the returned axes. + A label for the returned Axes. Returns ------- @@ -1042,25 +1062,14 @@ def axes(arg=None, **kwargs): ---------------- **kwargs This method also takes the keyword arguments for - the returned axes class. The keyword arguments for the - rectilinear axes class `~.axes.Axes` can be found in + the returned Axes class. The keyword arguments for the + rectilinear Axes class `~.axes.Axes` can be found in the following table but there might also be other keyword - arguments if another projection is used, see the actual axes + arguments if another projection is used, see the actual Axes class. %(Axes:kwdoc)s - Notes - ----- - If the figure already has a axes with key (*args*, - *kwargs*) then it will simply make that axes current and - return it. This behavior is deprecated. Meanwhile, if you do - not want this behavior (i.e., you want to force the creation of a - new axes), you must use a unique set of args and kwargs. The axes - *label* attribute has been exposed for this purpose: if you want - two axes that are otherwise identical to be added to the figure, - make sure you give them unique labels. - See Also -------- .Figure.add_axes @@ -1073,11 +1082,11 @@ def axes(arg=None, **kwargs): -------- :: - # Creating a new full window axes + # Creating a new full window Axes plt.axes() - # Creating a new axes with specified dimensions and some kwargs - plt.axes((left, bottom, width, height), facecolor='w') + # Creating a new Axes with specified dimensions and a grey background + plt.axes((left, bottom, width, height), facecolor='grey') """ fig = gcf() pos = kwargs.pop('position', None) @@ -1115,7 +1124,7 @@ def cla(): ## More ways of creating axes ## -@docstring.dedent_interpd +@_docstring.dedent_interpd def subplot(*args, **kwargs): """ Add an Axes to the current figure or retrieve an existing Axes. @@ -1167,13 +1176,11 @@ def subplot(*args, **kwargs): Returns ------- - `.axes.SubplotBase`, or another subclass of `~.axes.Axes` + `~.axes.Axes` - The axes of the subplot. The returned axes base class depends on - the projection used. It is `~.axes.Axes` if rectilinear projection - is used and `.projections.polar.PolarAxes` if polar projection - is used. The returned axes is then a subplot subclass of the - base class. + The Axes of the subplot. The returned Axes can actually be an instance + of a subclass, such as `.projections.polar.PolarAxes` for polar + projections. Other Parameters ---------------- @@ -1188,7 +1195,7 @@ def subplot(*args, **kwargs): Notes ----- - Creating a new Axes will delete any pre-existing Axes that + Creating a new Axes will delete any preexisting Axes that overlaps with it beyond sharing a boundary:: import matplotlib.pyplot as plt @@ -1289,8 +1296,8 @@ def subplot(*args, **kwargs): key = SubplotSpec._from_subplot_args(fig, args) for ax in fig.axes: - # if we found an axes at the position sort out if we can re-use it - if hasattr(ax, 'get_subplotspec') and ax.get_subplotspec() == key: + # if we found an Axes at the position sort out if we can re-use it + if ax.get_subplotspec() == key: # if the user passed no kwargs, re-use if kwargs == {}: break @@ -1305,13 +1312,13 @@ def subplot(*args, **kwargs): fig.sca(ax) - bbox = ax.bbox - axes_to_delete = [] - for other_ax in fig.axes: - if other_ax == ax: - continue - if bbox.fully_overlaps(other_ax.bbox): - axes_to_delete.append(other_ax) + axes_to_delete = [other for other in fig.axes + if other != ax and ax.bbox.fully_overlaps(other.bbox)] + if axes_to_delete: + _api.warn_deprecated( + "3.6", message="Auto-removal of overlapping axes is deprecated " + "since %(since)s and will be removed %(removal)s; explicitly call " + "ax.remove() as needed.") for ax_to_del in axes_to_delete: delaxes(ax_to_del) @@ -1319,6 +1326,7 @@ def subplot(*args, **kwargs): def subplots(nrows=1, ncols=1, *, sharex=False, sharey=False, squeeze=True, + width_ratios=None, height_ratios=None, subplot_kw=None, gridspec_kw=None, **fig_kw): """ Create a figure and a set of subplots. @@ -1364,6 +1372,18 @@ def subplots(nrows=1, ncols=1, *, sharex=False, sharey=False, squeeze=True, always a 2D array containing Axes instances, even if it ends up being 1x1. + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Equivalent + to ``gridspec_kw={'width_ratios': [...]}``. + + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Convenience + for ``gridspec_kw={'height_ratios': [...]}``. + subplot_kw : dict, optional Dict with keywords passed to the `~matplotlib.figure.Figure.add_subplot` call used to create each @@ -1379,13 +1399,12 @@ def subplots(nrows=1, ncols=1, *, sharex=False, sharey=False, squeeze=True, Returns ------- - fig : `~.figure.Figure` + fig : `.Figure` - ax : `.axes.Axes` or array of Axes - *ax* can be either a single `~matplotlib.axes.Axes` object or an - array of Axes objects if more than one subplot was created. The - dimensions of the resulting array can be controlled with the squeeze - keyword, see above. + ax : `~.axes.Axes` or array of Axes + *ax* can be either a single `~.axes.Axes` object, or an array of Axes + objects if more than one subplot was created. The dimensions of the + resulting array can be controlled with the squeeze keyword, see above. Typical idioms for handling the return value are:: @@ -1455,22 +1474,22 @@ def subplots(nrows=1, ncols=1, *, sharex=False, sharey=False, squeeze=True, fig = figure(**fig_kw) axs = fig.subplots(nrows=nrows, ncols=ncols, sharex=sharex, sharey=sharey, squeeze=squeeze, subplot_kw=subplot_kw, - gridspec_kw=gridspec_kw) + gridspec_kw=gridspec_kw, height_ratios=height_ratios, + width_ratios=width_ratios) return fig, axs def subplot_mosaic(mosaic, *, sharex=False, sharey=False, - subplot_kw=None, gridspec_kw=None, empty_sentinel='.', - **fig_kw): + width_ratios=None, height_ratios=None, empty_sentinel='.', + subplot_kw=None, gridspec_kw=None, + per_subplot_kw=None, **fig_kw): """ Build a layout of Axes based on ASCII art or nested lists. This is a helper function to build complex GridSpec layouts visually. - .. note :: - - This API is provisional and may be revised in the future based on - early user feedback. + See :doc:`/gallery/subplots_axes_and_figures/mosaic` + for an example and full API documentation Parameters ---------- @@ -1509,13 +1528,17 @@ def subplot_mosaic(mosaic, *, sharex=False, sharey=False, behave as for `subplots`. If False, each subplot's x- or y-axis will be independent. - subplot_kw : dict, optional - Dictionary with keywords passed to the `.Figure.add_subplot` call - used to create each subplot. + width_ratios : array-like of length *ncols*, optional + Defines the relative widths of the columns. Each column gets a + relative width of ``width_ratios[i] / sum(width_ratios)``. + If not given, all columns will have the same width. Convenience + for ``gridspec_kw={'width_ratios': [...]}``. - gridspec_kw : dict, optional - Dictionary with keywords passed to the `.GridSpec` constructor used - to create the grid the subplots are placed on. + height_ratios : array-like of length *nrows*, optional + Defines the relative heights of the rows. Each row gets a + relative height of ``height_ratios[i] / sum(height_ratios)``. + If not given, all rows will have the same height. Convenience + for ``gridspec_kw={'height_ratios': [...]}``. empty_sentinel : object, optional Entry in the layout to mean "leave this space empty". Defaults @@ -1523,13 +1546,35 @@ def subplot_mosaic(mosaic, *, sharex=False, sharey=False, `inspect.cleandoc` to remove leading white space, which may interfere with using white-space as the empty sentinel. + subplot_kw : dict, optional + Dictionary with keywords passed to the `.Figure.add_subplot` call + used to create each subplot. These values may be overridden by + values in *per_subplot_kw*. + + per_subplot_kw : dict, optional + A dictionary mapping the Axes identifiers or tuples of identifiers + to a dictionary of keyword arguments to be passed to the + `.Figure.add_subplot` call used to create each subplot. The values + in these dictionaries have precedence over the values in + *subplot_kw*. + + If *mosaic* is a string, and thus all keys are single characters, + it is possible to use a single string instead of a tuple as keys; + i.e. ``"AB"`` is equivalent to ``("A", "B")``. + + .. versionadded:: 3.7 + + gridspec_kw : dict, optional + Dictionary with keywords passed to the `.GridSpec` constructor used + to create the grid the subplots are placed on. + **fig_kw All additional keyword arguments are passed to the `.pyplot.figure` call. Returns ------- - fig : `~.figure.Figure` + fig : `.Figure` The new figure dict[label, Axes] @@ -1541,8 +1586,10 @@ def subplot_mosaic(mosaic, *, sharex=False, sharey=False, fig = figure(**fig_kw) ax_dict = fig.subplot_mosaic( mosaic, sharex=sharex, sharey=sharey, + height_ratios=height_ratios, width_ratios=width_ratios, subplot_kw=subplot_kw, gridspec_kw=gridspec_kw, - empty_sentinel=empty_sentinel + empty_sentinel=empty_sentinel, + per_subplot_kw=per_subplot_kw, ) return fig, ax_dict @@ -1568,12 +1615,11 @@ def subplot2grid(shape, loc, rowspan=1, colspan=1, fig=None, **kwargs): Returns ------- - `.axes.SubplotBase`, or another subclass of `~.axes.Axes` + `~.axes.Axes` - The axes of the subplot. The returned axes base class depends on the - projection used. It is `~.axes.Axes` if rectilinear projection is used - and `.projections.polar.PolarAxes` if polar projection is used. The - returned axes is then a subplot subclass of the base class. + The Axes of the subplot. The returned Axes can actually be an instance + of a subclass, such as `.projections.polar.PolarAxes` for polar + projections. Notes ----- @@ -1596,13 +1642,14 @@ def subplot2grid(shape, loc, rowspan=1, colspan=1, fig=None, **kwargs): subplotspec = gs.new_subplotspec(loc, rowspan=rowspan, colspan=colspan) ax = fig.add_subplot(subplotspec, **kwargs) - bbox = ax.bbox - axes_to_delete = [] - for other_ax in fig.axes: - if other_ax == ax: - continue - if bbox.fully_overlaps(other_ax.bbox): - axes_to_delete.append(other_ax) + + axes_to_delete = [other for other in fig.axes + if other != ax and ax.bbox.fully_overlaps(other.bbox)] + if axes_to_delete: + _api.warn_deprecated( + "3.6", message="Auto-removal of overlapping axes is deprecated " + "since %(since)s and will be removed %(removal)s; explicitly call " + "ax.remove() as needed.") for ax_to_del in axes_to_delete: delaxes(ax_to_del) @@ -1758,7 +1805,7 @@ def ylim(*args, **kwargs): return ret -def xticks(ticks=None, labels=None, **kwargs): +def xticks(ticks=None, labels=None, *, minor=False, **kwargs): """ Get or set the current tick locations and labels of the x-axis. @@ -1771,6 +1818,9 @@ def xticks(ticks=None, labels=None, **kwargs): labels : array-like, optional The labels to place at the given *ticks* locations. This argument can only be passed if *ticks* is passed as well. + minor : bool, default: False + If ``False``, get/set the major ticks/labels; if ``True``, the minor + ticks/labels. **kwargs `.Text` properties can be used to control the appearance of the labels. @@ -1801,24 +1851,24 @@ def xticks(ticks=None, labels=None, **kwargs): ax = gca() if ticks is None: - locs = ax.get_xticks() + locs = ax.get_xticks(minor=minor) if labels is not None: raise TypeError("xticks(): Parameter 'labels' can't be set " "without setting 'ticks'") else: - locs = ax.set_xticks(ticks) + locs = ax.set_xticks(ticks, minor=minor) if labels is None: - labels = ax.get_xticklabels() + labels = ax.get_xticklabels(minor=minor) for l in labels: - l.update(kwargs) + l._internal_update(kwargs) else: - labels = ax.set_xticklabels(labels, **kwargs) + labels = ax.set_xticklabels(labels, minor=minor, **kwargs) return locs, labels -def yticks(ticks=None, labels=None, **kwargs): +def yticks(ticks=None, labels=None, *, minor=False, **kwargs): """ Get or set the current tick locations and labels of the y-axis. @@ -1831,6 +1881,9 @@ def yticks(ticks=None, labels=None, **kwargs): labels : array-like, optional The labels to place at the given *ticks* locations. This argument can only be passed if *ticks* is passed as well. + minor : bool, default: False + If ``False``, get/set the major ticks/labels; if ``True``, the minor + ticks/labels. **kwargs `.Text` properties can be used to control the appearance of the labels. @@ -1861,19 +1914,19 @@ def yticks(ticks=None, labels=None, **kwargs): ax = gca() if ticks is None: - locs = ax.get_yticks() + locs = ax.get_yticks(minor=minor) if labels is not None: raise TypeError("yticks(): Parameter 'labels' can't be set " "without setting 'ticks'") else: - locs = ax.set_yticks(ticks) + locs = ax.set_yticks(ticks, minor=minor) if labels is None: - labels = ax.get_yticklabels() + labels = ax.get_yticklabels(minor=minor) for l in labels: - l.update(kwargs) + l._internal_update(kwargs) else: - labels = ax.set_yticklabels(labels, **kwargs) + labels = ax.set_yticklabels(labels, minor=minor, **kwargs) return locs, labels @@ -2017,24 +2070,23 @@ def thetagrids(angles=None, labels=None, fmt=None, **kwargs): return lines, labels -## Plotting Info ## - - -def plotting(): - pass - - +@_api.deprecated("3.7", pending=True) def get_plot_commands(): """ Get a sorted list of all of the plotting commands. """ + NON_PLOT_COMMANDS = { + 'connect', 'disconnect', 'get_current_fig_manager', 'ginput', + 'new_figure_manager', 'waitforbuttonpress'} + return (name for name in _get_pyplot_commands() + if name not in NON_PLOT_COMMANDS) + + +def _get_pyplot_commands(): # This works by searching for all functions in this module and removing # a few hard-coded exclusions, as well as all of the colormap-setting # functions, and anything marked as private with a preceding underscore. - exclude = {'colormaps', 'colors', 'connect', 'disconnect', - 'get_plot_commands', 'get_current_fig_manager', 'ginput', - 'plotting', 'waitforbuttonpress'} - exclude |= set(colormaps) + exclude = {'colormaps', 'colors', 'get_plot_commands', *colormaps} this_module = inspect.getmodule(get_plot_commands) return sorted( name for name, obj in globals().items() @@ -2043,62 +2095,11 @@ def get_plot_commands(): and inspect.getmodule(obj) is this_module) -def _setup_pyplot_info_docstrings(): - """ - Setup the docstring of `plotting` and of the colormap-setting functions. - - These must be done after the entire module is imported, so it is called - from the end of this module, which is generated by boilerplate.py. - """ - commands = get_plot_commands() - - first_sentence = re.compile(r"(?:\s*).+?\.(?:\s+|$)", flags=re.DOTALL) - - # Collect the first sentence of the docstring for all of the - # plotting commands. - rows = [] - max_name = len("Function") - max_summary = len("Description") - for name in commands: - doc = globals()[name].__doc__ - summary = '' - if doc is not None: - match = first_sentence.match(doc) - if match is not None: - summary = inspect.cleandoc(match.group(0)).replace('\n', ' ') - name = '`%s`' % name - rows.append([name, summary]) - max_name = max(max_name, len(name)) - max_summary = max(max_summary, len(summary)) - - separator = '=' * max_name + ' ' + '=' * max_summary - lines = [ - separator, - '{:{}} {:{}}'.format('Function', max_name, 'Description', max_summary), - separator, - ] + [ - '{:{}} {:{}}'.format(name, max_name, summary, max_summary) - for name, summary in rows - ] + [ - separator, - ] - plotting.__doc__ = '\n'.join(lines) - - for cm_name in colormaps: - if cm_name in globals(): - globals()[cm_name].__doc__ = f""" - Set the colormap to {cm_name!r}. - - This changes the default colormap as well as the colormap of the current - image if there is one. See ``help(colormaps)`` for more information. - """ - - ## Plotting part 1: manually generated functions and wrappers ## @_copy_docstring_and_deprecators(Figure.colorbar) -def colorbar(mappable=None, cax=None, ax=None, **kw): +def colorbar(mappable=None, cax=None, ax=None, **kwargs): if mappable is None: mappable = gci() if mappable is None: @@ -2106,7 +2107,7 @@ def colorbar(mappable=None, cax=None, ax=None, **kw): 'creation. First define a mappable such as ' 'an image (with imshow) or a contour set (' 'with contourf).') - ret = gcf().colorbar(mappable, cax=cax, ax=ax, **kw) + ret = gcf().colorbar(mappable, cax=cax, ax=ax, **kwargs) return ret @@ -2131,6 +2132,13 @@ def clim(vmin=None, vmax=None): im.set_clim(vmin, vmax) +# eventually this implementation should move here, use indirection for now to +# avoid having two copies of the code floating around. +def get_cmap(name=None, lut=None): + return cm._get_cmap(name=name, lut=lut) +get_cmap.__doc__ = cm._get_cmap.__doc__ + + def set_cmap(cmap): """ Set the default colormap, and applies it to the current image if any. @@ -2146,7 +2154,7 @@ def set_cmap(cmap): matplotlib.cm.register_cmap matplotlib.cm.get_cmap """ - cmap = cm.get_cmap(cmap) + cmap = get_cmap(cmap) rc('image', cmap=cmap.name) im = gci() @@ -2243,10 +2251,10 @@ def polar(*args, **kwargs): # requested, ignore rcParams['backend'] and force selection of a backend that # is compatible with the current running interactive framework. if (rcParams["backend_fallback"] - and dict.__getitem__(rcParams, "backend") in ( + and rcParams._get_backend_or_none() in ( set(_interactive_bk) - {'WebAgg', 'nbAgg'}) and cbook._get_running_interactive_framework()): - dict.__setitem__(rcParams, "backend", rcsetup._auto_backend_sentinel) + rcParams._set("backend", rcsetup._auto_backend_sentinel) ################# REMAINING CONTENT GENERATED BY boilerplate.py ############## @@ -2270,8 +2278,8 @@ def figtext(x, y, s, fontdict=None, **kwargs): # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Figure.gca) -def gca(**kwargs): - return gcf().gca(**kwargs) +def gca(): + return gcf().gca() # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @@ -2339,8 +2347,13 @@ def angle_spectrum( # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.annotate) -def annotate(text, xy, *args, **kwargs): - return gca().annotate(text, xy, *args, **kwargs) +def annotate( + text, xy, xytext=None, xycoords='data', textcoords=None, + arrowprops=None, annotation_clip=None, **kwargs): + return gca().annotate( + text, xy, xytext=xytext, xycoords=xycoords, + textcoords=textcoords, arrowprops=arrowprops, + annotation_clip=annotation_clip, **kwargs) # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @@ -2369,8 +2382,8 @@ def axhspan(ymin, ymax, xmin=0, xmax=1, **kwargs): # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.axis) -def axis(*args, emit=True, **kwargs): - return gca().axis(*args, emit=emit, **kwargs) +def axis(arg=None, /, *, emit=True, **kwargs): + return gca().axis(arg, emit=emit, **kwargs) # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @@ -2411,9 +2424,12 @@ def barbs(*args, data=None, **kwargs): # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.barh) -def barh(y, width, height=0.8, left=None, *, align='center', **kwargs): +def barh( + y, width, height=0.8, left=None, *, align='center', + data=None, **kwargs): return gca().barh( - y, width, height=height, left=left, align=align, **kwargs) + y, width, height=height, left=left, align=align, + **({"data": data} if data is not None else {}), **kwargs) # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @@ -2436,7 +2452,7 @@ def boxplot( showfliers=None, boxprops=None, labels=None, flierprops=None, medianprops=None, meanprops=None, capprops=None, whiskerprops=None, manage_ticks=True, autorange=False, - zorder=None, *, data=None): + zorder=None, capwidths=None, *, data=None): return gca().boxplot( x, notch=notch, sym=sym, vert=vert, whis=whis, positions=positions, widths=widths, patch_artist=patch_artist, @@ -2447,7 +2463,7 @@ def boxplot( flierprops=flierprops, medianprops=medianprops, meanprops=meanprops, capprops=capprops, whiskerprops=whiskerprops, manage_ticks=manage_ticks, - autorange=autorange, zorder=zorder, + autorange=autorange, zorder=zorder, capwidths=capwidths, **({"data": data} if data is not None else {})) @@ -2530,12 +2546,12 @@ def errorbar( @_copy_docstring_and_deprecators(Axes.eventplot) def eventplot( positions, orientation='horizontal', lineoffsets=1, - linelengths=1, linewidths=None, colors=None, + linelengths=1, linewidths=None, colors=None, alpha=None, linestyles='solid', *, data=None, **kwargs): return gca().eventplot( positions, orientation=orientation, lineoffsets=lineoffsets, linelengths=linelengths, linewidths=linewidths, colors=colors, - linestyles=linestyles, + alpha=alpha, linestyles=linestyles, **({"data": data} if data is not None else {}), **kwargs) @@ -2645,8 +2661,8 @@ def hlines( # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.imshow) def imshow( - X, cmap=None, norm=None, aspect=None, interpolation=None, - alpha=None, vmin=None, vmax=None, origin=None, extent=None, *, + X, cmap=None, norm=None, *, aspect=None, interpolation=None, + alpha=None, vmin=None, vmax=None, origin=None, extent=None, interpolation_stage=None, filternorm=True, filterrad=4.0, resample=None, url=None, data=None, **kwargs): __ret = gca().imshow( @@ -2752,7 +2768,7 @@ def pie( pctdistance=0.6, shadow=False, labeldistance=1.1, startangle=0, radius=1, counterclock=True, wedgeprops=None, textprops=None, center=(0, 0), frame=False, - rotatelabels=False, *, normalize=True, data=None): + rotatelabels=False, *, normalize=True, hatch=None, data=None): return gca().pie( x, explode=explode, labels=labels, colors=colors, autopct=autopct, pctdistance=pctdistance, shadow=shadow, @@ -2760,7 +2776,7 @@ def pie( radius=radius, counterclock=counterclock, wedgeprops=wedgeprops, textprops=textprops, center=center, frame=frame, rotatelabels=rotatelabels, normalize=normalize, - **({"data": data} if data is not None else {})) + hatch=hatch, **({"data": data} if data is not None else {})) # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @@ -2880,8 +2896,9 @@ def stackplot( @_copy_docstring_and_deprecators(Axes.stem) def stem( *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, - label=None, use_line_collection=True, orientation='vertical', - data=None): + label=None, + use_line_collection=_api.deprecation._deprecated_parameter, + orientation='vertical', data=None): return gca().stem( *args, linefmt=linefmt, markerfmt=markerfmt, basefmt=basefmt, bottom=bottom, label=label, @@ -2904,7 +2921,8 @@ def streamplot( x, y, u, v, density=1, linewidth=None, color=None, cmap=None, norm=None, arrowsize=1, arrowstyle='-|>', minlength=0.1, transform=None, zorder=None, start_points=None, maxlength=4.0, - integration_direction='both', *, data=None): + integration_direction='both', broken_streamlines=True, *, + data=None): __ret = gca().streamplot( x, y, u, v, density=density, linewidth=linewidth, color=color, cmap=cmap, norm=norm, arrowsize=arrowsize, @@ -2912,6 +2930,7 @@ def streamplot( transform=transform, zorder=zorder, start_points=start_points, maxlength=maxlength, integration_direction=integration_direction, + broken_streamlines=broken_streamlines, **({"data": data} if data is not None else {})) sci(__ret.lines) return __ret @@ -3068,25 +3087,209 @@ def yscale(value, **kwargs): # Autogenerated by boilerplate.py. Do not edit as changes will be lost. -def autumn(): set_cmap('autumn') -def bone(): set_cmap('bone') -def cool(): set_cmap('cool') -def copper(): set_cmap('copper') -def flag(): set_cmap('flag') -def gray(): set_cmap('gray') -def hot(): set_cmap('hot') -def hsv(): set_cmap('hsv') -def jet(): set_cmap('jet') -def pink(): set_cmap('pink') -def prism(): set_cmap('prism') -def spring(): set_cmap('spring') -def summer(): set_cmap('summer') -def winter(): set_cmap('winter') -def magma(): set_cmap('magma') -def inferno(): set_cmap('inferno') -def plasma(): set_cmap('plasma') -def viridis(): set_cmap('viridis') -def nipy_spectral(): set_cmap('nipy_spectral') - - -_setup_pyplot_info_docstrings() +def autumn(): + """ + Set the colormap to 'autumn'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('autumn') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def bone(): + """ + Set the colormap to 'bone'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('bone') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def cool(): + """ + Set the colormap to 'cool'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('cool') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def copper(): + """ + Set the colormap to 'copper'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('copper') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def flag(): + """ + Set the colormap to 'flag'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('flag') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def gray(): + """ + Set the colormap to 'gray'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('gray') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def hot(): + """ + Set the colormap to 'hot'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('hot') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def hsv(): + """ + Set the colormap to 'hsv'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('hsv') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def jet(): + """ + Set the colormap to 'jet'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('jet') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def pink(): + """ + Set the colormap to 'pink'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('pink') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def prism(): + """ + Set the colormap to 'prism'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('prism') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def spring(): + """ + Set the colormap to 'spring'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('spring') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def summer(): + """ + Set the colormap to 'summer'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('summer') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def winter(): + """ + Set the colormap to 'winter'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('winter') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def magma(): + """ + Set the colormap to 'magma'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('magma') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def inferno(): + """ + Set the colormap to 'inferno'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('inferno') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def plasma(): + """ + Set the colormap to 'plasma'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('plasma') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def viridis(): + """ + Set the colormap to 'viridis'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('viridis') + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +def nipy_spectral(): + """ + Set the colormap to 'nipy_spectral'. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap('nipy_spectral') diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index bf518eddaeba..1d80ed5343ac 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -19,7 +19,7 @@ import numpy as np from numpy import ma -from matplotlib import _api, cbook, docstring, font_manager +from matplotlib import _api, cbook, _docstring import matplotlib.artist as martist import matplotlib.collections as mcollections from matplotlib.patches import CirclePolygon @@ -32,34 +32,28 @@ Call signature:: - quiver([X, Y], U, V, [C], **kw) + quiver([X, Y], U, V, [C], **kwargs) *X*, *Y* define the arrow locations, *U*, *V* define the arrow directions, and *C* optionally sets the color. -Each arrow is internally represented by a filled polygon with a default edge -linewidth of 0. As a result, an arrow is rather a filled area, not a line with -a head, and `.PolyCollection` properties like *linewidth*, *linestyle*, -*facecolor*, etc. act accordingly. - -**Arrow size** +**Arrow length** The default settings auto-scales the length of the arrows to a reasonable size. To change this behavior see the *scale* and *scale_units* parameters. **Arrow shape** -The defaults give a slightly swept-back arrow; to make the head a -triangle, make *headaxislength* the same as *headlength*. To make the -arrow more pointed, reduce *headwidth* or increase *headlength* and -*headaxislength*. To make the head smaller relative to the shaft, -scale down all the head parameters. You will probably do best to leave -minshaft alone. +The arrow shape is determined by *width*, *headwidth*, *headlength* and +*headaxislength*. See the notes below. -**Arrow outline** +**Arrow styling** + +Each arrow is internally represented by a filled polygon with a default edge +linewidth of 0. As a result, an arrow is rather a filled area, not a line with +a head, and `.PolyCollection` properties like *linewidth*, *edgecolor*, +*facecolor*, etc. act accordingly. -*linewidths* and *edgecolors* can be used to customize the arrow -outlines. Parameters ---------- @@ -74,11 +68,12 @@ must match the column and row dimensions of *U* and *V*. U, V : 1D or 2D array-like - The x and y direction components of the arrow vectors. + The x and y direction components of the arrow vectors. The interpretation + of these components (in data or in screen space) depends on *angles*. - They must have the same number of elements, matching the number of arrow - locations. *U* and *V* may be masked. Only locations unmasked in - *U*, *V*, and *C* will be drawn. + *U* and *V* must have the same number of elements, matching the number of + arrow locations in *X*, *Y*. *U* and *V* may be masked. Locations masked + in any of *U*, *V*, and *C* will not be drawn. C : 1D or 2D array-like, optional Numeric data that defines the arrow colors by colormapping via *norm* and @@ -88,38 +83,20 @@ use *color* instead. The size of *C* must match the number of arrow locations. -units : {'width', 'height', 'dots', 'inches', 'x', 'y', 'xy'}, default: 'width' - The arrow dimensions (except for *length*) are measured in multiples of - this unit. - - The following values are supported: - - - 'width', 'height': The width or height of the axis. - - 'dots', 'inches': Pixels or inches based on the figure dpi. - - 'x', 'y', 'xy': *X*, *Y* or :math:`\\sqrt{X^2 + Y^2}` in data units. - - The arrows scale differently depending on the units. For - 'x' or 'y', the arrows get larger as one zooms in; for other - units, the arrow size is independent of the zoom state. For - 'width or 'height', the arrow size increases with the width and - height of the axes, respectively, when the window is resized; - for 'dots' or 'inches', resizing does not change the arrows. - angles : {'uv', 'xy'} or array-like, default: 'uv' Method for determining the angle of the arrows. - - 'uv': The arrow axis aspect ratio is 1 so that - if *U* == *V* the orientation of the arrow on the plot is 45 degrees - counter-clockwise from the horizontal axis (positive to the right). + - 'uv': Arrow direction in screen coordinates. Use this if the arrows + symbolize a quantity that is not based on *X*, *Y* data coordinates. - Use this if the arrows symbolize a quantity that is not based on - *X*, *Y* data coordinates. + If *U* == *V* the orientation of the arrow on the plot is 45 degrees + counter-clockwise from the horizontal axis (positive to the right). - - 'xy': Arrows point from (x, y) to (x+u, y+v). - Use this for plotting a gradient field, for example. + - 'xy': Arrow direction in data coordinates, i.e. the arrows point from + (x, y) to (x+u, y+v). Use this e.g. for plotting a gradient field. - - Alternatively, arbitrary angles may be specified explicitly as an array - of values in degrees, counter-clockwise from the horizontal axis. + - Arbitrary angles may be specified explicitly as an array of values + in degrees, counter-clockwise from the horizontal axis. In this case *U*, *V* is only used to determine the length of the arrows. @@ -127,7 +104,15 @@ Note: inverting a data axis will correspondingly invert the arrows only with ``angles='xy'``. +pivot : {'tail', 'mid', 'middle', 'tip'}, default: 'tail' + The part of the arrow that is anchored to the *X*, *Y* grid. The arrow + rotates about this point. + + 'mid' is a synonym for 'middle'. + scale : float, optional + Scales the length of the arrow inversely. + Number of data units per arrow length unit, e.g., m/s per plot width; a smaller scale parameter makes the arrow longer. Default is *None*. @@ -149,19 +134,42 @@ the same units as x and y, use ``angles='xy', scale_units='xy', scale=1``. +units : {'width', 'height', 'dots', 'inches', 'x', 'y', 'xy'}, default: 'width' + Affects the arrow size (except for the length). In particular, the shaft + *width* is measured in multiples of this unit. + + Supported values are: + + - 'width', 'height': The width or height of the Axes. + - 'dots', 'inches': Pixels or inches based on the figure dpi. + - 'x', 'y', 'xy': *X*, *Y* or :math:`\\sqrt{X^2 + Y^2}` in data units. + + The following table summarizes how these values affect the visible arrow + size under zooming and figure size changes: + + ================= ================= ================== + units zoom figure size change + ================= ================= ================== + 'x', 'y', 'xy' arrow size scales — + 'width', 'height' — arrow size scales + 'dots', 'inches' — — + ================= ================= ================== + width : float, optional - Shaft width in arrow units; default depends on choice of units, - above, and number of vectors; a typical starting value is about - 0.005 times the width of the plot. + Shaft width in arrow units. All head parameters are relative to *width*. + + The default depends on choice of *units* above, and number of vectors; + a typical starting value is about 0.005 times the width of the plot. headwidth : float, default: 3 - Head width as multiple of shaft width. + Head width as multiple of shaft *width*. See the notes below. headlength : float, default: 5 - Head length as multiple of shaft width. + Head length as multiple of shaft *width*. See the notes below. headaxislength : float, default: 4.5 - Head length at shaft intersection. + Head length at shaft intersection as multiple of shaft *width*. + See the notes below. minshaft : float, default: 1 Length below which arrow scales, in units of head length. Do not @@ -171,12 +179,6 @@ Minimum length as a multiple of shaft width; if an arrow length is less than this, plot a dot (hexagon) of this diameter instead. -pivot : {'tail', 'mid', 'middle', 'tip'}, default: 'tail' - The part of the arrow that is anchored to the *X*, *Y* grid. The arrow - rotates about this point. - - 'mid' is a synonym for 'middle'. - color : color or color sequence, optional Explicit color(s) for the arrows. If *C* has been set, *color* has no effect. @@ -200,9 +202,34 @@ See Also -------- .Axes.quiverkey : Add a key to a quiver plot. -""" % docstring.interpd.params -docstring.interpd.update(quiver_doc=_quiver_doc) +Notes +----- + +**Arrow shape** + +The arrow is drawn as a polygon using the nodes as shown below. The values +*headwidth*, *headlength*, and *headaxislength* are in units of *width*. + +.. image:: /_static/quiver_sizes.svg + :width: 500px + +The defaults give a slightly swept-back arrow. Here are some guidelines how to +get other head shapes: + +- To make the head a triangle, make *headaxislength* the same as *headlength*. +- To make the arrow more pointed, reduce *headwidth* or increase *headlength* + and *headaxislength*. +- To make the head smaller relative to the shaft, scale down all the head + parameters proportionally. +- To remove the head completely, set all *head* parameters to 0. +- To get a diamond-shaped head, make *headaxislength* larger than *headlength*. +- Warning: For *headaxislength* < (*headlength* / *headwidth*), the "headaxis" + nodes (i.e. the ones connecting the head with the shaft) will protrude out + of the head in forward direction so that the arrow head looks broken. +""" % _docstring.interpd.params + +_docstring.interpd.update(quiver_doc=_quiver_doc) class QuiverKey(martist.Artist): @@ -213,8 +240,7 @@ class QuiverKey(martist.Artist): def __init__(self, Q, X, Y, U, label, *, angle=0, coordinates='axes', color=None, labelsep=0.1, - labelpos='N', labelcolor=None, fontproperties=None, - **kw): + labelpos='N', labelcolor=None, fontproperties=None, **kwargs): """ Add a key to a quiver plot. @@ -276,15 +302,12 @@ def __init__(self, Q, X, Y, U, label, self.labelpos = labelpos self.labelcolor = labelcolor self.fontproperties = fontproperties or dict() - self.kw = kw - _fp = self.fontproperties - # boxprops = dict(facecolor='red') + self.kw = kwargs self.text = mtext.Text( - text=label, # bbox=boxprops, + text=label, horizontalalignment=self.halign[self.labelpos], verticalalignment=self.valign[self.labelpos], - fontproperties=font_manager.FontProperties._from_any(_fp)) - + fontproperties=self.fontproperties) if self.labelcolor is not None: self.text.set_color(self.labelcolor) self._dpi_at_last_init = None @@ -308,42 +331,33 @@ def _init(self): else 'uv') self.verts = self.Q._make_verts( np.array([u]), np.array([v]), angle) - kw = self.Q.polykw - kw.update(self.kw) + kwargs = self.Q.polykw + kwargs.update(self.kw) self.vector = mcollections.PolyCollection( - self.verts, - offsets=[(self.X, self.Y)], - transOffset=self.get_transform(), - **kw) + self.verts, + offsets=[(self.X, self.Y)], + offset_transform=self.get_transform(), + **kwargs) if self.color is not None: self.vector.set_color(self.color) self.vector.set_transform(self.Q.get_transform()) self.vector.set_figure(self.get_figure()) self._dpi_at_last_init = self.Q.axes.figure.dpi - def _text_x(self, x): - if self.labelpos == 'E': - return x + self.labelsep - elif self.labelpos == 'W': - return x - self.labelsep - else: - return x - - def _text_y(self, y): - if self.labelpos == 'N': - return y + self.labelsep - elif self.labelpos == 'S': - return y - self.labelsep - else: - return y + def _text_shift(self): + return { + "N": (0, +self.labelsep), + "S": (0, -self.labelsep), + "E": (+self.labelsep, 0), + "W": (-self.labelsep, 0), + }[self.labelpos] @martist.allow_rasterization def draw(self, renderer): self._init() self.vector.draw(renderer) - x, y = self.get_transform().transform((self.X, self.Y)) - self.text.set_x(self._text_x(x)) - self.text.set_y(self._text_y(y)) + pos = self.get_transform().transform((self.X, self.Y)) + self.text.set_position(pos + self._text_shift()) self.text.draw(renderer) self.stale = False @@ -392,20 +406,19 @@ def _parse_args(*args, caller_name='function'): """ X = Y = C = None - len_args = len(args) - if len_args == 2: + nargs = len(args) + if nargs == 2: # The use of atleast_1d allows for handling scalar arguments while also # keeping masked arrays U, V = np.atleast_1d(*args) - elif len_args == 3: + elif nargs == 3: U, V, C = np.atleast_1d(*args) - elif len_args == 4: + elif nargs == 4: X, Y, U, V = np.atleast_1d(*args) - elif len_args == 5: + elif nargs == 5: X, Y, U, V, C = np.atleast_1d(*args) else: - raise TypeError(f'{caller_name} takes 2-5 positional arguments but ' - f'{len_args} were given') + raise _api.nargs_error(caller_name, takes="from 2 to 5", given=nargs) nr, nc = (1, U.shape[0]) if U.ndim == 1 else U.shape @@ -450,11 +463,11 @@ class Quiver(mcollections.PolyCollection): _PIVOT_VALS = ('tail', 'middle', 'tip') - @docstring.Substitution(_quiver_doc) + @_docstring.Substitution(_quiver_doc) def __init__(self, ax, *args, scale=None, headwidth=3, headlength=5, headaxislength=4.5, minshaft=1, minlength=1, units='width', scale_units=None, - angles='uv', width=None, color='k', pivot='tail', **kw): + angles='uv', width=None, color='k', pivot='tail', **kwargs): """ The constructor takes one required argument, an Axes instance, followed by the args and kwargs described @@ -462,7 +475,7 @@ def __init__(self, ax, *args, %s """ self._axes = ax # The attr actually set by the Artist.axes property. - X, Y, U, V, C = _parse_args(*args, caller_name='quiver()') + X, Y, U, V, C = _parse_args(*args, caller_name='quiver') self.X = X self.Y = Y self.XY = np.column_stack((X, Y)) @@ -483,12 +496,12 @@ def __init__(self, ax, *args, self.pivot = pivot.lower() _api.check_in_list(self._PIVOT_VALS, pivot=self.pivot) - self.transform = kw.pop('transform', ax.transData) - kw.setdefault('facecolors', color) - kw.setdefault('linewidths', (0,)) - super().__init__([], offsets=self.XY, transOffset=self.transform, - closed=False, **kw) - self.polykw = kw + self.transform = kwargs.pop('transform', ax.transData) + kwargs.setdefault('facecolors', color) + kwargs.setdefault('linewidths', (0,)) + super().__init__([], offsets=self.XY, offset_transform=self.transform, + closed=False, **kwargs) + self.polykw = kwargs self.set_UVC(U, V, C) self._dpi_at_last_init = None @@ -515,8 +528,8 @@ def _init(self): def get_datalim(self, transData): trans = self.get_transform() - transOffset = self.get_offset_transform() - full_transform = (trans - transData) + (transOffset - transData) + offset_trf = self.get_offset_transform() + full_transform = (trans - transData) + (offset_trf - transData) XY = full_transform.transform(self.XY) bbox = transforms.Bbox.null() bbox.update_from_data_xy(XY, ignore=True) @@ -558,36 +571,18 @@ def set_UVC(self, U, V, C=None): self.stale = True def _dots_per_unit(self, units): - """ - Return a scale factor for converting from units to pixels - """ - if units in ('x', 'y', 'xy'): - if units == 'x': - dx0 = self.axes.viewLim.width - dx1 = self.axes.bbox.width - elif units == 'y': - dx0 = self.axes.viewLim.height - dx1 = self.axes.bbox.height - else: # 'xy' is assumed - dxx0 = self.axes.viewLim.width - dxx1 = self.axes.bbox.width - dyy0 = self.axes.viewLim.height - dyy1 = self.axes.bbox.height - dx1 = np.hypot(dxx1, dyy1) - dx0 = np.hypot(dxx0, dyy0) - dx = dx1 / dx0 - else: - if units == 'width': - dx = self.axes.bbox.width - elif units == 'height': - dx = self.axes.bbox.height - elif units == 'dots': - dx = 1.0 - elif units == 'inches': - dx = self.axes.figure.dpi - else: - raise ValueError('unrecognized units') - return dx + """Return a scale factor for converting from units to pixels.""" + bb = self.axes.bbox + vl = self.axes.viewLim + return _api.check_getitem({ + 'x': bb.width / vl.width, + 'y': bb.height / vl.height, + 'xy': np.hypot(*bb.size) / np.hypot(*vl.size), + 'width': bb.width, + 'height': bb.height, + 'dots': 1., + 'inches': self.axes.figure.dpi, + }, units=units) def _set_transform(self): """ @@ -727,7 +722,7 @@ def _h_arrows(self, length): # Mask handling is deferred to the caller, _make_verts. return X, Y - quiver_doc = _quiver_doc + quiver_doc = _api.deprecated("3.7")(property(lambda self: _quiver_doc)) _barbs_doc = r""" @@ -735,7 +730,7 @@ def _h_arrows(self, length): Call signature:: - barbs([X, Y], U, V, [C], **kw) + barbs([X, Y], U, V, [C], **kwargs) Where *X*, *Y* define the barb locations, *U*, *V* define the barb directions, and *C* optionally sets the color. @@ -864,9 +859,9 @@ def _h_arrows(self, length): arguments: %(PolyCollection:kwdoc)s -""" % docstring.interpd.params +""" % _docstring.interpd.params -docstring.interpd.update(barbs_doc=_barbs_doc) +_docstring.interpd.update(barbs_doc=_barbs_doc) class Barbs(mcollections.PolyCollection): @@ -883,14 +878,16 @@ class Barbs(mcollections.PolyCollection): From there :meth:`_make_barbs` is used to find the vertices of the polygon to represent the barb based on this information. """ + # This may be an abuse of polygons here to render what is essentially maybe # 1 triangle and a series of lines. It works fine as far as I can tell # however. - @docstring.interpd + + @_docstring.interpd def __init__(self, ax, *args, pivot='tip', length=7, barbcolor=None, flagcolor=None, sizes=None, fill_empty=False, barb_increments=None, - rounding=True, flip_barb=False, **kw): + rounding=True, flip_barb=False, **kwargs): """ The constructor takes one required argument, an Axes instance, followed by the args and kwargs described @@ -902,11 +899,9 @@ def __init__(self, ax, *args, self.barb_increments = barb_increments or dict() self.rounding = rounding self.flip = np.atleast_1d(flip_barb) - transform = kw.pop('transform', ax.transData) + transform = kwargs.pop('transform', ax.transData) self._pivot = pivot self._length = length - barbcolor = barbcolor - flagcolor = flagcolor # Flagcolor and barbcolor provide convenience parameters for # setting the facecolor and edgecolor, respectively, of the barb @@ -914,68 +909,68 @@ def __init__(self, ax, *args, # rest of the barb by default if None in (barbcolor, flagcolor): - kw['edgecolors'] = 'face' + kwargs['edgecolors'] = 'face' if flagcolor: - kw['facecolors'] = flagcolor + kwargs['facecolors'] = flagcolor elif barbcolor: - kw['facecolors'] = barbcolor + kwargs['facecolors'] = barbcolor else: # Set to facecolor passed in or default to black - kw.setdefault('facecolors', 'k') + kwargs.setdefault('facecolors', 'k') else: - kw['edgecolors'] = barbcolor - kw['facecolors'] = flagcolor + kwargs['edgecolors'] = barbcolor + kwargs['facecolors'] = flagcolor # Explicitly set a line width if we're not given one, otherwise # polygons are not outlined and we get no barbs - if 'linewidth' not in kw and 'lw' not in kw: - kw['linewidth'] = 1 + if 'linewidth' not in kwargs and 'lw' not in kwargs: + kwargs['linewidth'] = 1 # Parse out the data arrays from the various configurations supported - x, y, u, v, c = _parse_args(*args, caller_name='barbs()') + x, y, u, v, c = _parse_args(*args, caller_name='barbs') self.x = x self.y = y xy = np.column_stack((x, y)) # Make a collection barb_size = self._length ** 2 / 4 # Empirically determined - super().__init__([], (barb_size,), offsets=xy, transOffset=transform, - **kw) + super().__init__( + [], (barb_size,), offsets=xy, offset_transform=transform, **kwargs) self.set_transform(transforms.IdentityTransform()) self.set_UVC(u, v, c) def _find_tails(self, mag, rounding=True, half=5, full=10, flag=50): """ - Find how many of each of the tail pieces is necessary. Flag - specifies the increment for a flag, barb for a full barb, and half for - half a barb. Mag should be the magnitude of a vector (i.e., >= 0). + Find how many of each of the tail pieces is necessary. - This returns a tuple of: - - (*number of flags*, *number of barbs*, *half_flag*, *empty_flag*) + Parameters + ---------- + mag : `~numpy.ndarray` + Vector magnitudes; must be non-negative (and an actual ndarray). + rounding : bool, default: True + Whether to round or to truncate to the nearest half-barb. + half, full, flag : float, defaults: 5, 10, 50 + Increments for a half-barb, a barb, and a flag. - The bool *half_flag* indicates whether half of a barb is needed, - since there should only ever be one half on a given - barb. *empty_flag* flag is an array of flags to easily tell if - a barb is empty (too low to plot any barbs/flags. + Returns + ------- + n_flags, n_barbs : int array + For each entry in *mag*, the number of flags and barbs. + half_flag : bool array + For each entry in *mag*, whether a half-barb is needed. + empty_flag : bool array + For each entry in *mag*, whether nothing is drawn. """ - # If rounding, round to the nearest multiple of half, the smallest # increment if rounding: - mag = half * (mag / half + 0.5).astype(int) - - num_flags = np.floor(mag / flag).astype(int) - mag = mag % flag - - num_barb = np.floor(mag / full).astype(int) - mag = mag % full - + mag = half * np.around(mag / half) + n_flags, mag = divmod(mag, flag) + n_barb, mag = divmod(mag, full) half_flag = mag >= half - empty_flag = ~(half_flag | (num_flags > 0) | (num_barb > 0)) - - return num_flags, num_barb, half_flag, empty_flag + empty_flag = ~(half_flag | (n_flags > 0) | (n_barb > 0)) + return n_flags.astype(int), n_barb.astype(int), half_flag, empty_flag def _make_barbs(self, u, v, nflags, nbarbs, half_barb, empty_flag, length, pivot, sizes, fill_empty, flip): @@ -1147,9 +1142,8 @@ def set_UVC(self, U, V, C=None): _check_consistent_shapes(x, y, u, v, flip) magnitude = np.hypot(u, v) - flags, barbs, halves, empty = self._find_tails(magnitude, - self.rounding, - **self.barb_increments) + flags, barbs, halves, empty = self._find_tails( + magnitude, self.rounding, **self.barb_increments) # Get the vertices for each of the barbs @@ -1185,4 +1179,4 @@ def set_offsets(self, xy): super().set_offsets(xy) self.stale = True - barbs_doc = _barbs_doc + barbs_doc = _api.deprecated("3.7")(property(lambda self: _barbs_doc)) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 7edc1773b49e..22b11f44e8b5 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -25,7 +25,7 @@ from matplotlib import _api, cbook from matplotlib.cbook import ls_mapper from matplotlib.colors import Colormap, is_color_like -from matplotlib.fontconfig_pattern import parse_fontconfig_pattern +from matplotlib._fontconfig_pattern import parse_fontconfig_pattern from matplotlib._enums import JoinStyle, CapStyle # Don't let the original cycler collide with our validating cycler @@ -68,7 +68,7 @@ def __call__(self, s): name, = (k for k, v in globals().items() if v is self) _api.warn_deprecated( self._deprecated_since, name=name, obj_type="function") - if self.ignorecase: + if self.ignorecase and isinstance(s, str): s = s.lower() if s in self.valid: return self.valid[s] @@ -146,7 +146,7 @@ def validate_bool(b): elif b in ('f', 'n', 'no', 'off', 'false', '0', 0, False): return False else: - raise ValueError('Could not convert "%s" to bool' % b) + raise ValueError(f'Cannot convert {b!r} to bool') def validate_axisbelow(s): @@ -156,8 +156,8 @@ def validate_axisbelow(s): if isinstance(s, str): if s == 'line': return 'line' - raise ValueError('%s cannot be interpreted as' - ' True, False, or "line"' % s) + raise ValueError(f'{s!r} cannot be interpreted as' + ' True, False, or "line"') def validate_dpi(s): @@ -182,10 +182,7 @@ def validator(s): (s is None or isinstance(s, str) and s.lower() == "none")): return None if cls is str and not isinstance(s, str): - _api.warn_deprecated( - "3.5", message="Support for setting an rcParam that expects a " - "str value to a non-str value is deprecated since %(since)s " - "and support will be removed %(removal)s.") + raise ValueError(f'Could not convert {s!r} to str') try: return cls(s) except (TypeError, ValueError) as e: @@ -218,7 +215,7 @@ def _validate_pathlike(s): # between "" (cwd) and "." (cwd, but gets updated by user selections). return os.fsdecode(s) else: - return validate_string(s) # Emit deprecation warning. + return validate_string(s) def validate_fonttype(s): @@ -387,6 +384,20 @@ def validate_fontweight(s): raise ValueError(f'{s} is not a valid font weight.') from e +def validate_fontstretch(s): + stretchvalues = [ + 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', + 'normal', 'semi-expanded', 'expanded', 'extra-expanded', + 'ultra-expanded'] + # Note: Historically, stretchvalues have been case-sensitive in Matplotlib + if s in stretchvalues: + return s + try: + return int(s) + except (ValueError, TypeError) as e: + raise ValueError(f'{s} is not a valid font stretch.') from e + + def validate_font_properties(s): parse_fontconfig_pattern(s) return s @@ -414,7 +425,7 @@ def validate_whiskers(s): try: return float(s) except ValueError as e: - raise ValueError("Not a valid whisker value ['range', float, " + raise ValueError("Not a valid whisker value [float, " "(float, float)]") from e @@ -725,14 +736,14 @@ def validate_cycler(s): _DunderChecker().visit(ast.parse(s)) s = eval(s, {'cycler': cycler, '__builtins__': {}}) except BaseException as e: - raise ValueError("'%s' is not a valid cycler construction: %s" % - (s, e)) from e + raise ValueError(f"{s!r} is not a valid cycler construction: {e}" + ) from e # Should make sure what comes from the above eval() # is a Cycler object. if isinstance(s, Cycler): cycler_inst = s else: - raise ValueError("object was not a string or Cycler instance: %s" % s) + raise ValueError(f"Object is not a string or Cycler instance: {s!r}") unknowns = cycler_inst.keys - (set(_prop_validators) | set(_prop_aliases)) if unknowns: @@ -744,12 +755,11 @@ def validate_cycler(s): for prop in cycler_inst.keys: norm_prop = _prop_aliases.get(prop, prop) if norm_prop != prop and norm_prop in cycler_inst.keys: - raise ValueError("Cannot specify both '{0}' and alias '{1}'" - " in the same prop_cycle".format(norm_prop, prop)) + raise ValueError(f"Cannot specify both {norm_prop!r} and alias " + f"{prop!r} in the same prop_cycle") if norm_prop in checker: - raise ValueError("Another property was already aliased to '{0}'." - " Collision normalizing '{1}'.".format(norm_prop, - prop)) + raise ValueError(f"Another property was already aliased to " + f"{norm_prop!r}. Collision normalizing {prop!r}.") checker.update([norm_prop]) # This is just an extra-careful check, just in case there is some @@ -803,6 +813,7 @@ def _convert_validator_spec(key, conv): _validators = { "backend": validate_backend, "backend_fallback": validate_bool, + "figure.hooks": validate_stringlist, "toolbar": _validate_toolbar, "interactive": validate_bool, "timezone": validate_string, @@ -901,7 +912,7 @@ def _convert_validator_spec(key, conv): "font.family": validate_stringlist, # used by text object "font.style": validate_string, "font.variant": validate_string, - "font.stretch": validate_string, + "font.stretch": validate_fontstretch, "font.weight": validate_fontweight, "font.size": validate_float, # Base font size in points "font.serif": validate_stringlist, @@ -919,6 +930,7 @@ def _convert_validator_spec(key, conv): "text.hinting_factor": validate_int, "text.kerning_factor": validate_int, "text.antialiased": validate_bool, + "text.parse_math": validate_bool, "mathtext.cal": validate_font_properties, "mathtext.rm": validate_font_properties, @@ -946,6 +958,7 @@ def _convert_validator_spec(key, conv): "contour.negative_linestyle": _validate_linestyle, "contour.corner_mask": validate_bool, "contour.linewidth": validate_float_or_None, + "contour.algorithm": ["mpl2005", "mpl2014", "serial", "threaded"], # errorbar props "errorbar.capsize": validate_float, @@ -1005,6 +1018,10 @@ def _convert_validator_spec(key, conv): "polaraxes.grid": validate_bool, # display polar grid or not "axes3d.grid": validate_bool, # display 3d grid + "axes3d.xaxis.panecolor": validate_color, # 3d background pane + "axes3d.yaxis.panecolor": validate_color, # 3d background pane + "axes3d.zaxis.panecolor": validate_color, # 3d background pane + # scatter props "scatter.marker": validate_string, "scatter.edgecolors": validate_string, @@ -1117,6 +1134,10 @@ def _convert_validator_spec(key, conv): "figure.titlesize": validate_fontsize, "figure.titleweight": validate_fontweight, + # figure labels + "figure.labelsize": validate_fontsize, + "figure.labelweight": validate_fontweight, + # figure size in inches: width by height "figure.figsize": _listify_validator(validate_float, n=2), "figure.dpi": validate_float, diff --git a/lib/matplotlib/sankey.py b/lib/matplotlib/sankey.py index 196df813a3a4..39e8fce98fbb 100644 --- a/lib/matplotlib/sankey.py +++ b/lib/matplotlib/sankey.py @@ -11,7 +11,7 @@ from matplotlib.path import Path from matplotlib.patches import PathPatch from matplotlib.transforms import Affine2D -from matplotlib import docstring +from matplotlib import _docstring _log = logging.getLogger(__name__) @@ -204,12 +204,12 @@ def _arc(self, quadrant=0, cw=True, radius=1, center=(0, 0)): # Insignificant # [6.12303177e-17, 1.00000000e+00]]) [0.00000000e+00, 1.00000000e+00]]) - if quadrant == 0 or quadrant == 2: + if quadrant in (0, 2): if cw: vertices = ARC_VERTICES else: vertices = ARC_VERTICES[:, ::-1] # Swap x and y. - elif quadrant == 1 or quadrant == 3: + else: # 1, 3 # Negate x. if cw: # Swap x and y. @@ -299,15 +299,11 @@ def _add_output(self, path, angle, flow, length): else: # Vertical x += self.gap if angle == UP: - sign = 1 + sign, quadrant = 1, 3 else: - sign = -1 + sign, quadrant = -1, 0 tip = [x - flow / 2.0, y + sign * (length + tipheight)] - if angle == UP: - quadrant = 3 - else: - quadrant = 0 # Inner arc isn't needed if inner radius is zero if self.radius: path.extend(self._arc(quadrant=quadrant, @@ -351,7 +347,7 @@ def _revert(self, path, first_action=Path.LINETO): # path[2] = path[2][::-1] # return path - @docstring.dedent_interpd + @_docstring.dedent_interpd def add(self, patchlabel='', flows=None, orientations=None, labels='', trunklength=1.0, pathlengths=0.25, prior=None, connect=(0, 0), rotation=0, **kwargs): @@ -375,7 +371,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', the outside in. If the sum of the inputs and outputs is - nonzero, the discrepancy will appear as a cubic Bezier curve along + nonzero, the discrepancy will appear as a cubic Bézier curve along the top and bottom edges of the trunk. orientations : list of {-1, 0, 1} @@ -438,10 +434,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', Sankey.finish """ # Check and preprocess the arguments. - if flows is None: - flows = np.array([1.0, -1.0]) - else: - flows = np.array(flows) + flows = np.array([1.0, -1.0]) if flows is None else np.array(flows) n = flows.shape[0] # Number of flows if rotation is None: rotation = 0 @@ -525,7 +518,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', if orient == 1: if is_input: angles[i] = DOWN - elif not is_input: + elif is_input is False: # Be specific since is_input can be None. angles[i] = UP elif orient == 0: @@ -538,7 +531,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', f"but it must be -1, 0, or 1") if is_input: angles[i] = UP - elif not is_input: + elif is_input is False: angles[i] = DOWN # Justify the lengths of the paths. @@ -561,7 +554,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', if angle == DOWN and is_input: pathlengths[i] = ullength ullength += flow - elif angle == UP and not is_input: + elif angle == UP and is_input is False: pathlengths[i] = urlength urlength -= flow # Flow is negative for outputs. # Determine the lengths of the bottom-side arrows @@ -571,7 +564,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', if angle == UP and is_input: pathlengths[n - i - 1] = lllength lllength += flow - elif angle == DOWN and not is_input: + elif angle == DOWN and is_input is False: pathlengths[n - i - 1] = lrlength lrlength -= flow # Determine the lengths of the left-side arrows @@ -591,7 +584,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', for i, (angle, is_input, spec) in enumerate(zip( angles, are_inputs, list(zip(scaled_flows, pathlengths)))): if angle == RIGHT: - if not is_input: + if is_input is False: if has_right_output: pathlengths[i] = 0 else: @@ -637,7 +630,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', if angle == DOWN and is_input: tips[i, :], label_locations[i, :] = self._add_input( ulpath, angle, *spec) - elif angle == UP and not is_input: + elif angle == UP and is_input is False: tips[i, :], label_locations[i, :] = self._add_output( urpath, angle, *spec) # Add the bottom-side inputs and outputs from the middle outwards. @@ -647,7 +640,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', tip, label_location = self._add_input(llpath, angle, *spec) tips[n - i - 1, :] = tip label_locations[n - i - 1, :] = label_location - elif angle == DOWN and not is_input: + elif angle == DOWN and is_input is False: tip, label_location = self._add_output(lrpath, angle, *spec) tips[n - i - 1, :] = tip label_locations[n - i - 1, :] = label_location @@ -670,7 +663,7 @@ def add(self, patchlabel='', flows=None, orientations=None, labels='', has_right_output = False for i, (angle, is_input, spec) in enumerate(zip( angles, are_inputs, list(zip(scaled_flows, pathlengths)))): - if angle == RIGHT and not is_input: + if angle == RIGHT and is_input is False: if not has_right_output: # Make sure the upper path extends # at least as far as the lower one. diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 306a60aa4e66..01e09f11b444 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -16,14 +16,13 @@ import textwrap import numpy as np -from numpy import ma import matplotlib as mpl -from matplotlib import _api, docstring +from matplotlib import _api, _docstring from matplotlib.ticker import ( NullFormatter, ScalarFormatter, LogFormatterSciNotation, LogitFormatter, NullLocator, LogLocator, AutoLocator, AutoMinorLocator, - SymmetricalLogLocator, LogitLocator) + SymmetricalLogLocator, AsinhLocator, LogitLocator) from matplotlib.transforms import Transform, IdentityTransform @@ -251,7 +250,7 @@ def __str__(self): return "{}(base={})".format(type(self).__name__, self.base) def transform_non_affine(self, a): - return ma.power(self.base, a) + return np.power(self.base, a) def inverted(self): return LogTransform(self.base) @@ -390,6 +389,11 @@ def __init__(self, base, linthresh, linscale): def transform_non_affine(self, a): abs_a = np.abs(a) + if (abs_a < self.linthresh).all(): + _api.warn_external( + "All values for SymLogScale are below linthresh, making " + "it effectively linear. You likely should lower the value " + "of linthresh. ") with np.errstate(divide="ignore", invalid="ignore"): out = np.sign(a) * self.linthresh * ( np.power(self.base, @@ -458,6 +462,123 @@ def get_transform(self): return self._transform +class AsinhTransform(Transform): + """Inverse hyperbolic-sine transformation used by `.AsinhScale`""" + input_dims = output_dims = 1 + + def __init__(self, linear_width): + super().__init__() + if linear_width <= 0.0: + raise ValueError("Scale parameter 'linear_width' " + + "must be strictly positive") + self.linear_width = linear_width + + def transform_non_affine(self, a): + return self.linear_width * np.arcsinh(a / self.linear_width) + + def inverted(self): + return InvertedAsinhTransform(self.linear_width) + + +class InvertedAsinhTransform(Transform): + """Hyperbolic sine transformation used by `.AsinhScale`""" + input_dims = output_dims = 1 + + def __init__(self, linear_width): + super().__init__() + self.linear_width = linear_width + + def transform_non_affine(self, a): + return self.linear_width * np.sinh(a / self.linear_width) + + def inverted(self): + return AsinhTransform(self.linear_width) + + +class AsinhScale(ScaleBase): + """ + A quasi-logarithmic scale based on the inverse hyperbolic sine (asinh) + + For values close to zero, this is essentially a linear scale, + but for large magnitude values (either positive or negative) + it is asymptotically logarithmic. The transition between these + linear and logarithmic regimes is smooth, and has no discontinuities + in the function gradient in contrast to + the `.SymmetricalLogScale` ("symlog") scale. + + Specifically, the transformation of an axis coordinate :math:`a` is + :math:`a \\rightarrow a_0 \\sinh^{-1} (a / a_0)` where :math:`a_0` + is the effective width of the linear region of the transformation. + In that region, the transformation is + :math:`a \\rightarrow a + \\mathcal{O}(a^3)`. + For large values of :math:`a` the transformation behaves as + :math:`a \\rightarrow a_0 \\, \\mathrm{sgn}(a) \\ln |a| + \\mathcal{O}(1)`. + + .. note:: + + This API is provisional and may be revised in the future + based on early user feedback. + """ + + name = 'asinh' + + auto_tick_multipliers = { + 3: (2, ), + 4: (2, ), + 5: (2, ), + 8: (2, 4), + 10: (2, 5), + 16: (2, 4, 8), + 64: (4, 16), + 1024: (256, 512) + } + + def __init__(self, axis, *, linear_width=1.0, + base=10, subs='auto', **kwargs): + """ + Parameters + ---------- + linear_width : float, default: 1 + The scale parameter (elsewhere referred to as :math:`a_0`) + defining the extent of the quasi-linear region, + and the coordinate values beyond which the transformation + becomes asymptotically logarithmic. + base : int, default: 10 + The number base used for rounding tick locations + on a logarithmic scale. If this is less than one, + then rounding is to the nearest integer multiple + of powers of ten. + subs : sequence of int + Multiples of the number base used for minor ticks. + If set to 'auto', this will use built-in defaults, + e.g. (2, 5) for base=10. + """ + super().__init__(axis) + self._transform = AsinhTransform(linear_width) + self._base = int(base) + if subs == 'auto': + self._subs = self.auto_tick_multipliers.get(self._base) + else: + self._subs = subs + + linear_width = property(lambda self: self._transform.linear_width) + + def get_transform(self): + return self._transform + + def set_default_locators_and_formatters(self, axis): + axis.set(major_locator=AsinhLocator(self.linear_width, + base=self._base), + minor_locator=AsinhLocator(self.linear_width, + base=self._base, + subs=self._subs), + minor_formatter=NullFormatter()) + if self._base > 1: + axis.set_major_formatter(LogFormatterSciNotation(self._base)) + else: + axis.set_major_formatter('{x:.3g}'), + + class LogitTransform(Transform): input_dims = output_dims = 1 @@ -568,6 +689,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos): 'linear': LinearScale, 'log': LogScale, 'symlog': SymmetricalLogScale, + 'asinh': AsinhScale, 'logit': LogitScale, 'function': FuncScale, 'functionlog': FuncScaleLog, @@ -588,11 +710,6 @@ def scale_factory(scale, axis, **kwargs): scale : {%(names)s} axis : `matplotlib.axis.Axis` """ - if scale != scale.lower(): - _api.warn_deprecated( - "3.5", message="Support for case-insensitive scales is deprecated " - "since %(since)s and support will be removed %(removal)s.") - scale = scale.lower() scale_cls = _api.check_getitem(_scale_mapping, scale=scale) return scale_cls(axis, **kwargs) @@ -630,7 +747,7 @@ def _get_scale_docs(): return "\n".join(docs) -docstring.interpd.update( +_docstring.interpd.update( scale_type='{%s}' % ', '.join([repr(x) for x in get_scale_names()]), scale_docs=_get_scale_docs().rstrip(), ) diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index 4024bccddea1..c942085e2159 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -2,10 +2,13 @@ A directive for including a Matplotlib plot in a Sphinx document ================================================================ -By default, in HTML output, `plot` will include a .png file with a link to a -high-res .png and .pdf. In LaTeX output, it will include a .pdf. +This is a Sphinx extension providing a reStructuredText directive +``.. plot::`` for including a plot in a Sphinx document. -The source code for the plot may be included in one of three ways: +In HTML output, ``.. plot::`` will include a .png file with a link +to a high-res .png and .pdf. In LaTeX output, it will include a .pdf. + +The plot content may be defined in one of three ways: 1. **A path to a source file** as the argument to the directive:: @@ -28,10 +31,8 @@ .. plot:: import matplotlib.pyplot as plt - import matplotlib.image as mpimg - import numpy as np - img = mpimg.imread('_static/stinkbug.png') - imgplot = plt.imshow(img) + plt.plot([1, 2, 3], [4, 5, 6]) + plt.title("A plotting exammple") 3. Using **doctest** syntax:: @@ -44,22 +45,22 @@ Options ------- -The ``plot`` directive supports the following options: +The ``.. plot::`` directive supports the following options: - format : {'python', 'doctest'} + ``:format:`` : {'python', 'doctest'} The format of the input. If unset, the format is auto-detected. - include-source : bool + ``:include-source:`` : bool Whether to display the source code. The default can be changed using - the `plot_include_source` variable in :file:`conf.py` (which itself + the ``plot_include_source`` variable in :file:`conf.py` (which itself defaults to False). - encoding : str - If this source file is in a non-UTF8 or non-ASCII encoding, the - encoding must be specified using the ``:encoding:`` option. The - encoding will not be inferred using the ``-*- coding -*-`` metacomment. + ``:show-source-link:`` : bool + Whether to show a link to the source in HTML. The default can be + changed using the ``plot_html_show_source_link`` variable in + :file:`conf.py` (which itself defaults to True). - context : bool or str + ``:context:`` : bool or str If provided, the code will be run in the context of all previous plot directives for which the ``:context:`` option was specified. This only applies to inline code plot directives, not those run from files. If @@ -68,18 +69,19 @@ running the code. ``:context: close-figs`` keeps the context but closes previous figures before running the code. - nofigs : bool + ``:nofigs:`` : bool If specified, the code block will be run, but no figures will be inserted. This is usually useful with the ``:context:`` option. - caption : str + ``:caption:`` : str If specified, the option's argument will be used as a caption for the figure. This overwrites the caption given in the content, when the plot is generated from a file. -Additionally, this directive supports all of the options of the `image` -directive, except for *target* (since plot will add its own target). These -include *alt*, *height*, *width*, *scale*, *align* and *class*. +Additionally, this directive supports all the options of the `image directive +`_, +except for ``:target:`` (since plot will add its own target). These include +``:alt:``, ``:height:``, ``:width:``, ``:scale:``, ``:align:`` and ``:class:``. Configuration options --------------------- @@ -159,12 +161,9 @@ import matplotlib from matplotlib.backend_bases import FigureManagerBase import matplotlib.pyplot as plt -from matplotlib import _api, _pylab_helpers, cbook +from matplotlib import _pylab_helpers, cbook matplotlib.use("agg") -align = _api.deprecated( - "3.4", alternative="docutils.parsers.rst.directives.images.Image.align")( - Image.align) __version__ = 2 @@ -183,7 +182,7 @@ def _option_boolean(arg): elif arg.strip().lower() in ('yes', '1', 'true'): return True else: - raise ValueError('"%s" unknown boolean' % arg) + raise ValueError(f'{arg!r} unknown boolean') def _option_context(arg): @@ -196,11 +195,6 @@ def _option_format(arg): return directives.choice(arg, ('python', 'doctest')) -def _deprecated_option_encoding(arg): - _api.warn_deprecated("3.5", name="encoding", obj_type="option") - return directives.encoding(arg) - - def mark_plot_labels(app, document): """ To make plots referenceable, we need to move the reference from the @@ -246,10 +240,10 @@ class PlotDirective(Directive): 'align': Image.align, 'class': directives.class_option, 'include-source': _option_boolean, + 'show-source-link': _option_boolean, 'format': _option_format, 'context': _option_context, 'nofigs': directives.flag, - 'encoding': _deprecated_option_encoding, 'caption': directives.unchanged, } @@ -267,7 +261,8 @@ def _copy_css_file(app, exc): src = cbook._get_data_path('plot_directive/plot_directive.css') dst = app.outdir / Path('_static') dst.mkdir(exist_ok=True) - shutil.copy(src, dst) + # Use copyfile because we do not want to copy src's permissions. + shutil.copyfile(src, dst / Path('plot_directive.css')) def setup(app): @@ -310,47 +305,25 @@ def contains_doctest(text): return bool(m) -@_api.deprecated("3.5", alternative="doctest.script_from_examples") -def unescape_doctest(text): - """ - Extract code from a piece of text, which contains either Python code - or doctests. - """ - if not contains_doctest(text): - return text - code = "" - for line in text.split("\n"): - m = re.match(r'^\s*(>>>|\.\.\.) (.*)$', line) - if m: - code += m.group(2) + "\n" - elif line.strip(): - code += "# " + line.strip() + "\n" - else: - code += "\n" - return code - - -@_api.deprecated("3.5") -def split_code_at_show(text): +def _split_code_at_show(text, function_name): """Split code at plt.show().""" - return _split_code_at_show(text)[1] - -def _split_code_at_show(text): - """Split code at plt.show().""" - parts = [] is_doctest = contains_doctest(text) - part = [] - for line in text.split("\n"): - if (not is_doctest and line.strip() == 'plt.show()') or \ - (is_doctest and line.strip() == '>>> plt.show()'): - part.append(line) + if function_name is None: + parts = [] + part = [] + for line in text.split("\n"): + if ((not is_doctest and line.startswith('plt.show(')) or + (is_doctest and line.strip() == '>>> plt.show()')): + part.append(line) + parts.append("\n".join(part)) + part = [] + else: + part.append(line) + if "\n".join(part).strip(): parts.append("\n".join(part)) - part = [] - else: - part.append(line) - if "\n".join(part).strip(): - parts.append("\n".join(part)) + else: + parts = [text] return is_doctest, parts @@ -363,16 +336,16 @@ def _split_code_at_show(text): .. only:: html - {% if source_link or (html_show_formats and not multi_image) %} + {% if src_name or (html_show_formats and not multi_image) %} ( - {%- if source_link -%} - `Source code <{{ source_link }}>`__ + {%- if src_name -%} + :download:`Source code <{{ build_dir }}/{{ src_name }}>` {%- endif -%} {%- if html_show_formats and not multi_image -%} {%- for img in images -%} {%- for fmt in img.formats -%} - {%- if source_link or not loop.first -%}, {% endif -%} - `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ + {%- if src_name or not loop.first -%}, {% endif -%} + :download:`{{ fmt }} <{{ build_dir }}/{{ img.basename }}.{{ fmt }}>` {%- endfor -%} {%- endfor -%} {%- endif -%} @@ -389,7 +362,7 @@ def _split_code_at_show(text): ( {%- for fmt in img.formats -%} {%- if not loop.first -%}, {% endif -%} - `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ + :download:`{{ fmt }} <{{ build_dir }}/{{ img.basename }}.{{ fmt }}>` {%- endfor -%} ) {%- endif -%} @@ -463,15 +436,6 @@ class PlotError(RuntimeError): pass -@_api.deprecated("3.5") -def run_code(code, code_path, ns=None, function_name=None): - """ - Import a Python module from a path, and run the function given by - name, if function_name is not None. - """ - _run_code(unescape_doctest(code), code_path, ns, function_name) - - def _run_code(code, code_path, ns=None, function_name=None): """ Import a Python module from a path, and run the function given by @@ -486,13 +450,13 @@ def _run_code(code, code_path, ns=None, function_name=None): try: os.chdir(setup.config.plot_working_directory) except OSError as err: - raise OSError(str(err) + '\n`plot_working_directory` option in' - 'Sphinx configuration file must be a valid ' - 'directory path') from err + raise OSError(f'{err}\n`plot_working_directory` option in ' + f'Sphinx configuration file must be a valid ' + f'directory path') from err except TypeError as err: - raise TypeError(str(err) + '\n`plot_working_directory` option in ' - 'Sphinx configuration file must be a string or ' - 'None') from err + raise TypeError(f'{err}\n`plot_working_directory` option in ' + f'Sphinx configuration file must be a string or ' + f'None') from err elif code_path is not None: dirname = os.path.abspath(os.path.dirname(code_path)) os.chdir(dirname) @@ -560,14 +524,15 @@ def render_figures(code, code_path, output_dir, output_base, context, Save the images under *output_dir* with file names derived from *output_base* """ + if function_name is not None: + output_base = f'{output_base}_{function_name}' formats = get_plot_formats(config) # Try to determine if all images already exist - is_doctest, code_pieces = _split_code_at_show(code) + is_doctest, code_pieces = _split_code_at_show(code, function_name) # Look for single-figure output files first - all_exists = True img = ImageFile(output_base, output_dir) for format, dpi in formats: if context or out_of_date(code_path, img.filename(format), @@ -575,13 +540,14 @@ def render_figures(code, code_path, output_dir, output_base, context, all_exists = False break img.formats.append(format) + else: + all_exists = True if all_exists: return [(code, [img])] # Then look for multi-figure output files results = [] - all_exists = True for i, code_piece in enumerate(code_pieces): images = [] for j in itertools.count(): @@ -605,6 +571,8 @@ def render_figures(code, code_path, output_dir, output_base, context, if not all_exists: break results.append((code_piece, images)) + else: + all_exists = True if all_exists: return results @@ -666,6 +634,7 @@ def run(arguments, content, options, state_machine, state, lineno): default_fmt = formats[0][0] options.setdefault('include-source', config.plot_include_source) + options.setdefault('show-source-link', config.plot_html_show_source_link) if 'class' in options: # classes are parsed into a list of string, and output by simply # printing the list, abusing the fact that RST guarantees to strip @@ -749,21 +718,13 @@ def run(arguments, content, options, state_machine, state, lineno): build_dir = os.path.normpath(build_dir) os.makedirs(build_dir, exist_ok=True) - # output_dir: final location in the builder's directory - dest_dir = os.path.abspath(os.path.join(setup.app.builder.outdir, - source_rel_dir)) - os.makedirs(dest_dir, exist_ok=True) - # how to link to files from the RST file - dest_dir_link = os.path.join(relpath(setup.confdir, rst_dir), - source_rel_dir).replace(os.path.sep, '/') try: build_dir_link = relpath(build_dir, rst_dir).replace(os.path.sep, '/') except ValueError: # on Windows, relpath raises ValueError when path and start are on # different mounts/drives build_dir_link = build_dir - source_link = dest_dir_link + '/' + output_base + source_ext # get list of included rst files so that the output is updated when any # plots in the included files change. These attributes are modified by the @@ -784,15 +745,23 @@ def run(arguments, content, options, state_machine, state, lineno): except ValueError: pass + # save script (if necessary) + if options['show-source-link']: + Path(build_dir, output_base + source_ext).write_text( + doctest.script_from_examples(code) + if source_file_name == rst_file and is_doctest + else code, + encoding='utf-8') + # make figures try: - results = render_figures(code, - source_file_name, - build_dir, - output_base, - keep_context, - function_name, - config, + results = render_figures(code=code, + code_path=source_file_name, + output_dir=build_dir, + output_base=output_base, + context=keep_context, + function_name=function_name, + config=config, context_reset=context_opt == 'reset', close_figs=context_opt == 'close-figs', code_includes=source_file_includes) @@ -830,18 +799,17 @@ def run(arguments, content, options, state_machine, state, lineno): ':%s: %s' % (key, val) for key, val in options.items() if key in ('alt', 'height', 'width', 'scale', 'align', 'class')] - # Not-None src_link signals the need for a source link in the generated - # html - if j == 0 and config.plot_html_show_source_link: - src_link = source_link + # Not-None src_name signals the need for a source download in the + # generated html + if j == 0 and options['show-source-link']: + src_name = output_base + source_ext else: - src_link = None + src_name = None result = jinja2.Template(config.plot_template or TEMPLATE).render( default_fmt=default_fmt, - dest_dir=dest_dir_link, build_dir=build_dir_link, - source_link=src_link, + src_name=src_name, multi_image=len(images) > 1, options=opts, images=images, @@ -855,22 +823,4 @@ def run(arguments, content, options, state_machine, state, lineno): if total_lines: state_machine.insert_input(total_lines, source=source_file_name) - # copy image files to builder's output directory, if necessary - Path(dest_dir).mkdir(parents=True, exist_ok=True) - - for code_piece, images in results: - for img in images: - for fn in img.filenames(): - destimg = os.path.join(dest_dir, os.path.basename(fn)) - if fn != destimg: - shutil.copyfile(fn, destimg) - - # copy script (if necessary) - if config.plot_html_show_source_link: - Path(dest_dir, output_base + source_ext).write_text( - doctest.script_from_examples(code) - if source_file_name == rst_file and is_doctest - else code, - encoding='utf-8') - return errors diff --git a/lib/matplotlib/spines.py b/lib/matplotlib/spines.py index deb0a65d21f6..674ae3e97067 100644 --- a/lib/matplotlib/spines.py +++ b/lib/matplotlib/spines.py @@ -3,8 +3,8 @@ import numpy as np -import matplotlib -from matplotlib import _api, docstring, rcParams +import matplotlib as mpl +from matplotlib import _api, _docstring from matplotlib.artist import allow_rasterization import matplotlib.transforms as mtransforms import matplotlib.patches as mpatches @@ -23,15 +23,16 @@ class Spine(mpatches.Patch): Spines are subclasses of `.Patch`, and inherit much of their behavior. - Spines draw a line, a circle, or an arc depending if + Spines draw a line, a circle, or an arc depending on if `~.Spine.set_patch_line`, `~.Spine.set_patch_circle`, or `~.Spine.set_patch_arc` has been called. Line-like is the default. + For examples see :ref:`spines_examples`. """ def __str__(self): return "Spine" - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, axes, spine_type, path, **kwargs): """ Parameters @@ -55,8 +56,8 @@ def __init__(self, axes, spine_type, path, **kwargs): self.set_figure(self.axes.figure) self.spine_type = spine_type self.set_facecolor('none') - self.set_edgecolor(rcParams['axes.edgecolor']) - self.set_linewidth(rcParams['axes.linewidth']) + self.set_edgecolor(mpl.rcParams['axes.edgecolor']) + self.set_linewidth(mpl.rcParams['axes.linewidth']) self.set_capstyle('projecting') self.axis = None @@ -69,7 +70,7 @@ def __init__(self, axes, spine_type, path, **kwargs): # non-rectangular axes is currently implemented, and this lets # them pass through the spines machinery without errors.) self._position = None - _api.check_isinstance(matplotlib.path.Path, path=path) + _api.check_isinstance(mpath.Path, path=path) self._path = path # To support drawing both linear and circular spines, this @@ -151,15 +152,16 @@ def get_window_extent(self, renderer=None): # make sure the location is updated so that transforms etc are correct: self._adjust_location() bb = super().get_window_extent(renderer=renderer) - if self.axis is None: + if self.axis is None or not self.axis.get_visible(): return bb bboxes = [bb] - tickstocheck = [self.axis.majorTicks[0]] - if len(self.axis.minorTicks) > 1: - # only pad for minor ticks if there are more than one - # of them. There is always one... - tickstocheck.append(self.axis.minorTicks[1]) - for tick in tickstocheck: + drawn_ticks = self.axis._update_ticks() + + major_tick = next(iter({*drawn_ticks} & {*self.axis.majorTicks}), None) + minor_tick = next(iter({*drawn_ticks} & {*self.axis.minorTicks}), None) + for tick in [major_tick, minor_tick]: + if tick is None: + continue bb0 = bb.frozen() tickl = tick._size tickdir = tick._tickdir @@ -222,10 +224,6 @@ def clear(self): if self.axis is not None: self.axis.clear() - @_api.deprecated("3.4", alternative="`.Spine.clear`") - def cla(self): - self.clear() - def _adjust_location(self): """Automatically set spine bounds to the view interval.""" @@ -305,8 +303,12 @@ def set_position(self, position): Additionally, shorthand notations define a special positions: - * 'center' -> ('axes', 0.5) - * 'zero' -> ('data', 0.0) + * 'center' -> ``('axes', 0.5)`` + * 'zero' -> ``('data', 0.0)`` + + Examples + -------- + :doc:`/gallery/spines/spine_placement_demo` """ if position in ('center', 'zero'): # special positions pass @@ -435,7 +437,7 @@ def linear_spine(cls, axes, spine_type, **kwargs): else: raise ValueError('unable to make path for spine "%s"' % spine_type) result = cls(axes, spine_type, path, **kwargs) - result.set_visible(rcParams['axes.spines.{0}'.format(spine_type)]) + result.set_visible(mpl.rcParams['axes.spines.{0}'.format(spine_type)]) return result @@ -515,7 +517,7 @@ class Spines(MutableMapping): The container of all `.Spine`\s in an Axes. The interface is dict-like mapping names (e.g. 'left') to `.Spine` objects. - Additionally it implements some pandas.Series-like features like accessing + Additionally, it implements some pandas.Series-like features like accessing elements by attribute:: spines['top'].set_visible(False) diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index c580043eebbc..c97a21e029f9 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -6,6 +6,8 @@ (https://stackoverflow.com/users/66549/doug) """ +import itertools + import numpy as np from matplotlib import _api @@ -70,7 +72,9 @@ def stackplot(axes, x, *args, labels = iter(labels) if colors is not None: - axes.set_prop_cycle(color=colors) + colors = itertools.cycle(colors) + else: + colors = (axes._get_lines.get_next_color() for _ in y) # Assume data passed has not been 'stacked', so stack it here. # We'll need a float buffer for the upcoming calculations. @@ -108,17 +112,16 @@ def stackplot(axes, x, *args, stack += first_line # Color between x = 0 and the first array. - color = axes._get_lines.get_next_color() coll = axes.fill_between(x, first_line, stack[0, :], - facecolor=color, label=next(labels, None), + facecolor=next(colors), label=next(labels, None), **kwargs) coll.sticky_edges.y[:] = [0] r = [coll] # Color between array i-1 and array i for i in range(len(y) - 1): - color = axes._get_lines.get_next_color() r.append(axes.fill_between(x, stack[i, :], stack[i + 1, :], - facecolor=color, label=next(labels, None), + facecolor=next(colors), + label=next(labels, None), **kwargs)) return r diff --git a/lib/matplotlib/streamplot.py b/lib/matplotlib/streamplot.py index 6d64c1c6d540..293fd6791213 100644 --- a/lib/matplotlib/streamplot.py +++ b/lib/matplotlib/streamplot.py @@ -5,12 +5,11 @@ import numpy as np -import matplotlib -from matplotlib import _api, cm +import matplotlib as mpl +from matplotlib import _api, cm, patches import matplotlib.colors as mcolors import matplotlib.collections as mcollections import matplotlib.lines as mlines -import matplotlib.patches as patches __all__ = ['streamplot'] @@ -19,7 +18,8 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, cmap=None, norm=None, arrowsize=1, arrowstyle='-|>', minlength=0.1, transform=None, zorder=None, start_points=None, - maxlength=4.0, integration_direction='both'): + maxlength=4.0, integration_direction='both', + broken_streamlines=True): """ Draw streamlines of a vector flow. @@ -39,19 +39,17 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, For different densities in each direction, use a tuple (density_x, density_y). linewidth : float or 2D array - The width of the stream lines. With a 2D array the line width can be + The width of the streamlines. With a 2D array the line width can be varied across the grid. The array must have the same shape as *u* and *v*. color : color or 2D array The streamline color. If given an array, its values are converted to colors using *cmap* and *norm*. The array must have the same shape as *u* and *v*. - cmap : `~matplotlib.colors.Colormap` - Colormap used to plot streamlines and arrows. This is only used if - *color* is an array. - norm : `~matplotlib.colors.Normalize` - Normalize object used to scale luminance data to 0, 1. If ``None``, - stretch (min, max) to (0, 1). This is only used if *color* is an array. + cmap, norm + Data normalization and colormapping parameters for *color*; only used + if *color* is an array of floats. See `~.Axes.imshow` for a detailed + description. arrowsize : float Scaling factor for the arrow size. arrowstyle : str @@ -62,8 +60,8 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, start_points : Nx2 array Coordinates of starting points for the streamlines in data coordinates (the same coordinates as the *x* and *y* arrays). - zorder : int - The zorder of the stream lines and arrows. + zorder : float + The zorder of the streamlines and arrows. Artists with lower zorder values are drawn first. maxlength : float Maximum length of streamline in axes coordinates. @@ -71,6 +69,10 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, Integrate the streamline in forward, backward or both directions. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER + broken_streamlines : boolean, default: True + If False, forces streamlines to continue until they + leave the plot domain. If True, they may be terminated if they + come too close to another streamline. Returns ------- @@ -80,7 +82,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, - ``lines``: `.LineCollection` of streamlines - ``arrows``: `.PatchCollection` containing `.FancyArrowPatch` - objects representing the arrows half-way along stream lines. + objects representing the arrows half-way along streamlines. This container will probably change in the future to allow changes to the colormap, alpha, etc. for both lines and arrows, but these @@ -101,7 +103,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, color = axes._get_lines.get_next_color() if linewidth is None: - linewidth = matplotlib.rcParams['lines.linewidth'] + linewidth = mpl.rcParams['lines.linewidth'] line_kw = {} arrow_kw = dict(arrowstyle=arrowstyle, mutation_scale=10 * arrowsize) @@ -116,8 +118,8 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, if use_multicolor_lines: if color.shape != grid.shape: raise ValueError("If 'color' is given, it must match the shape of " - "'Grid(x, y)'") - line_colors = [] + "the (x, y) grid") + line_colors = [[]] # Empty entry allows concatenation of zero arrays. color = np.ma.masked_invalid(color) else: line_kw['color'] = color @@ -126,7 +128,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, if isinstance(linewidth, np.ndarray): if linewidth.shape != grid.shape: raise ValueError("If 'linewidth' is given, it must match the " - "shape of 'Grid(x, y)'") + "shape of the (x, y) grid") line_kw['linewidth'] = [] else: line_kw['linewidth'] = linewidth @@ -137,7 +139,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, # Sanity checks. if u.shape != grid.shape or v.shape != grid.shape: - raise ValueError("'u' and 'v' must match the shape of 'Grid(x, y)'") + raise ValueError("'u' and 'v' must match the shape of the (x, y) grid") u = np.ma.masked_invalid(u) v = np.ma.masked_invalid(v) @@ -150,7 +152,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, for xm, ym in _gen_starting_points(mask.shape): if mask[ym, xm] == 0: xg, yg = dmap.mask2grid(xm, ym) - t = integrate(xg, yg) + t = integrate(xg, yg, broken_streamlines) if t is not None: trajectories.append(t) else: @@ -178,14 +180,14 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, xg = np.clip(xg, 0, grid.nx - 1) yg = np.clip(yg, 0, grid.ny - 1) - t = integrate(xg, yg) + t = integrate(xg, yg, broken_streamlines) if t is not None: trajectories.append(t) if use_multicolor_lines: if norm is None: norm = mcolors.Normalize(color.min(), color.max()) - cmap = cm.get_cmap(cmap) + cmap = cm._ensure_cmap(cmap) streamlines = [] arrows = [] @@ -199,7 +201,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, points = np.transpose([tx, ty]).reshape(-1, 1, 2) streamlines.extend(np.hstack([points[:-1], points[1:]])) - # Add arrows half way along each trajectory. + # Add arrows halfway along each trajectory. s = np.cumsum(np.hypot(np.diff(tx), np.diff(ty))) n = np.searchsorted(s, s[-1] / 2.) arrow_tail = (tx[n], ty[n]) @@ -229,7 +231,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, lc.set_norm(norm) axes.add_collection(lc) - ac = matplotlib.collections.PatchCollection(arrows) + ac = mcollections.PatchCollection(arrows) # Adding the collection itself is broken; see #2341. for p in arrows: axes.add_patch(p) @@ -283,8 +285,7 @@ def __init__(self, grid, mask): def grid2mask(self, xi, yi): """Return nearest space in mask-coords from given grid-coords.""" - return (int(xi * self.x_grid2mask + 0.5), - int(yi * self.y_grid2mask + 0.5)) + return round(xi * self.x_grid2mask), round(yi * self.y_grid2mask) def mask2grid(self, xm, ym): return xm * self.x_mask2grid, ym * self.y_mask2grid @@ -295,19 +296,19 @@ def data2grid(self, xd, yd): def grid2data(self, xg, yg): return xg / self.x_data2grid, yg / self.y_data2grid - def start_trajectory(self, xg, yg): + def start_trajectory(self, xg, yg, broken_streamlines=True): xm, ym = self.grid2mask(xg, yg) - self.mask._start_trajectory(xm, ym) + self.mask._start_trajectory(xm, ym, broken_streamlines) def reset_start_point(self, xg, yg): xm, ym = self.grid2mask(xg, yg) self.mask._current_xy = (xm, ym) - def update_trajectory(self, xg, yg): + def update_trajectory(self, xg, yg, broken_streamlines=True): if not self.grid.within_grid(xg, yg): raise InvalidIndexError xm, ym = self.grid2mask(xg, yg) - self.mask._update_trajectory(xm, ym) + self.mask._update_trajectory(xm, ym, broken_streamlines) def undo_trajectory(self): self.mask._undo_trajectory() @@ -317,21 +318,22 @@ class Grid: """Grid of data.""" def __init__(self, x, y): - if x.ndim == 1: + if np.ndim(x) == 1: pass - elif x.ndim == 2: - x_row = x[0, :] + elif np.ndim(x) == 2: + x_row = x[0] if not np.allclose(x_row, x): raise ValueError("The rows of 'x' must be equal") x = x_row else: raise ValueError("'x' can have at maximum 2 dimensions") - if y.ndim == 1: + if np.ndim(y) == 1: pass - elif y.ndim == 2: - y_col = y[:, 0] - if not np.allclose(y_col, y.T): + elif np.ndim(y) == 2: + yt = np.transpose(y) # Also works for nested lists. + y_col = yt[0] + if not np.allclose(y_col, yt): raise ValueError("The columns of 'y' must be equal") y = y_col else: @@ -396,17 +398,17 @@ def __init__(self, density): def __getitem__(self, args): return self._mask[args] - def _start_trajectory(self, xm, ym): + def _start_trajectory(self, xm, ym, broken_streamlines=True): """Start recording streamline trajectory""" self._traj = [] - self._update_trajectory(xm, ym) + self._update_trajectory(xm, ym, broken_streamlines) def _undo_trajectory(self): """Remove current trajectory from mask""" for t in self._traj: self._mask[t] = 0 - def _update_trajectory(self, xm, ym): + def _update_trajectory(self, xm, ym, broken_streamlines=True): """ Update current trajectory position in mask. @@ -418,7 +420,10 @@ def _update_trajectory(self, xm, ym): self._mask[ym, xm] = 1 self._current_xy = (xm, ym) else: - raise InvalidIndexError + if broken_streamlines: + raise InvalidIndexError + else: + pass class InvalidIndexError(Exception): @@ -457,9 +462,9 @@ def backward_time(xi, yi): dxi, dyi = forward_time(xi, yi) return -dxi, -dyi - def integrate(x0, y0): + def integrate(x0, y0, broken_streamlines=True): """ - Return (N, 2) grid-coordinates of trajectory based on starting point. + Return x, y grid-coordinates of trajectory based on starting point. Integrate both forward and backward in time from starting point in grid coordinates. @@ -472,17 +477,19 @@ def integrate(x0, y0): stotal, xy_traj = 0., [] try: - dmap.start_trajectory(x0, y0) + dmap.start_trajectory(x0, y0, broken_streamlines) except InvalidIndexError: return None if integration_direction in ['both', 'backward']: - s, xyt = _integrate_rk12(x0, y0, dmap, backward_time, maxlength) + s, xyt = _integrate_rk12(x0, y0, dmap, backward_time, maxlength, + broken_streamlines) stotal += s xy_traj += xyt[::-1] if integration_direction in ['both', 'forward']: dmap.reset_start_point(x0, y0) - s, xyt = _integrate_rk12(x0, y0, dmap, forward_time, maxlength) + s, xyt = _integrate_rk12(x0, y0, dmap, forward_time, maxlength, + broken_streamlines) stotal += s xy_traj += xyt[1:] @@ -495,20 +502,11 @@ def integrate(x0, y0): return integrate -@_api.deprecated("3.5") -def get_integrator(u, v, dmap, minlength, maxlength, integration_direction): - xy_traj = _get_integrator( - u, v, dmap, minlength, maxlength, integration_direction) - return (None if xy_traj is None - else ([], []) if not len(xy_traj) - else [*zip(*xy_traj)]) - - class OutOfBounds(IndexError): pass -def _integrate_rk12(x0, y0, dmap, f, maxlength): +def _integrate_rk12(x0, y0, dmap, f, maxlength, broken_streamlines=True): """ 2nd-order Runge-Kutta algorithm with adaptive step size. @@ -588,7 +586,7 @@ def _integrate_rk12(x0, y0, dmap, f, maxlength): xi += dx2 yi += dy2 try: - dmap.update_trajectory(xi, yi) + dmap.update_trajectory(xi, yi, broken_streamlines) except InvalidIndexError: break if stotal + ds > maxlength: diff --git a/lib/matplotlib/style/__init__.py b/lib/matplotlib/style/__init__.py index 42d050d22cd0..488c6d6ae1ec 100644 --- a/lib/matplotlib/style/__init__.py +++ b/lib/matplotlib/style/__init__.py @@ -1 +1,4 @@ -from .core import use, context, available, library, reload_library +from .core import available, context, library, reload_library, use + + +__all__ = ["available", "context", "library", "reload_library", "use"] diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py index 85dcc8bc41fe..6a97d3e3ec77 100644 --- a/lib/matplotlib/style/core.py +++ b/lib/matplotlib/style/core.py @@ -15,23 +15,24 @@ import logging import os from pathlib import Path -import re +import sys import warnings +if sys.version_info >= (3, 10): + import importlib.resources as importlib_resources +else: + # Even though Py3.9 has importlib.resources, it doesn't properly handle + # modules added in sys.path. + import importlib_resources + import matplotlib as mpl -from matplotlib import _api, docstring, rc_params_from_file, rcParamsDefault +from matplotlib import _api, _docstring, _rc_params_in_file, rcParamsDefault _log = logging.getLogger(__name__) __all__ = ['use', 'context', 'available', 'library', 'reload_library'] -@_api.caching_module_getattr # module-level deprecations -class __getattr__: - STYLE_FILE_PATTERN = _api.deprecated("3.5", obj_type="")(property( - lambda self: re.compile(r'([\S]+).%s$' % STYLE_EXTENSION))) - - BASE_LIBRARY_PATH = os.path.join(mpl.get_data_path(), 'stylelib') # Users may want multiple library paths, so store a list of paths. USER_LIBRARY_PATHS = [os.path.join(mpl.get_configdir(), 'stylelib')] @@ -40,29 +41,38 @@ class __getattr__: STYLE_BLACKLIST = { 'interactive', 'backend', 'webagg.port', 'webagg.address', 'webagg.port_retries', 'webagg.open_in_browser', 'backend_fallback', - 'toolbar', 'timezone', 'datapath', 'figure.max_open_warning', + 'toolbar', 'timezone', 'figure.max_open_warning', 'figure.raise_window', 'savefig.directory', 'tk.window_focus', 'docstring.hardcopy', 'date.epoch'} - - -def _remove_blacklisted_style_params(d, warn=True): - o = {} - for key in d: # prevent triggering RcParams.__getitem__('backend') - if key in STYLE_BLACKLIST: - if warn: - _api.warn_external( - f"Style includes a parameter, {key!r}, that is not " - "related to style. Ignoring this parameter.") - else: - o[key] = d[key] - return o - - -def _apply_style(d, warn=True): - mpl.rcParams.update(_remove_blacklisted_style_params(d, warn=warn)) - - -@docstring.Substitution( +_DEPRECATED_SEABORN_STYLES = { + s: s.replace("seaborn", "seaborn-v0_8") + for s in [ + "seaborn", + "seaborn-bright", + "seaborn-colorblind", + "seaborn-dark", + "seaborn-darkgrid", + "seaborn-dark-palette", + "seaborn-deep", + "seaborn-muted", + "seaborn-notebook", + "seaborn-paper", + "seaborn-pastel", + "seaborn-poster", + "seaborn-talk", + "seaborn-ticks", + "seaborn-white", + "seaborn-whitegrid", + ] +} +_DEPRECATED_SEABORN_MSG = ( + "The seaborn styles shipped by Matplotlib are deprecated since %(since)s, " + "as they no longer correspond to the styles shipped by seaborn. However, " + "they will remain available as 'seaborn-v0_8- + @@ -15,7 +26,7 @@ L 576 432 L 576 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> @@ -24,66 +35,66 @@ L 274.909091 200.290909 L 274.909091 43.2 L 72 43.2 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + - + - + - + - + - + - + +" clip-path="url(#p2bb8a737e6)" style="fill: none; stroke: #0000ff"/> - +" style="stroke: #0000ff; stroke-width: 0.5"/> - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - +" style="stroke: #000000; stroke-width: 0.5"/> - - - - - - - - - + + + + + + + + + +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - + - + - + - + @@ -202,388 +213,399 @@ L 0 4 - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - - + + - + - + - + - - +" transform="scale(0.015625)"/> + - - - + + + - + - + - - + + - + - + - - + + - + - + - - - + + + - - + + - + - + - - + + - - + + - + - + - + - - + + - + - + - + - + - - +" transform="scale(0.015625)"/> + - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -594,66 +616,66 @@ L 518.4 200.290909 L 518.4 43.2 L 315.490909 43.2 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + - + - + - + - + - + - + +" clip-path="url(#p06e990243b)" style="fill: none; stroke: #0000ff; stroke-opacity: 0.4"/> - +" style="stroke: #0000ff; stroke-opacity: 0.4; stroke-width: 0.5"/> - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - +" style="stroke: #000000; stroke-opacity: 0.4; stroke-width: 0.5"/> - - - - - - - - - + + + + + + + + + +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - + - + - + - + - + - + - + - + - + - + @@ -762,409 +784,416 @@ L 518.4 43.2 - + - + - - + + - + - + - - - + + + - - + + - + - + - - + + - - +" transform="scale(0.015625)"/> + - - + + - + - + - - + + - - +" transform="scale(0.015625)"/> + - - + + - + - + - - + + - - +" transform="scale(0.015625)"/> + - - + + - + - + - - + + - - + + - + - + - + - + - + - + - + - - +" transform="scale(0.015625)"/> + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -1175,115 +1204,115 @@ L 274.909091 388.8 L 274.909091 231.709091 L 72 231.709091 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + - + - + - + - + - + - + +" clip-path="url(#p2c9259b7f3)" style="fill: none; stroke: #0000ff"/> - - + - + - + - + - + - + - + +" clip-path="url(#p2c9259b7f3)" style="fill: none; stroke: #0000ff"/> - +" style="stroke: #0000ff; stroke-width: 0.5"/> - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - +" clip-path="url(#p2c9259b7f3)" style="fill: none; stroke-dasharray: 6,6; stroke-dashoffset: 0; stroke: #0000ff"/> + + + + + + + + + +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - + - + - + - + - + @@ -1364,12 +1393,12 @@ L 274.909091 231.709091 - + - + @@ -1382,12 +1411,12 @@ L 274.909091 231.709091 - + - + @@ -1400,12 +1429,12 @@ L 274.909091 231.709091 - + - + @@ -1420,133 +1449,134 @@ L 274.909091 231.709091 - + - + - - - + + + - + - + - - + + - + - + - - + + - + - + - - + + - + - + - - + + - - - + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -1557,179 +1587,179 @@ L 518.4 388.8 L 518.4 231.709091 L 315.490909 231.709091 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + - + - + - + - + - + - + +" clip-path="url(#pc7f973f0cd)" style="fill: none; stroke: #008000"/> - - + - + - + - + - + - + - + +" clip-path="url(#pc7f973f0cd)" style="fill: none; stroke: #008000"/> - +" style="stroke: #008000; stroke-width: 2"/> - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - +" style="stroke: #008000; stroke-width: 2"/> - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - + - + - + - + - + @@ -1742,12 +1772,12 @@ L 518.4 231.709091 - + - + @@ -1760,12 +1790,12 @@ L 518.4 231.709091 - + - + @@ -1778,12 +1808,12 @@ L 518.4 231.709091 - + - + @@ -1798,568 +1828,564 @@ L 518.4 231.709091 - + - + - - - - - - - - - + + + + + + - + - + - - - - - - + + + + + + - + - + - - - + + + - + - + - - - + + + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - - +" transform="scale(0.015625)"/> + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - + + - - +" transform="scale(0.015625)"/> + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + diff --git a/lib/matplotlib/tests/baseline_images/test_axes/extent_units.png b/lib/matplotlib/tests/baseline_images/test_axes/extent_units.png new file mode 100644 index 000000000000..28bde8bf76ec Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/extent_units.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/hexbin_linear.png b/lib/matplotlib/tests/baseline_images/test_axes/hexbin_linear.png new file mode 100644 index 000000000000..824ea49b0599 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/hexbin_linear.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/inset_polar.png b/lib/matplotlib/tests/baseline_images/test_axes/inset_polar.png new file mode 100644 index 000000000000..b7b7faf198ec Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/inset_polar.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/log_scales.pdf b/lib/matplotlib/tests/baseline_images/test_axes/log_scales.pdf deleted file mode 100644 index c76b653c33fb..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/log_scales.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/log_scales.png b/lib/matplotlib/tests/baseline_images/test_axes/log_scales.png deleted file mode 100644 index e305de1d9ac7..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/log_scales.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/log_scales.svg b/lib/matplotlib/tests/baseline_images/test_axes/log_scales.svg deleted file mode 100644 index 0a29c9d0af21..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_axes/log_scales.svg +++ /dev/null @@ -1,670 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.pdf b/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.pdf new file mode 100644 index 000000000000..bd4feafd6aa0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.png b/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.png new file mode 100644 index 000000000000..2eb087944ec4 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.svg b/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.svg new file mode 100644 index 000000000000..c52aaf9de094 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_axes/markevery_linear_scales_nans.svg @@ -0,0 +1,3581 @@ + + + + + + + + 2022-03-28T18:57:12.789026 + image/svg+xml + + + Matplotlib v3.6.0.dev1706+g252085fd25, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_axes/mixed_errorbar_polar_caps.png b/lib/matplotlib/tests/baseline_images/test_axes/mixed_errorbar_polar_caps.png new file mode 100644 index 000000000000..bbe879779df4 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/mixed_errorbar_polar_caps.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pcolormesh_small.eps b/lib/matplotlib/tests/baseline_images/test_axes/pcolormesh_small.eps new file mode 100644 index 000000000000..c96cc35aea08 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_axes/pcolormesh_small.eps @@ -0,0 +1,312 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Title: pcolormesh_small.eps +%%Creator: Matplotlib v3.6.0.dev2473+ga4ddf81873.d20220618, https://matplotlib.org/ +%%CreationDate: Sat Jun 18 21:17:11 2022 +%%Orientation: portrait +%%BoundingBox: 18 180 594 612 +%%HiResBoundingBox: 18.000000 180.000000 594.000000 612.000000 +%%EndComments +%%BeginProlog +/mpldict 10 dict def +mpldict begin +/_d { bind def } bind def +/m { moveto } _d +/l { lineto } _d +/r { rlineto } _d +/c { curveto } _d +/cl { closepath } _d +/ce { closepath eofill } _d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } _d +/clipbox { + box + clip + newpath + } _d +/sc { setcachedevice } _d +end +%%EndProlog +mpldict begin +18 180 translate +576 432 0 0 clipbox +gsave +0 0 m +576 0 l +576 432 l +0 432 l +cl +1.000 setgray +fill +grestore +0.500 setlinewidth +1 setlinejoin +0 setlinecap +[] 0 setdash +0.000 setgray +gsave +131.294 345.6 72 43.2 clipbox +144.211765 43.632866 m +83.208395 129.816433 l +119.371572 148.320555 l +180.374942 62.136988 l +144.211765 43.632866 l +gsave +0.500 0.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +83.208395 129.816433 m +144.211765 216 l +180.374942 234.504122 l +119.371572 148.320555 l +83.208395 129.816433 l +gsave +0.000 0.000 0.500 setrgbcolor +fill +grestore +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +180.374942 62.136988 m +119.371572 148.320555 l +141.279737 190.467054 l +202.283106 104.283487 l +180.374942 62.136988 l +gsave +0.161 1.000 0.806 setrgbcolor +fill +grestore +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +119.371572 148.320555 m +180.374942 234.504122 l +202.283106 276.650621 l +141.279737 190.467054 l +119.371572 148.320555 l +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +202.283106 104.283487 m +141.279737 190.467054 l +141.279737 241.532946 l +202.283106 155.349379 l +202.283106 104.283487 l +gsave +0.000 0.000 0.714 setrgbcolor +fill +grestore +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +141.279737 190.467054 m +202.283106 276.650621 l +202.283106 327.716513 l +141.279737 241.532946 l +141.279737 190.467054 l +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +202.283106 155.349379 m +141.279737 241.532946 l +119.371572 283.679445 l +180.374942 197.495878 l +202.283106 155.349379 l +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +141.279737 241.532946 m +202.283106 327.716513 l +180.374942 369.863012 l +119.371572 283.679445 l +141.279737 241.532946 l +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +180.374942 197.495878 m +119.371572 283.679445 l +83.208395 302.183567 l +144.211765 216 l +180.374942 197.495878 l +stroke +grestore +gsave +131.294 345.6 72 43.2 clipbox +119.371572 283.679445 m +180.374942 369.863012 l +144.211765 388.367134 l +83.208395 302.183567 l +119.371572 283.679445 l +stroke +grestore +2.000 setlinewidth +0.000 0.000 1.000 setrgbcolor +gsave +131.294 345.6 229.553 43.2 clipbox +301.764706 43.632866 m +240.761336 129.816433 l +276.924513 148.320555 l +337.927883 62.136988 l +301.764706 43.632866 l +gsave +0.500 0.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +1.000 setgray +gsave +131.294 345.6 229.553 43.2 clipbox +240.761336 129.816433 m +301.764706 216 l +337.927883 234.504122 l +276.924513 148.320555 l +240.761336 129.816433 l +gsave +0.000 0.000 0.500 setrgbcolor +fill +grestore +stroke +grestore +0.000 0.000 1.000 setrgbcolor +gsave +131.294 345.6 229.553 43.2 clipbox +337.927883 62.136988 m +276.924513 148.320555 l +298.832678 190.467054 l +359.836047 104.283487 l +337.927883 62.136988 l +gsave +0.161 1.000 0.806 setrgbcolor +fill +grestore +stroke +grestore +1.000 setgray +gsave +131.294 345.6 229.553 43.2 clipbox +276.924513 148.320555 m +337.927883 234.504122 l +359.836047 276.650621 l +298.832678 190.467054 l +276.924513 148.320555 l +stroke +grestore +0.000 0.000 1.000 setrgbcolor +gsave +131.294 345.6 229.553 43.2 clipbox +359.836047 104.283487 m +298.832678 190.467054 l +298.832678 241.532946 l +359.836047 155.349379 l +359.836047 104.283487 l +gsave +0.000 0.000 0.714 setrgbcolor +fill +grestore +stroke +grestore +1.000 setgray +gsave +131.294 345.6 229.553 43.2 clipbox +298.832678 190.467054 m +359.836047 276.650621 l +359.836047 327.716513 l +298.832678 241.532946 l +298.832678 190.467054 l +stroke +grestore +0.000 0.000 1.000 setrgbcolor +gsave +131.294 345.6 229.553 43.2 clipbox +359.836047 155.349379 m +298.832678 241.532946 l +276.924513 283.679445 l +337.927883 197.495878 l +359.836047 155.349379 l +stroke +grestore +1.000 setgray +gsave +131.294 345.6 229.553 43.2 clipbox +298.832678 241.532946 m +359.836047 327.716513 l +337.927883 369.863012 l +276.924513 283.679445 l +298.832678 241.532946 l +stroke +grestore +0.000 0.000 1.000 setrgbcolor +gsave +131.294 345.6 229.553 43.2 clipbox +337.927883 197.495878 m +276.924513 283.679445 l +240.761336 302.183567 l +301.764706 216 l +337.927883 197.495878 l +stroke +grestore +1.000 setgray +gsave +131.294 345.6 229.553 43.2 clipbox +276.924513 283.679445 m +337.927883 369.863012 l +301.764706 388.367134 l +240.761336 302.183567 l +276.924513 283.679445 l +stroke +grestore +gsave +<< /ShadingType 4 + /ColorSpace [/DeviceRGB] + /BitsPerCoordinate 32 + /BitsPerComponent 8 + /BitsPerFlag 8 + /AntiAlias true + /Decode [ -3697.69 4613.39 -4052.37 4484.37 0 1 0 1 0 1 ] + /DataSource < +00800b99127ad4c0007f0000007e2a90007d6a604b00007f007fa9a91e7c6697312a3f53007e2a90007d6a604b00007f007f47b92a7df86e63000000007fa9a9 +1e7c6697312a3f53007f47b92a7df86e63000000008128c23c7b62ce1729ffcd007fa9a91e7c6697312a3f53008128c23c7b62ce1729ffcd00800b99127ad4c0 +007f0000007fa9a91e7c6697312a3f53007e2a90007d6a604b00007f00800b991280000096000000007fa9a91e7efc377d00001f00800b991280000096000000 +008128c23c808e0eae000000007fa9a91e7efc377d00001f008128c23c808e0eae000000007f47b92a7df86e63000000007fa9a91e7efc377d00001f007f47b9 +2a7df86e63000000007e2a90007d6a604b00007f007fa9a91e7efc377d00001f008128c23c7b62ce1729ffcd007f47b92a7df86e6300000000808e9e457d4f65 +6e0a3f60007f47b92a7df86e63000000007ff47a4d7f3bfcc500000000808e9e457d4f656e0a3f60007ff47a4d7f3bfcc50000000081d5835f7ca65c790000b6 +00808e9e457d4f656e0a3f600081d5835f7ca65c790000b6008128c23c7b62ce1729ffcd00808e9e457d4f656e0a3f60007f47b92a7df86e63000000008128c2 +3c808e0eae00000000808e9e457fe505b9000000008128c23c808e0eae0000000081d5835f81d19d1000000000808e9e457fe505b90000000081d5835f81d19d +10000000007ff47a4d7f3bfcc500000000808e9e457fe505b9000000007ff47a4d7f3bfcc5000000007f47b92a7df86e6300000000808e9e457fe505b9000000 +0081d5835f7ca65c790000b6007ff47a4d7f3bfcc50000000080e4fed67eb5307100002d007ff47a4d7f3bfcc5000000007ff47a4d80c404680000000080e4fe +d67eb5307100002d007ff47a4d80c404680000000081d5835f7e2e641d0000000080e4fed67eb5307100002d0081d5835f7e2e641d0000000081d5835f7ca65c +790000b60080e4fed67eb5307100002d007ff47a4d7f3bfcc50000000081d5835f81d19d100000000080e4fed6814ad0bc00002d0081d5835f81d19d10000000 +0081d5835f8359a4b30000b60080e4fed6814ad0bc00002d0081d5835f8359a4b30000b6007ff47a4d80c404680000000080e4fed6814ad0bc00002d007ff47a +4d80c40468000000007ff47a4d7f3bfcc50000000080e4fed6814ad0bc00002d0081d5835f7e2e641d000000007ff47a4d80c4046800000000808e9e45801afb +73000000007ff47a4d80c40468000000007f47b92a820792ca00000000808e9e45801afb73000000007f47b92a820792ca000000008128c23c7f71f27f000000 +00808e9e45801afb73000000008128c23c7f71f27f0000000081d5835f7e2e641d00000000808e9e45801afb73000000007ff47a4d80c404680000000081d583 +5f8359a4b30000b600808e9e4582b09bbf0a3f600081d5835f8359a4b30000b6008128c23c849d331529ffcd00808e9e4582b09bbf0a3f60008128c23c849d33 +1529ffcd007f47b92a820792ca00000000808e9e4582b09bbf0a3f60007f47b92a820792ca000000007ff47a4d80c4046800000000808e9e4582b09bbf0a3f60 +008128c23c7f71f27f000000007f47b92a820792ca000000007fa9a91e8103c9b000001f007f47b92a820792ca000000007e2a90008295a0e200007f007fa9a9 +1e8103c9b000001f007e2a90008295a0e200007f00800b991280000096000000007fa9a91e8103c9b000001f00800b991280000096000000008128c23c7f71f2 +7f000000007fa9a91e8103c9b000001f007f47b92a820792ca000000008128c23c849d331529ffcd007fa9a91e839969fc2a3f53008128c23c849d331529ffcd +00800b9912852b412d7f0000007fa9a91e839969fc2a3f5300800b9912852b412d7f0000007e2a90008295a0e200007f007fa9a91e839969fc2a3f53007e2a90 +008295a0e200007f007f47b92a820792ca000000007fa9a91e839969fc2a3f53 +> +>> +shfill +grestore + +end +showpage diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_ccw_true.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_ccw_true.png index 8af5f62b5526..c5236a34b9e1 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_ccw_true.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_ccw_true.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_center_radius.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_center_radius.png index f7b7108ce79b..64b2244711f9 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_center_radius.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_center_radius.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_default.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_default.png index 1c0c6c2c0577..f3935a9e159a 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_default.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_default.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_frame_grid.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_frame_grid.png index 4eea9fb7c157..4e4edbeed0ed 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_frame_grid.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_frame_grid.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_0.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_0.png index 18acc069100e..e814e061205a 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_0.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_0.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_2.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_2.png index 68fc84a4d596..e12d743fbc45 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_2.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_linewidth_2.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_no_label.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_no_label.png index d2d651e421dc..c6fd5262acce 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_no_label.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_no_label.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/pie_rotatelabels_true.png b/lib/matplotlib/tests/baseline_images/test_axes/pie_rotatelabels_true.png index 91664207dee7..d5875752c3cd 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/pie_rotatelabels_true.png and b/lib/matplotlib/tests/baseline_images/test_axes/pie_rotatelabels_true.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/test_centered_bar_label_nonlinear.svg b/lib/matplotlib/tests/baseline_images/test_axes/test_centered_bar_label_nonlinear.svg new file mode 100644 index 000000000000..cea1050932a6 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_axes/test_centered_bar_label_nonlinear.svg @@ -0,0 +1,166 @@ + + + + + + + + 2022-09-10T15:01:10.033044 + image/svg+xml + + + Matplotlib v3.5.0.dev5765+gcb3beb2f91.d20220910, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/multi_font_type3.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/multi_font_type3.pdf new file mode 100644 index 000000000000..a148a7d571b1 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/multi_font_type3.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/multi_font_type42.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/multi_font_type42.pdf new file mode 100644 index 000000000000..e33f8d803b12 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/multi_font_type42.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/colorbar_shift.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/colorbar_shift.eps new file mode 100644 index 000000000000..b88e23a33c42 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_ps/colorbar_shift.eps @@ -0,0 +1,897 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Title: colorbar_shift.eps +%%Creator: Matplotlib v3.7.0.dev1597+g613b343238.d20230210, https://matplotlib.org/ +%%CreationDate: Fri Feb 10 16:16:04 2023 +%%Orientation: portrait +%%BoundingBox: 110 245 502 547 +%%HiResBoundingBox: 110.097762 245.509625 501.902238 546.490375 +%%EndComments +%%BeginProlog +/mpldict 11 dict def +mpldict begin +/_d { bind def } bind def +/m { moveto } _d +/l { lineto } _d +/r { rlineto } _d +/c { curveto } _d +/cl { closepath } _d +/ce { closepath eofill } _d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } _d +/clipbox { + box + clip + newpath + } _d +/sc { setcachedevice } _d +%!PS-Adobe-3.0 Resource-Font +%%Creator: Converted from TrueType to Type 3 by Matplotlib. +10 dict begin +/FontName /DejaVuSans def +/PaintType 0 def +/FontMatrix [0.00048828125 0 0 0.00048828125 0 0] def +/FontBBox [-2090 -948 3673 2524] def +/FontType 3 def +/Encoding [/period /zero /one /two /minus /four /five /six /eight /nine] def +/CharStrings 11 dict dup begin +/.notdef 0 def +/period{651 0 219 0 430 254 sc +219 254 m +430 254 l +430 0 l +219 0 l +219 254 l + +ce} _d +/zero{1303 0 135 -29 1167 1520 sc +651 1360 m +547 1360 469 1309 416 1206 c +364 1104 338 950 338 745 c +338 540 364 387 416 284 c +469 182 547 131 651 131 c +756 131 834 182 886 284 c +939 387 965 540 965 745 c +965 950 939 1104 886 1206 c +834 1309 756 1360 651 1360 c + +651 1520 m +818 1520 946 1454 1034 1321 c +1123 1189 1167 997 1167 745 c +1167 494 1123 302 1034 169 c +946 37 818 -29 651 -29 c +484 -29 356 37 267 169 c +179 302 135 494 135 745 c +135 997 179 1189 267 1321 c +356 1454 484 1520 651 1520 c + +ce} _d +/one{1303 0 225 0 1114 1493 sc +254 170 m +584 170 l +584 1309 l +225 1237 l +225 1421 l +582 1493 l +784 1493 l +784 170 l +1114 170 l +1114 0 l +254 0 l +254 170 l + +ce} _d +/two{1303 0 150 0 1098 1520 sc +393 170 m +1098 170 l +1098 0 l +150 0 l +150 170 l +227 249 331 356 463 489 c +596 623 679 709 713 748 c +778 821 823 882 848 932 c +874 983 887 1032 887 1081 c +887 1160 859 1225 803 1275 c +748 1325 675 1350 586 1350 c +523 1350 456 1339 385 1317 c +315 1295 240 1262 160 1217 c +160 1421 l +241 1454 317 1478 388 1495 c +459 1512 523 1520 582 1520 c +737 1520 860 1481 952 1404 c +1044 1327 1090 1223 1090 1094 c +1090 1033 1078 974 1055 919 c +1032 864 991 800 930 725 c +913 706 860 650 771 557 c +682 465 556 336 393 170 c + +ce} _d +/minus{1716 0 217 557 1499 727 sc +217 727 m +1499 727 l +1499 557 l +217 557 l +217 727 l + +ce} _d +/four{1303 0 100 0 1188 1493 sc +774 1317 m +264 520 l +774 520 l +774 1317 l + +721 1493 m +975 1493 l +975 520 l +1188 520 l +1188 352 l +975 352 l +975 0 l +774 0 l +774 352 l +100 352 l +100 547 l +721 1493 l + +ce} _d +/five{1303 0 158 -29 1124 1493 sc +221 1493 m +1014 1493 l +1014 1323 l +406 1323 l +406 957 l +435 967 465 974 494 979 c +523 984 553 987 582 987 c +749 987 881 941 978 850 c +1075 759 1124 635 1124 479 c +1124 318 1074 193 974 104 c +874 15 733 -29 551 -29 c +488 -29 424 -24 359 -13 c +294 -2 227 14 158 35 c +158 238 l +218 205 280 181 344 165 c +408 149 476 141 547 141 c +662 141 754 171 821 232 c +888 293 922 375 922 479 c +922 583 888 665 821 726 c +754 787 662 817 547 817 c +493 817 439 811 385 799 c +332 787 277 768 221 743 c +221 1493 l + +ce} _d +/six{1303 0 143 -29 1174 1520 sc +676 827 m +585 827 513 796 460 734 c +407 672 381 587 381 479 c +381 372 407 287 460 224 c +513 162 585 131 676 131 c +767 131 838 162 891 224 c +944 287 971 372 971 479 c +971 587 944 672 891 734 c +838 796 767 827 676 827 c + +1077 1460 m +1077 1276 l +1026 1300 975 1318 923 1331 c +872 1344 821 1350 770 1350 c +637 1350 535 1305 464 1215 c +394 1125 354 989 344 807 c +383 865 433 909 492 940 c +551 971 617 987 688 987 c +838 987 956 941 1043 850 c +1130 759 1174 636 1174 479 c +1174 326 1129 203 1038 110 c +947 17 827 -29 676 -29 c +503 -29 371 37 280 169 c +189 302 143 494 143 745 c +143 981 199 1169 311 1309 c +423 1450 573 1520 762 1520 c +813 1520 864 1515 915 1505 c +967 1495 1021 1480 1077 1460 c + +ce} _d +/eight{1303 0 139 -29 1163 1520 sc +651 709 m +555 709 479 683 424 632 c +369 581 342 510 342 420 c +342 330 369 259 424 208 c +479 157 555 131 651 131 c +747 131 823 157 878 208 c +933 260 961 331 961 420 c +961 510 933 581 878 632 c +823 683 748 709 651 709 c + +449 795 m +362 816 295 857 246 916 c +198 975 174 1048 174 1133 c +174 1252 216 1347 301 1416 c +386 1485 503 1520 651 1520 c +800 1520 916 1485 1001 1416 c +1086 1347 1128 1252 1128 1133 c +1128 1048 1104 975 1055 916 c +1007 857 940 816 854 795 c +951 772 1027 728 1081 662 c +1136 596 1163 515 1163 420 c +1163 275 1119 164 1030 87 c +942 10 816 -29 651 -29 c +486 -29 360 10 271 87 c +183 164 139 275 139 420 c +139 515 166 596 221 662 c +276 728 352 772 449 795 c + +375 1114 m +375 1037 399 976 447 933 c +496 890 564 868 651 868 c +738 868 805 890 854 933 c +903 976 928 1037 928 1114 c +928 1191 903 1252 854 1295 c +805 1338 738 1360 651 1360 c +564 1360 496 1338 447 1295 c +399 1252 375 1191 375 1114 c + +ce} _d +/nine{1303 0 129 -29 1159 1520 sc +225 31 m +225 215 l +276 191 327 173 379 160 c +431 147 482 141 532 141 c +665 141 767 186 837 275 c +908 365 948 501 958 684 c +919 627 870 583 811 552 c +752 521 686 506 614 506 c +465 506 346 551 259 641 c +172 732 129 855 129 1012 c +129 1165 174 1288 265 1381 c +356 1474 476 1520 627 1520 c +800 1520 931 1454 1022 1321 c +1113 1189 1159 997 1159 745 c +1159 510 1103 322 991 181 c +880 41 730 -29 541 -29 c +490 -29 439 -24 387 -14 c +335 -4 281 11 225 31 c + +627 664 m +718 664 789 695 842 757 c +895 819 922 904 922 1012 c +922 1119 895 1204 842 1266 c +789 1329 718 1360 627 1360 c +536 1360 464 1329 411 1266 c +358 1204 332 1119 332 1012 c +332 904 358 819 411 757 c +464 695 536 664 627 664 c + +ce} _d +end readonly def + +/BuildGlyph { + exch begin + CharStrings exch + 2 copy known not {pop /.notdef} if + true 3 1 roll get exec + end +} _d + +/BuildChar { + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec +} _d + +FontName currentdict end definefont pop +end +%%EndProlog +mpldict begin +110.098 245.51 translate +391.804 300.981 0 0 clipbox +gsave +0 0 m +391.804475 0 l +391.804475 300.98075 l +0 300.98075 l +cl +1.000 setgray +fill +grestore +gsave +36.465625 23.871875 m +322.161625 23.871875 l +322.161625 289.983875 l +36.465625 289.983875 l +cl +1.000 setgray +fill +grestore +/p0_0 { +newpath +translate +0 -3 m +0.795609 -3 1.55874 -2.683901 2.12132 -2.12132 c +2.683901 -1.55874 3 -0.795609 3 0 c +3 0.795609 2.683901 1.55874 2.12132 2.12132 c +1.55874 2.683901 0.795609 3 0 3 c +-0.795609 3 -1.55874 2.683901 -2.12132 2.12132 c +-2.683901 1.55874 -3 0.795609 -3 0 c +-3 -0.795609 -2.683901 -1.55874 -2.12132 -2.12132 c +-1.55874 -2.683901 -0.795609 -3 0 -3 c +cl + +} bind def +1.000 setlinewidth +1 setlinejoin +0 setlinecap +[] 0 setdash +0.000 0.500 0.000 setrgbcolor +gsave +285.696 266.112 36.466 23.872 clipbox +49.4518 156.928 p0_0 +gsave +fill +grestore +stroke +grestore +0.000 0.000 1.000 setrgbcolor +gsave +285.696 266.112 36.466 23.872 clipbox +309.175 156.928 p0_0 +gsave +fill +grestore +stroke +grestore +0.800 setlinewidth +0.000 setgray +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +0 -3.5 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +49.4518 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +41.4987 9.27812 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /zero glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +0 -3.5 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +101.397 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +93.4434 9.27812 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /two glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +0 -3.5 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +153.341 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +145.388 9.27812 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /four glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +0 -3.5 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +205.286 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +197.333 9.27812 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /six glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +0 -3.5 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +257.231 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +249.278 9.27812 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /eight glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +0 -3.5 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +309.175 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +301.222 9.27812 translate +0 rotate +0 0 m /one glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /zero glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +-0 0 m +-3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +36.4656 60.1599 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +7.2 56.363 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /nine glyphshow +15.9033 0 m /six glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +-0 0 m +-3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +36.4656 108.544 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +7.2 104.747 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /nine glyphshow +15.9033 0 m /eight glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +-0 0 m +-3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +36.4656 156.928 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +7.2 153.131 translate +0 rotate +0 0 m /one glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /zero glyphshow +15.9033 0 m /zero glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +-0 0 m +-3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +36.4656 205.312 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +7.2 201.515 translate +0 rotate +0 0 m /one glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /zero glyphshow +15.9033 0 m /two glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +-0 0 m +-3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +36.4656 253.696 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +7.2 249.899 translate +0 rotate +0 0 m /one glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /zero glyphshow +15.9033 0 m /four glyphshow +grestore +0 setlinejoin +2 setlinecap +gsave +36.465625 23.871875 m +36.465625 289.983875 l +stroke +grestore +gsave +322.161625 23.871875 m +322.161625 289.983875 l +stroke +grestore +gsave +36.465625 23.871875 m +322.161625 23.871875 l +stroke +grestore +gsave +36.465625 289.983875 m +322.161625 289.983875 l +stroke +grestore +gsave +340.017625 23.871875 m +353.323225 23.871875 l +353.323225 289.983875 l +340.017625 289.983875 l +cl +1.000 setgray +fill +grestore +gsave +13.306 266.112 340.018 23.872 clipbox +340.017625 23.871875 m +353.323225 23.871875 l +353.323225 112.575875 l +340.017625 112.575875 l +340.017625 23.871875 l +1.000 0.000 0.000 setrgbcolor +fill +grestore +gsave +13.306 266.112 340.018 23.872 clipbox +340.017625 112.575875 m +353.323225 112.575875 l +353.323225 201.279875 l +340.017625 201.279875 l +340.017625 112.575875 l +0.000 0.500 0.000 setrgbcolor +fill +grestore +gsave +13.306 266.112 340.018 23.872 clipbox +340.017625 201.279875 m +353.323225 201.279875 l +353.323225 289.983875 l +340.017625 289.983875 l +340.017625 201.279875 l +0.000 0.000 1.000 setrgbcolor +fill +grestore +1 setlinejoin +0 setlinecap +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +353.323 23.8719 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +360.323 20.075 translate +0 rotate +0 0 m /minus glyphshow +8.37891 0 m /one glyphshow +14.7412 0 m /period glyphshow +17.9199 0 m /zero glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +353.323 112.576 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +360.323 108.779 translate +0 rotate +0 0 m /minus glyphshow +8.37891 0 m /zero glyphshow +14.7412 0 m /period glyphshow +17.9199 0 m /five glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +353.323 201.28 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +360.323 197.483 translate +0 rotate +0 0 m /zero glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /five glyphshow +grestore +gsave +/o { +gsave +newpath +translate +0.8 setlinewidth +1 setlinejoin + +0 setlinecap + +0 0 m +3.5 0 l + +gsave +0.000 setgray +fill +grestore +stroke +grestore +} bind def +353.323 289.984 o +grestore +/DejaVuSans 10.000 selectfont +gsave + +360.323 286.187 translate +0 rotate +0 0 m /one glyphshow +6.3623 0 m /period glyphshow +9.54102 0 m /zero glyphshow +grestore +0 setlinejoin +2 setlinecap +gsave +340.017625 23.871875 m +346.670425 23.871875 l +353.323225 23.871875 l +353.323225 289.983875 l +346.670425 289.983875 l +340.017625 289.983875 l +340.017625 23.871875 l +cl +stroke +grestore + +end +showpage diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/coloredhatcheszerolw.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/coloredhatcheszerolw.eps new file mode 100644 index 000000000000..c0994b3116a5 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_ps/coloredhatcheszerolw.eps @@ -0,0 +1,216 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Title: coloredhatcheszerolw.eps +%%Creator: Matplotlib v3.6.0.dev1993+g86a08ee.d20220407, https://matplotlib.org/ +%%CreationDate: Thu Apr 7 11:52:41 2022 +%%Orientation: portrait +%%BoundingBox: 18 180 594 612 +%%HiResBoundingBox: 18.000000 180.000000 594.000000 612.000000 +%%EndComments +%%BeginProlog +/mpldict 10 dict def +mpldict begin +/_d { bind def } bind def +/m { moveto } _d +/l { lineto } _d +/r { rlineto } _d +/c { curveto } _d +/cl { closepath } _d +/ce { closepath eofill } _d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } _d +/clipbox { + box + clip + newpath + } _d +/sc { setcachedevice } _d +end +%%EndProlog +mpldict begin +18 180 translate +576 432 0 0 clipbox +gsave +0 0 m +576 0 l +576 432 l +0 432 l +cl +1.000 setgray +fill +grestore +1.000 0.000 0.000 setrgbcolor +gsave +446.4 345.6 72 43.2 clipbox +72 -129.6 m +131.193332 -129.6 187.970227 -111.392702 229.826234 -78.988052 c +271.68224 -46.583402 295.2 -2.627096 295.2 43.2 c +295.2 89.027096 271.68224 132.983402 229.826234 165.388052 c +187.970227 197.792702 131.193332 216 72 216 c +12.806668 216 -43.970227 197.792702 -85.826234 165.388052 c +-127.68224 132.983402 -151.2 89.027096 -151.2 43.2 c +-151.2 -2.627096 -127.68224 -46.583402 -85.826234 -78.988052 c +-43.970227 -111.392702 12.806668 -129.6 72 -129.6 c +cl + << /PatternType 1 + /PaintType 2 + /TilingType 2 + /BBox[0 0 72 72] + /XStep 72 + /YStep 72 + + /PaintProc { + pop + 1 setlinewidth +-36 36 m +36 108 l +-24 24 m +48 96 l +-12 12 m +60 84 l +0 0 m +72 72 l +12 -12 m +84 60 l +24 -24 m +96 48 l +36 -36 m +108 36 l + + gsave + fill + grestore + stroke + } bind + >> + matrix + 0 432 translate + makepattern + /H0 exch def +gsave +1.000000 0.000000 0.000000 H0 setpattern fill grestore +grestore +0.200 setlinewidth +0 setlinejoin +0 setlinecap +[] 0 setdash +0.000 0.500 0.000 setrgbcolor +gsave +446.4 345.6 72 43.2 clipbox +295.2 129.6 m +324.796666 129.6 353.185114 138.703649 374.113117 154.905974 c +395.04112 171.108299 406.8 193.086452 406.8 216 c +406.8 238.913548 395.04112 260.891701 374.113117 277.094026 c +353.185114 293.296351 324.796666 302.4 295.2 302.4 c +265.603334 302.4 237.214886 293.296351 216.286883 277.094026 c +195.35888 260.891701 183.6 238.913548 183.6 216 c +183.6 193.086452 195.35888 171.108299 216.286883 154.905974 c +237.214886 138.703649 265.603334 129.6 295.2 129.6 c +cl + << /PatternType 1 + /PaintType 2 + /TilingType 2 + /BBox[0 0 72 72] + /XStep 72 + /YStep 72 + + /PaintProc { + pop + 1 setlinewidth +0 6 m +72 6 l +0 18 m +72 18 l +0 30 m +72 30 l +0 42 m +72 42 l +0 54 m +72 54 l +0 66 m +72 66 l +6 0 m +6 72 l +18 0 m +18 72 l +30 0 m +30 72 l +42 0 m +42 72 l +54 0 m +54 72 l +66 0 m +66 72 l + + gsave + fill + grestore + stroke + } bind + >> + matrix + 0 432 translate + makepattern + /H1 exch def +gsave +0.000000 0.500000 0.000000 H1 setpattern fill grestore +stroke +grestore +0.000 0.000 1.000 setrgbcolor +gsave +446.4 345.6 72 43.2 clipbox +518.4 250.56 m +536.158 250.56 553.191068 265.125838 565.74787 291.049559 c +578.304672 316.973279 585.36 352.138323 585.36 388.8 c +585.36 425.461677 578.304672 460.626721 565.74787 486.550441 c +553.191068 512.474162 536.158 527.04 518.4 527.04 c +500.642 527.04 483.608932 512.474162 471.05213 486.550441 c +458.495328 460.626721 451.44 425.461677 451.44 388.8 c +451.44 352.138323 458.495328 316.973279 471.05213 291.049559 c +483.608932 265.125838 500.642 250.56 518.4 250.56 c +cl + << /PatternType 1 + /PaintType 2 + /TilingType 2 + /BBox[0 0 72 72] + /XStep 72 + /YStep 72 + + /PaintProc { + pop + 1 setlinewidth +-36 36 m +36 -36 l +-24 48 m +48 -24 l +-12 60 m +60 -12 l +0 72 m +72 0 l +12 84 m +84 12 l +24 96 m +96 24 l +36 108 m +108 36 l + + gsave + fill + grestore + stroke + } bind + >> + matrix + 0 432 translate + makepattern + /H2 exch def +gsave +0.000000 0.000000 1.000000 H2 setpattern fill grestore +grestore + +end +showpage diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/multi_font_type3.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/multi_font_type3.eps new file mode 100644 index 000000000000..efc8fc9416a7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_ps/multi_font_type3.eps @@ -0,0 +1,521 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Title: multi_font_type3.eps +%%Creator: Matplotlib v3.6.0.dev2856+g4848cedd6d.d20220807, https://matplotlib.org/ +%%CreationDate: Sun Aug 7 16:45:01 2022 +%%Orientation: portrait +%%BoundingBox: 18 180 594 612 +%%HiResBoundingBox: 18.000000 180.000000 594.000000 612.000000 +%%EndComments +%%BeginProlog +/mpldict 12 dict def +mpldict begin +/_d { bind def } bind def +/m { moveto } _d +/l { lineto } _d +/r { rlineto } _d +/c { curveto } _d +/cl { closepath } _d +/ce { closepath eofill } _d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } _d +/clipbox { + box + clip + newpath + } _d +/sc { setcachedevice } _d +%!PS-Adobe-3.0 Resource-Font +%%Creator: Converted from TrueType to Type 3 by Matplotlib. +10 dict begin +/FontName /DejaVuSans def +/PaintType 0 def +/FontMatrix [0.00048828125 0 0 0.00048828125 0 0] def +/FontBBox [-2090 -948 3673 2524] def +/FontType 3 def +/Encoding [/space /exclam /b /a /e /h /i /n /r /T /t /w] def +/CharStrings 13 dict dup begin +/.notdef 0 def +/space{651 0 0 0 0 0 sc +ce} _d +/exclam{821 0 309 0 512 1493 sc +309 254 m +512 254 l +512 0 l +309 0 l +309 254 l + +309 1493 m +512 1493 l +512 838 l +492 481 l +330 481 l +309 838 l +309 1493 l + +ce} _d +/b{1300 0 186 -29 1188 1556 sc +997 559 m +997 694 969 800 913 877 c +858 954 781 993 684 993 c +587 993 510 954 454 877 c +399 800 371 694 371 559 c +371 424 399 317 454 240 c +510 163 587 125 684 125 c +781 125 858 163 913 240 c +969 317 997 424 997 559 c + +371 950 m +410 1017 458 1066 517 1098 c +576 1131 647 1147 729 1147 c +865 1147 975 1093 1060 985 c +1145 877 1188 735 1188 559 c +1188 383 1145 241 1060 133 c +975 25 865 -29 729 -29 c +647 -29 576 -13 517 19 c +458 52 410 101 371 168 c +371 0 l +186 0 l +186 1556 l +371 1556 l +371 950 l + +ce} _d +/a{1255 0 123 -29 1069 1147 sc +702 563 m +553 563 450 546 393 512 c +336 478 307 420 307 338 c +307 273 328 221 371 182 c +414 144 473 125 547 125 c +649 125 731 161 792 233 c +854 306 885 402 885 522 c +885 563 l +702 563 l + +1069 639 m +1069 0 l +885 0 l +885 170 l +843 102 791 52 728 19 c +665 -13 589 -29 498 -29 c +383 -29 292 3 224 67 c +157 132 123 218 123 326 c +123 452 165 547 249 611 c +334 675 460 707 627 707 c +885 707 l +885 725 l +885 810 857 875 801 921 c +746 968 668 991 567 991 c +503 991 441 983 380 968 c +319 953 261 930 205 899 c +205 1069 l +272 1095 338 1114 401 1127 c +464 1140 526 1147 586 1147 c +748 1147 869 1105 949 1021 c +1029 937 1069 810 1069 639 c + +ce} _d +/e{1260 0 113 -29 1151 1147 sc +1151 606 m +1151 516 l +305 516 l +313 389 351 293 419 226 c +488 160 583 127 705 127 c +776 127 844 136 910 153 c +977 170 1043 196 1108 231 c +1108 57 l +1042 29 974 8 905 -7 c +836 -22 765 -29 694 -29 c +515 -29 374 23 269 127 c +165 231 113 372 113 549 c +113 732 162 878 261 985 c +360 1093 494 1147 662 1147 c +813 1147 932 1098 1019 1001 c +1107 904 1151 773 1151 606 c + +967 660 m +966 761 937 841 882 901 c +827 961 755 991 664 991 c +561 991 479 962 417 904 c +356 846 320 764 311 659 c +967 660 l + +ce} _d +/h{1298 0 186 0 1124 1556 sc +1124 676 m +1124 0 l +940 0 l +940 670 l +940 776 919 855 878 908 c +837 961 775 987 692 987 c +593 987 514 955 457 892 c +400 829 371 742 371 633 c +371 0 l +186 0 l +186 1556 l +371 1556 l +371 946 l +415 1013 467 1064 526 1097 c +586 1130 655 1147 733 1147 c +862 1147 959 1107 1025 1027 c +1091 948 1124 831 1124 676 c + +ce} _d +/i{569 0 193 0 377 1556 sc +193 1120 m +377 1120 l +377 0 l +193 0 l +193 1120 l + +193 1556 m +377 1556 l +377 1323 l +193 1323 l +193 1556 l + +ce} _d +/n{1298 0 186 0 1124 1147 sc +1124 676 m +1124 0 l +940 0 l +940 670 l +940 776 919 855 878 908 c +837 961 775 987 692 987 c +593 987 514 955 457 892 c +400 829 371 742 371 633 c +371 0 l +186 0 l +186 1120 l +371 1120 l +371 946 l +415 1013 467 1064 526 1097 c +586 1130 655 1147 733 1147 c +862 1147 959 1107 1025 1027 c +1091 948 1124 831 1124 676 c + +ce} _d +/r{842 0 186 0 842 1147 sc +842 948 m +821 960 799 969 774 974 c +750 980 723 983 694 983 c +590 983 510 949 454 881 c +399 814 371 717 371 590 c +371 0 l +186 0 l +186 1120 l +371 1120 l +371 946 l +410 1014 460 1064 522 1097 c +584 1130 659 1147 748 1147 c +761 1147 775 1146 790 1144 c +805 1143 822 1140 841 1137 c +842 948 l + +ce} _d +/T{1251 0 -6 0 1257 1493 sc +-6 1493 m +1257 1493 l +1257 1323 l +727 1323 l +727 0 l +524 0 l +524 1323 l +-6 1323 l +-6 1493 l + +ce} _d +/t{803 0 55 0 754 1438 sc +375 1438 m +375 1120 l +754 1120 l +754 977 l +375 977 l +375 369 l +375 278 387 219 412 193 c +437 167 488 154 565 154 c +754 154 l +754 0 l +565 0 l +423 0 325 26 271 79 c +217 132 190 229 190 369 c +190 977 l +55 977 l +55 1120 l +190 1120 l +190 1438 l +375 1438 l + +ce} _d +/w{1675 0 86 0 1589 1120 sc +86 1120 m +270 1120 l +500 246 l +729 1120 l +946 1120 l +1176 246 l +1405 1120 l +1589 1120 l +1296 0 l +1079 0 l +838 918 l +596 0 l +379 0 l +86 1120 l + +ce} _d +end readonly def + +/BuildGlyph { + exch begin + CharStrings exch + 2 copy known not {pop /.notdef} if + true 3 1 roll get exec + end +} _d + +/BuildChar { + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec +} _d + +FontName currentdict end definefont pop +%!PS-Adobe-3.0 Resource-Font +%%Creator: Converted from TrueType to Type 3 by Matplotlib. +10 dict begin +/FontName /WenQuanYiZenHei def +/PaintType 0 def +/FontMatrix [0.0009765625 0 0 0.0009765625 0 0] def +/FontBBox [-129 -304 1076 986] def +/FontType 3 def +/Encoding [/uni51E0 /uni6C49 /uni4E2A /uni5B57] def +/CharStrings 5 dict dup begin +/.notdef 0 def +/uni51E0{1024 0 34 -126 1004 818 sc +935 257 m +947 232 970 219 1004 217 c +974 -26 l +973 -40 968 -51 960 -60 c +955 -63 950 -65 943 -65 c +729 -65 l +697 -65 670 -55 649 -34 c +628 -14 617 12 615 43 c +614 75 613 193 613 397 c +613 602 614 719 616 748 c +393 748 l +394 395 l +391 256 361 144 302 58 c +250 -21 184 -82 104 -126 c +89 -96 66 -77 34 -68 c +119 -34 188 23 240 103 c +292 184 318 281 318 395 c +317 818 l +692 818 l +692 55 l +692 42 695 30 702 20 c +709 11 719 6 730 6 c +886 6 l +898 7 905 12 908 23 c +911 42 912 62 913 81 c +935 257 l + +ce} _d +/uni6C49{1024 0 17 -119 990 820 sc +612 211 m +536 349 488 512 468 699 c +447 699 426 698 405 697 c +407 719 407 741 405 763 c +445 761 485 760 526 760 c +871 760 l +851 534 793 348 696 202 c +769 91 867 2 990 -65 c +963 -76 942 -94 928 -119 c +819 -56 727 31 653 143 c +561 27 446 -58 307 -112 c +289 -89 268 -69 243 -53 c +392 -2 515 86 612 211 c + +535 700 m +552 534 592 391 655 271 c +735 396 782 539 796 700 c +535 700 l + +151 -118 m +123 -102 88 -93 47 -92 c +76 -38 107 24 138 93 c +169 162 215 283 274 454 c +315 433 l +199 54 l +151 -118 l + +230 457 m +166 408 l +17 544 l +80 594 l +230 457 l + +248 626 m +202 677 152 724 97 768 c +157 820 l +214 773 268 723 317 670 c +248 626 l + +ce} _d +/uni4E2A{1024 0 14 -123 980 833 sc +547 -123 m +520 -120 492 -120 464 -123 c +467 -72 468 -21 468 30 c +468 362 l +468 413 467 465 464 516 c +492 513 520 513 547 516 c +545 465 544 413 544 362 c +544 30 l +544 -21 545 -72 547 -123 c + +980 427 m +955 410 939 387 931 358 c +846 384 767 429 695 494 c +624 559 563 631 514 711 c +383 520 236 378 71 285 c +59 314 40 337 14 354 c +113 405 204 471 285 550 c +367 630 433 724 484 833 c +499 822 515 813 531 805 c +537 808 l +542 800 l +549 796 557 792 564 789 c +555 775 l +614 672 682 590 759 531 c +824 484 898 450 980 427 c + +ce} _d +/uni5B57{1024 0 32 -132 982 872 sc +982 285 m +980 264 980 243 982 222 c +943 224 904 225 865 225 c +555 225 l +555 -29 l +555 -54 545 -76 525 -95 c +496 -120 444 -132 368 -131 c +376 -98 368 -68 344 -43 c +366 -46 392 -47 422 -47 c +452 -48 470 -46 475 -42 c +480 -38 483 -27 483 -9 c +483 225 l +148 225 l +109 225 71 224 32 222 c +34 243 34 264 32 285 c +71 283 109 282 148 282 c +483 282 l +483 355 l +648 506 l +317 506 l +278 506 239 505 200 503 c +203 524 203 545 200 566 c +239 564 278 563 317 563 c +761 563 l +769 498 l +748 493 730 483 714 469 c +555 323 l +555 282 l +865 282 l +904 282 943 283 982 285 c + +131 562 m +59 562 l +59 753 l +468 752 l +390 807 l +435 872 l +542 798 l +510 752 l +925 752 l +925 562 l +852 562 l +852 695 l +131 695 l +131 562 l + +ce} _d +end readonly def + +/BuildGlyph { + exch begin + CharStrings exch + 2 copy known not {pop /.notdef} if + true 3 1 roll get exec + end +} _d + +/BuildChar { + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec +} _d + +FontName currentdict end definefont pop +end +%%EndProlog +mpldict begin +18 180 translate +576 432 0 0 clipbox +gsave +0 0 m +576 0 l +576 432 l +0 432 l +cl +1.000 setgray +fill +grestore +0.000 setgray +/DejaVuSans 27.000 selectfont +gsave + +86.4 205.2 translate +0 rotate +0.000000 0 m /T glyphshow +16.492676 0 m /h glyphshow +33.604980 0 m /e glyphshow +50.216309 0 m /r glyphshow +61.316895 0 m /e glyphshow +77.928223 0 m /space glyphshow +86.510742 0 m /a glyphshow +103.056152 0 m /r glyphshow +114.156738 0 m /e glyphshow +130.768066 0 m /space glyphshow +grestore +/WenQuanYiZenHei 27.000 selectfont +gsave + +86.4 205.2 translate +0 rotate +139.350586 0 m /uni51E0 glyphshow +166.350586 0 m /uni4E2A glyphshow +193.350586 0 m /uni6C49 glyphshow +220.350586 0 m /uni5B57 glyphshow +grestore +/DejaVuSans 27.000 selectfont +gsave + +86.4 205.2 translate +0 rotate +247.350586 0 m /space glyphshow +255.933105 0 m /i glyphshow +263.434570 0 m /n glyphshow +280.546875 0 m /space glyphshow +289.129395 0 m /b glyphshow +306.268066 0 m /e glyphshow +322.879395 0 m /t glyphshow +333.465820 0 m /w glyphshow +355.548340 0 m /e glyphshow +372.159668 0 m /e glyphshow +388.770996 0 m /n glyphshow +405.883301 0 m /exclam glyphshow +grestore + +end +showpage diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/multi_font_type42.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/multi_font_type42.eps new file mode 100644 index 000000000000..472824330da4 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_ps/multi_font_type42.eps @@ -0,0 +1,361 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Title: multi_font_type42.eps +%%Creator: Matplotlib v3.6.0.dev2856+g4848cedd6d.d20220807, https://matplotlib.org/ +%%CreationDate: Sun Aug 7 16:45:01 2022 +%%Orientation: portrait +%%BoundingBox: 18 180 594 612 +%%HiResBoundingBox: 18.000000 180.000000 594.000000 612.000000 +%%EndComments +%%BeginProlog +/mpldict 12 dict def +mpldict begin +/_d { bind def } bind def +/m { moveto } _d +/l { lineto } _d +/r { rlineto } _d +/c { curveto } _d +/cl { closepath } _d +/ce { closepath eofill } _d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } _d +/clipbox { + box + clip + newpath + } _d +/sc { setcachedevice } _d +%!PS-TrueTypeFont-1.0-2.22937 +%%Title: unknown +%%Creator: Converted from TrueType to type 42 by PPR +15 dict begin +/FontName /DejaVuSans def +/PaintType 0 def +/FontMatrix[1 0 0 1 0 0]def +/FontBBox[-1021 -463 1793 1232]def +/FontType 42 def +/Encoding StandardEncoding def +/FontInfo 10 dict dup begin +/FamilyName (unknown) def +/FullName (unknown) def +/Weight (unknown) def +/Version (unknown) def +/ItalicAngle 0.0 def +/isFixedPitch false def +/UnderlinePosition -130 def +/UnderlineThickness 90 def +end readonly def +/sfnts[<0001000000090080000300106376742000691D390000009C000001FE6670676D +7134766A0000029C000000AB676C7966118399D500000348000007B668656164085DC286 +00000B0000000036686865610D9F077C00000B3800000024686D74783A5706B700000B5C +0000003C6C6F636108C30B6500000B98000000206D617870047C067100000BB800000020 +707265703B07F10000000BD800000568013500B800CB00CB00C100AA009C01A600B80066 +0000007100CB00A002B20085007500B800C301CB0189022D00CB00A600F000D300AA0087 +00CB03AA0400014A003300CB000000D9050200F4015400B4009C01390114013907060400 +044E04B4045204B804E704CD0037047304CD04600473013303A2055605A60556053903C5 +021200C9001F00B801DF007300BA03E9033303BC0444040E00DF03CD03AA00E503AA0404 +000000CB008F00A4007B00B80014016F007F027B0252008F00C705CD009A009A006F00CB +00CD019E01D300F000BA018300D5009803040248009E01D500C100CB00F600830354027F +00000333026600D300C700A400CD008F009A0073040005D5010A00FE022B00A400B4009C +00000062009C0000001D032D05D505D505D505F0007F007B005400A406B80614072301D3 +00B800CB00A601C301EC069300A000D3035C037103DB0185042304A80448008F01390114 +01390360008F05D5019A0614072306660179046004600460047B009C00000277046001AA +00E904600762007B00C5007F027B000000B4025205CD006600BC00660077061000CD013B +01850389008F007B0000001D00CD074A042F009C009C0000077D006F0000006F0335006A +006F007B00AE00B2002D0396008F027B00F600830354063705F6008F009C04E10266008F +018D02F600CD03440029006604EE00730000140000960000B707060504030201002C2010 +B002254964B040515820C859212D2CB002254964B040515820C859212D2C20100720B000 +50B00D7920B8FFFF5058041B0559B0051CB0032508B0042523E120B00050B00D7920B8FF +FF5058041B0559B0051CB0032508E12D2C4B505820B0FD454459212D2CB002254560442D +2C4B5358B00225B0022545445921212D2C45442D2CB00225B0022549B00525B005254960 +B0206368208A108A233A8A10653A2D00000201350000020005D5000300090035400F0700 +8304810208070501030400000A10FC4BB00B5458B90000FFC038593CEC32393931002FE4 +FCCC3001B6000B200B500B035D253315231133110323030135CBCBCB14A215FEFE05D5FD +71FE9B0165000001FFFA000004E905D50007004A400E0602950081040140031C00400508 +10D4E4FCE431002FF4EC3230014BB00A5458BD00080040000100080008FFC03811373859 +401300091F00100110021F071009400970099F09095D03211521112311210604EFFDEECB +FDEE05D5AAFAD5052B000002007BFFE3042D047B000A002500BC4027191F0B17090E00A9 +1706B90E1120861FBA1CB923B8118C170C001703180D09080B1F030814452610FCECCCD4 +EC323211393931002FC4E4F4FCF4EC10C6EE10EE11391139123930406E301D301E301F30 +20302130223F27401D401E401F402040214022501D501E501F5020502150225027702785 +1D871E871F8720872185229027A027F0271E301E301F30203021401E401F40204021501E +501F50205021601E601F60206021701E701F70207021801E801F80208021185D015D0122 +061514163332363D01371123350E01232226353436332135342623220607353E01333216 +02BEDFAC816F99B9B8B83FBC88ACCBFDFB0102A79760B65465BE5AF3F00233667B6273D9 +B4294CFD81AA6661C1A2BDC0127F8B2E2EAA2727FC00000200BAFFE304A40614000B001C +0038401903B90C0F09B918158C0FB81B971900121247180C06081A461D10FCEC3232F4EC +31002FECE4F4C4EC10C6EE30B6601E801EA01E03015D013426232206151416333236013E +01333200111002232226271523113303E5A79292A7A79292A7FD8E3AB17BCC00FFFFCC7B +B13AB9B9022FCBE7E7CBCBE7E702526461FEBCFEF8FEF8FEBC6164A8061400020071FFE3 +047F047B0014001B00704024001501098608880515A90105B90C01BB18B912B80C8C1C1B +1502081508004B02120F451C10FCECF4ECC4111239310010E4F4ECE410EE10EE10F4EE11 +12393040293F1D701DA01DD01DF01D053F003F013F023F153F1B052C072F082F092C0A6F +006F016F026F156F1B095D71015D0115211E0133323637150E0123200011100033320007 +2E0123220607047FFCB20CCDB76AC76263D06BFEF4FEC70129FCE20107B802A5889AB90E +025E5ABEC73434AE2A2C0138010A01130143FEDDC497B4AE9E00000100BA000004640614 +001300344019030900030E0106870E11B80C970A010208004E0D09080B461410FCEC32F4 +EC31002F3CECF4C4EC1112173930B2601501015D0111231134262322061511231133113E +013332160464B87C7C95ACB9B942B375C1C602A4FD5C029E9F9EBEA4FD870614FD9E6564 +EF00000200C100000179061400030007002B400E06BE04B100BC020501080400460810FC +3CEC3231002FE4FCEC30400B1009400950096009700905015D1333112311331523C1B8B8 +B8B80460FBA00614E900000100BA00000464047B001300364019030900030E0106870E11 +B80CBC0A010208004E0D09080B461410FCEC32F4EC31002F3CE4F4C4EC1112173930B460 +15CF1502015D0111231134262322061511231133153E013332160464B87C7C95ACB9B942 +B375C1C602A4FD5C029E9F9EBEA4FD870460AE6564EF000100BA0000034A047B00110030 +4014060B0700110B03870EB809BC070A06080008461210FCC4EC3231002FE4F4ECC4D4CC +11123930B450139F1302015D012E012322061511231133153E0133321617034A1F492C9C +A7B9B93ABA85132E1C03B41211CBBEFDB20460AE6663050500010037000002F2059E0013 +003840190E05080F03A9001101BC08870A0B08090204000810120E461410FC3CC4FC3CC4 +32393931002FECF43CC4EC3211393930B2AF1501015D01112115211114163B0115232226 +3511233533110177017BFE854B73BDBDD5A28787059EFEC28FFDA0894E9A9FD202608F01 +3E0000010056000006350460000C01EB404905550605090A0904550A0903550A0B0A0255 +01020B0B0A061107080705110405080807021103020C000C011100000C420A0502030603 +00BF0B080C0B0A09080605040302010B07000D10D44BB00A544BB011545B4BB012545B4B +B013545B4BB00B545B58B9000000403859014BB00C544BB00D545B4BB010545B58B90000 +FFC03859CC173931002F3CEC32321739304B5358071005ED071008ED071008ED071005ED +071008ED071005ED0705ED071008ED59220140FF050216021605220A350A49024905460A +400A5B025B05550A500A6E026E05660A79027F0279057F05870299029805940ABC02BC05 +CE02C703CF051D0502090306040B050A080B09040B050C1502190316041A051B081B0914 +0B150C2500250123022703210425052206220725082709240A210B230C39033604360839 +0C300E460248034604400442054006400740084409440A440B400E400E56005601560250 +0451055206520750085309540A550B6300640165026A0365046A056A066A076E09610B67 +0C6F0E7500750179027D0378047D057A067F067A077F07780879097F097B0A760B7D0C87 +0288058F0E97009701940293039C049B05980698079908402F960C9F0EA600A601A402A4 +03AB04AB05A906A907AB08A40CAF0EB502B103BD04BB05B809BF0EC402C303CC04CA0579 +5D005D13331B01331B013301230B012356B8E6E5D9E6E5B8FEDBD9F1F2D90460FC96036A +FC96036AFBA00396FC6A00000001000000025999D203C60C5F0F3CF5001F080000000000 +D17E0EE400000000D17E0EE4F7D6FC4C0E5909DC00000008000000000000000000010000 +076DFE1D00000EFEF7D6FA510E5900010000000000000000000000000000000F04CD0066 +0000000002AA0000028B00000335013504E3FFFA04E7007B051400BA04EC0071051200BA +023900C1051200BA034A00BA03230037068B0056000000000000000000000031006900FF +014B01B501F102190255028C02C903DB00010000000F0354002B0068000C000200100099 +000800000415021600080004B8028040FFFBFE03FA1403F92503F83203F79603F60E03F5 +FE03F4FE03F32503F20E03F19603F02503EF8A4105EFFE03EE9603ED9603ECFA03EBFA03 +EAFE03E93A03E84203E7FE03E63203E5E45305E59603E48A4105E45303E3E22F05E3FA03 +E22F03E1FE03E0FE03DF3203DE1403DD9603DCFE03DB1203DA7D03D9BB03D8FE03D68A41 +05D67D03D5D44705D57D03D44703D3D21B05D3FE03D21B03D1FE03D0FE03CFFE03CEFE03 +CD9603CCCB1E05CCFE03CB1E03CA3203C9FE03C6851105C61C03C51603C4FE03C3FE03C2 +FE03C1FE03C0FE03BFFE03BEFE03BDFE03BCFE03BBFE03BA1103B9862505B9FE03B8B7BB +05B8FE03B7B65D05B7BB03B78004B6B52505B65D40FF03B64004B52503B4FE03B39603B2 +FE03B1FE03B0FE03AFFE03AE6403AD0E03ACAB2505AC6403ABAA1205AB2503AA1203A98A +4105A9FA03A8FE03A7FE03A6FE03A51203A4FE03A3A20E05A33203A20E03A16403A08A41 +05A096039FFE039E9D0C059EFE039D0C039C9B19059C64039B9A10059B19039A1003990A +0398FE0397960D0597FE03960D03958A410595960394930E05942803930E0392FA039190 +BB0591FE03908F5D0590BB039080048F8E25058F5D038F40048E25038DFE038C8B2E058C +FE038B2E038A8625058A410389880B05891403880B038786250587640386851105862503 +85110384FE038382110583FE0382110381FE0380FE037FFE0340FF7E7D7D057EFE037D7D +037C64037B5415057B25037AFE0379FE03780E03770C03760A0375FE0374FA0373FA0372 +FA0371FA0370FE036FFE036EFE036C21036BFE036A1142056A530369FE03687D03671142 +0566FE0365FE0364FE0363FE0362FE03613A0360FA035E0C035DFE035BFE035AFE035958 +0A0559FA03580A035716190557320356FE03555415055542035415035301100553180352 +1403514A130551FE03500B034FFE034E4D10054EFE034D10034CFE034B4A13054BFE034A +4910054A1303491D0D05491003480D0347FE0346960345960344FE0343022D0543FA0342 +BB03414B0340FE033FFE033E3D12053E14033D3C0F053D12033C3B0D053C40FF0F033B0D +033AFE0339FE033837140538FA033736100537140336350B05361003350B03341E03330D +0332310B0532FE03310B03302F0B05300D032F0B032E2D09052E10032D09032C32032B2A +25052B64032A2912052A25032912032827250528410327250326250B05260F03250B0324 +FE0323FE03220F03210110052112032064031FFA031E1D0D051E64031D0D031C1142051C +FE031BFA031A42031911420519FE031864031716190517FE031601100516190315FE0314 +FE0313FE031211420512FE0311022D05114203107D030F64030EFE030D0C16050DFE030C +0110050C16030BFE030A100309FE0308022D0508FE030714030664030401100504FE0340 +1503022D0503FE0302011005022D0301100300FE0301B80164858D012B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B002B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B +2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B1D00>]def +/CharStrings 13 dict dup begin +/.notdef 0 def +/space 3 def +/exclam 4 def +/b 7 def +/a 6 def +/e 8 def +/h 9 def +/i 10 def +/n 11 def +/r 12 def +/T 5 def +/t 13 def +/w 14 def +end readonly def + +systemdict/resourcestatus known + {42 /FontType resourcestatus + {pop pop false}{true}ifelse} + {true}ifelse +{/TrueDict where{pop}{(%%[ Error: no TrueType rasterizer ]%%)= flush}ifelse +/FontType 3 def + /TrueState 271 string def + TrueDict begin sfnts save + 72 0 matrix defaultmatrix dtransform dup + mul exch dup mul add sqrt cvi 0 72 matrix + defaultmatrix dtransform dup mul exch dup + mul add sqrt cvi 3 -1 roll restore + TrueState initer end + /BuildGlyph{exch begin + CharStrings dup 2 index known + {exch}{exch pop /.notdef}ifelse + get dup xcheck + {currentdict systemdict begin begin exec end end} + {TrueDict begin /bander load cvlit exch TrueState render end} + ifelse + end}bind def + /BuildChar{ + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec + }bind def +}if + +FontName currentdict end definefont pop +%!PS-TrueTypeFont-1.0-0.58982 +%%Title: unknown +%%Creator: Converted from TrueType to type 42 by PPR +15 dict begin +/FontName /WenQuanYiZenHei def +/PaintType 0 def +/FontMatrix[1 0 0 1 0 0]def +/FontBBox[-126 -297 1051 963]def +/FontType 42 def +/Encoding StandardEncoding def +/FontInfo 10 dict dup begin +/FamilyName (unknown) def +/FullName (unknown) def +/Weight (unknown) def +/Version (unknown) def +/ItalicAngle 0.0 def +/isFixedPitch false def +/UnderlinePosition -230 def +/UnderlineThickness 51 def +end readonly def +/sfnts[<00010000000700400002003063767420002202880000007C00000004676C7966 +AC20EA39000000800000024A68656164F2831BDF000002CC000000366868656107EC01A3 +0000030400000024686D747805A7004E000003280000001A6C6F636101A0011100000344 +000000126D617870008A02690000035800000020002202880002000DFF8503D50341000D +0024000005260736351134271637061511140106072E0127020726273E01371617371716 +17071617160223292A04042A290301B4250C80D74AC4F7122795F54C171806050B0B0958 +74627B04044D4C014C4D4D04044D4DFEB44C01D91A2B27C278FEE18B2C194DEFA3100C03 +0806050E9B59460000010021FF8203EC033300230000011617070607062B01222E011037 +23130607060726273E013503211114163B013637363703A712331E020C070AD6303F0503 +DF0104584E781630809C01017715119C1204040101012503F3150D053D5F02652CFE9FD0 +8176422D0D33F1AB01A7FD05141D01101D1D0002001FFF7C03D60369002A003700000106 +17262321151407062736271E01363D012122073627163321353721220736271633211706 +0F011521320123350527371707211523352103D603033A3BFECA1E2B720C24215A10FEB1 +3A3A03033A3A014FA5FEB53A3B04043B3A01BC081F189F01363BFCE74801994E2D6B2001 +9F49FD2F011D1F2003FE261C2501322604010C1BEA03201F03499703201F034108159229 +0118BF0137414A2EBE8500050010FF8803DF03350016001B00230027002D000025260322 +0736271633210207161706072627060726273613161736370126273E011317031307273F +0126273716170264721E201F03033C3D01591E916EB82915A46F8AD01B25E0441A5E7815 +FD7B2A3E2C5E5929741F40953FA845523C564AD3CF011902212103FEADDBA66510265EA8 +AE5123184D02A4F9B4BBF2FCCE180251D0010115FE850193318832204C42344650000000 +000100000000E666EDAC36235F0F3CF5003F040000000000C7BE78E900000000C7BE78E9 +FF7FFED0043403DA0000000800020000000000000001000003DAFED0005C0455FF7FFE78 +043400010000000000000000000000000000000501760022000000000000000000000000 +0400000D0021001F00100000000000000000000000000040007B00D10125000000010000 +00080165002800D10012000200000001000100000040002E0006000200>]def +/CharStrings 5 dict dup begin +/.notdef 0 def +/uni51E0 5 def +/uni6C49 7 def +/uni4E2A 4 def +/uni5B57 6 def +end readonly def + +systemdict/resourcestatus known + {42 /FontType resourcestatus + {pop pop false}{true}ifelse} + {true}ifelse +{/TrueDict where{pop}{(%%[ Error: no TrueType rasterizer ]%%)= flush}ifelse +/FontType 3 def + /TrueState 271 string def + TrueDict begin sfnts save + 72 0 matrix defaultmatrix dtransform dup + mul exch dup mul add sqrt cvi 0 72 matrix + defaultmatrix dtransform dup mul exch dup + mul add sqrt cvi 3 -1 roll restore + TrueState initer end + /BuildGlyph{exch begin + CharStrings dup 2 index known + {exch}{exch pop /.notdef}ifelse + get dup xcheck + {currentdict systemdict begin begin exec end end} + {TrueDict begin /bander load cvlit exch TrueState render end} + ifelse + end}bind def + /BuildChar{ + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec + }bind def +}if + +FontName currentdict end definefont pop +end +%%EndProlog +mpldict begin +18 180 translate +576 432 0 0 clipbox +gsave +0 0 m +576 0 l +576 432 l +0 432 l +cl +1.000 setgray +fill +grestore +0.000 setgray +/DejaVuSans 27.000 selectfont +gsave + +86.4 205.2 translate +0 rotate +0.000000 0 m /T glyphshow +16.492676 0 m /h glyphshow +33.604980 0 m /e glyphshow +50.216309 0 m /r glyphshow +61.316895 0 m /e glyphshow +77.928223 0 m /space glyphshow +86.510742 0 m /a glyphshow +103.056152 0 m /r glyphshow +114.156738 0 m /e glyphshow +130.768066 0 m /space glyphshow +grestore +/WenQuanYiZenHei 27.000 selectfont +gsave + +86.4 205.2 translate +0 rotate +139.350586 0 m /uni51E0 glyphshow +166.350586 0 m /uni4E2A glyphshow +193.350586 0 m /uni6C49 glyphshow +220.350586 0 m /uni5B57 glyphshow +grestore +/DejaVuSans 27.000 selectfont +gsave + +86.4 205.2 translate +0 rotate +247.350586 0 m /space glyphshow +255.933105 0 m /i glyphshow +263.434570 0 m /n glyphshow +280.546875 0 m /space glyphshow +289.129395 0 m /b glyphshow +306.268066 0 m /e glyphshow +322.879395 0 m /t glyphshow +333.465820 0 m /w glyphshow +355.548340 0 m /e glyphshow +372.159668 0 m /e glyphshow +388.770996 0 m /n glyphshow +405.883301 0 m /exclam glyphshow +grestore + +end +showpage diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/scatter.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/scatter.eps new file mode 100644 index 000000000000..b21ff4234af4 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_ps/scatter.eps @@ -0,0 +1,306 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Title: scatter.eps +%%Creator: Matplotlib v3.6.0.dev2701+g27bf604984.d20220719, https://matplotlib.org/ +%%CreationDate: Tue Jul 19 12:36:23 2022 +%%Orientation: portrait +%%BoundingBox: 18 180 594 612 +%%HiResBoundingBox: 18.000000 180.000000 594.000000 612.000000 +%%EndComments +%%BeginProlog +/mpldict 10 dict def +mpldict begin +/_d { bind def } bind def +/m { moveto } _d +/l { lineto } _d +/r { rlineto } _d +/c { curveto } _d +/cl { closepath } _d +/ce { closepath eofill } _d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } _d +/clipbox { + box + clip + newpath + } _d +/sc { setcachedevice } _d +end +%%EndProlog +mpldict begin +18 180 translate +576 432 0 0 clipbox +gsave +0 0 m +576 0 l +576 432 l +0 432 l +cl +1.000 setgray +fill +grestore +/p0_0 { +newpath +translate +72 141.529351 m +17.327389 80.435325 l +126.672611 80.435325 l +cl + +} bind def +/p0_1 { +newpath +translate +72 158.4 m +-17.28 100.8 l +72 43.2 l +161.28 100.8 l +cl + +} bind def +/p0_2 { +newpath +translate +72 141.529351 m +11.959333 113.386062 l +34.892827 67.849263 l +109.107173 67.849263 l +132.040667 113.386062 l +cl + +} bind def +/p0_3 { +newpath +translate +72 158.4 m +-5.318748 129.6 l +-5.318748 72 l +72 43.2 l +149.318748 72 l +149.318748 129.6 l +cl + +} bind def +1.000 setlinewidth +1 setlinejoin +0 setlinecap +[] 0 setdash +0.000 setgray +gsave +446.4 345.6 72 43.2 clipbox +96.7145 132.649 p0_0 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +166.544 15.5782 p0_1 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +149.874 179.799 p0_2 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +34.7409 104.813 p0_3 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +145.839 37.968 p0_0 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +147.462 82.9425 p0_1 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +147.29 120.393 p0_2 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +151.565 52.8617 p0_3 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +165.375 85.5808 p0_0 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +12.8578 119.079 p0_1 +gsave +1.000 1.000 0.000 setrgbcolor +fill +grestore +stroke +grestore +0.900 0.200 0.100 setrgbcolor +gsave +446.4 345.6 72 43.2 clipbox +326.215567 311.071597 m +334.595085 306.881838 l +334.595085 315.261356 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +184.274432 293.965646 m +190.679432 290.763146 l +190.679432 297.168146 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +276.081223 354.823805 m +283.311607 351.208613 l +283.311607 358.438997 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +411.593191 219.187935 m +420.363106 214.802977 l +420.363106 223.572893 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +141.383198 139.751386 m +149.294063 135.795953 l +149.294063 143.706818 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +154.058079 131.129187 m +160.028366 128.144043 l +160.028366 134.114331 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +247.767539 370.319257 m +255.714503 366.345775 l +255.714503 374.292739 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +410.16817 374.136435 m +419.852735 369.294152 l +419.852735 378.978717 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +450.836918 106.524611 m +457.983473 102.951334 l +457.983473 110.097888 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore +gsave +446.4 345.6 72 43.2 clipbox +397.084416 298.708741 m +402.739273 295.881312 l +402.739273 301.53617 l +cl +gsave +0.000 0.000 1.000 setrgbcolor +fill +grestore +stroke +grestore + +end +showpage diff --git a/lib/matplotlib/tests/baseline_images/test_backend_svg/bold_font_output_with_none_fonttype.svg b/lib/matplotlib/tests/baseline_images/test_backend_svg/bold_font_output_with_none_fonttype.svg index 20526ec9476e..af3d2e7a8f3c 100644 --- a/lib/matplotlib/tests/baseline_images/test_backend_svg/bold_font_output_with_none_fonttype.svg +++ b/lib/matplotlib/tests/baseline_images/test_backend_svg/bold_font_output_with_none_fonttype.svg @@ -10,61 +10,61 @@ - - - - - - - - @@ -73,8 +73,8 @@ L 0 -4 - @@ -82,7 +82,7 @@ L 0 4 - 0 + 0 @@ -97,7 +97,7 @@ L 0 4 - 1 + 1 @@ -112,7 +112,7 @@ L 0 4 - 2 + 2 @@ -127,7 +127,7 @@ L 0 4 - 3 + 3 @@ -142,7 +142,7 @@ L 0 4 - 4 + 4 @@ -157,7 +157,7 @@ L 0 4 - 5 + 5 @@ -172,7 +172,7 @@ L 0 4 - 6 + 6 @@ -187,7 +187,7 @@ L 0 4 - 7 + 7 @@ -202,7 +202,7 @@ L 0 4 - 8 + 8 @@ -217,19 +217,19 @@ L 0 4 - 9 + 9 - nonbold-xlabel + nonbold-xlabel - @@ -238,8 +238,8 @@ L 4 0 - @@ -247,7 +247,7 @@ L -4 0 - 0 + 0 @@ -262,7 +262,7 @@ L -4 0 - 1 + 1 @@ -277,7 +277,7 @@ L -4 0 - 2 + 2 @@ -292,7 +292,7 @@ L -4 0 - 3 + 3 @@ -307,7 +307,7 @@ L -4 0 - 4 + 4 @@ -322,7 +322,7 @@ L -4 0 - 5 + 5 @@ -337,7 +337,7 @@ L -4 0 - 6 + 6 @@ -352,7 +352,7 @@ L -4 0 - 7 + 7 @@ -367,7 +367,7 @@ L -4 0 - 8 + 8 @@ -382,15 +382,15 @@ L -4 0 - 9 + 9 - bold-ylabel + bold-ylabel - bold-title + bold-title diff --git a/lib/matplotlib/tests/baseline_images/test_backend_svg/multi_font_aspath.svg b/lib/matplotlib/tests/baseline_images/test_backend_svg/multi_font_aspath.svg new file mode 100644 index 000000000000..7184700caf17 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_svg/multi_font_aspath.svg @@ -0,0 +1,423 @@ + + + + + + + + 2022-08-09T18:12:28.920123 + image/svg+xml + + + Matplotlib v3.6.0.dev2839+gb0bf8fb1de.d20220809, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_backend_svg/multi_font_astext.svg b/lib/matplotlib/tests/baseline_images/test_backend_svg/multi_font_astext.svg new file mode 100644 index 000000000000..373103f61b9f --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_svg/multi_font_astext.svg @@ -0,0 +1,35 @@ + + + + + + + + 2022-08-09T18:42:37.025191 + image/svg+xml + + + Matplotlib v3.6.0.dev2840+g372782e258.d20220809, https://matplotlib.org/ + + + + + + + + + + + + + + There are 几个汉字 in between! + + + diff --git a/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_fixed_aspect.png b/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_fixed_aspect.png new file mode 100644 index 000000000000..0fd7a35e3303 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_fixed_aspect.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_tight_suptile_legend.svg b/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_tight_suptile_legend.svg index c99489ca7dfb..5cf932d60cb7 100644 --- a/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_tight_suptile_legend.svg +++ b/lib/matplotlib/tests/baseline_images/test_bbox_tight/bbox_inches_tight_suptile_legend.svg @@ -1,21 +1,32 @@ - - + + + + + + 2022-07-07T13:08:55.409721 + image/svg+xml + + + Matplotlib v3.5.0.dev5238+g5d127c48a6.d20220707, https://matplotlib.org/ + + + + + - + +" style="fill: #ffffff"/> @@ -24,10 +35,10 @@ L 542.014375 387.36 L 542.014375 41.76 L 95.614375 41.76 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - +" clip-path="url(#p602791bb46)" style="fill: none; stroke: #0000ff; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-linejoin: miter; stroke-linecap: square"/> - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - - - - + + + + @@ -112,32 +125,33 @@ Q 19.53125 74.21875 31.78125 74.21875 - + - + - - + + - - +" transform="scale(0.015625)"/> + @@ -145,42 +159,43 @@ z - + - + - - - - + + + + @@ -188,50 +203,51 @@ Q 31.109375 20.453125 19.1875 8.296875 - + - + - - - - + + + + @@ -239,36 +255,38 @@ Q 46.96875 40.921875 40.578125 39.3125 - + - + - - + + - - +" transform="scale(0.015625)"/> + @@ -276,43 +294,44 @@ z - + - + - - + + - - +" transform="scale(0.015625)"/> + @@ -320,47 +339,49 @@ z - + - + - - - - + + + + @@ -368,28 +389,29 @@ Q 48.484375 72.75 52.59375 71.296875 - + - + - - + + - - +" transform="scale(0.015625)"/> + @@ -397,55 +419,58 @@ z - + - + - - - - + + + + @@ -453,163 +478,172 @@ Q 18.3125 60.0625 18.3125 54.390625 - + - + - - - - + + + + - - + + - - + + - - + + - - - +M 603 4863 +L 1178 4863 +L 1178 4134 +L 603 4134 +L 603 4863 +z +" transform="scale(0.015625)"/> + + - - - - - + + + + + @@ -617,446 +651,462 @@ Q 40.578125 54.546875 44.28125 53.078125 - +" style="stroke: #000000; stroke-width: 0.5"/> - + - +" style="stroke: #000000; stroke-width: 0.5"/> - + - - + + - - +" transform="scale(0.015625)"/> + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - - + + - - + - - + - - - + - - +" transform="scale(0.015625)"/> + + + + + - - - - - - - - - - - + + + + + + + + + + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - - + + - + - + - - +" transform="scale(0.015625)"/> + - - - - - - - - - + + + + + + + + + @@ -1066,105 +1116,107 @@ L 656.183875 74.4165 L 656.183875 48.96 L 504.574375 48.96 z -" style="fill:#ffffff;stroke:#000000;stroke-linejoin:miter;"/> +" style="fill: #ffffff; stroke: #000000; stroke-linejoin: miter"/> +" style="fill: none; stroke: #0000ff; stroke-linecap: square"/> - - - + + - - +M 3481 434 +Q 3481 -459 3084 -895 +Q 2688 -1331 1869 -1331 +Q 1566 -1331 1297 -1286 +Q 1028 -1241 775 -1147 +L 775 -588 +Q 1028 -725 1275 -790 +Q 1522 -856 1778 -856 +Q 2344 -856 2625 -561 +Q 2906 -266 2906 331 +L 2906 616 +Q 2728 306 2450 153 +Q 2172 0 1784 0 +Q 1141 0 747 490 +Q 353 981 353 1791 +Q 353 2603 747 3093 +Q 1141 3584 1784 3584 +Q 2172 3584 2450 3431 +Q 2728 3278 2906 2969 +L 2906 3500 +L 3481 3500 +L 3481 434 +z +" transform="scale(0.015625)"/> + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + - - +" transform="scale(0.015625)"/> + - - - - - - - - - - - + + + + + + + + + + + - - + + diff --git a/lib/matplotlib/tests/baseline_images/test_collections/test_check_masked_offsets.png b/lib/matplotlib/tests/baseline_images/test_collections/test_check_masked_offsets.png new file mode 100644 index 000000000000..ebac9df75ddb Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_collections/test_check_masked_offsets.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/colorbar_extend_alpha.png b/lib/matplotlib/tests/baseline_images/test_colorbar/colorbar_extend_alpha.png new file mode 100644 index 000000000000..68cbf3d341f3 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/colorbar_extend_alpha.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/colorbar_keeping_xlabel.png b/lib/matplotlib/tests/baseline_images/test_colorbar/colorbar_keeping_xlabel.png new file mode 100644 index 000000000000..410b9f5b0878 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/colorbar_keeping_xlabel.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/contourf_extend_patches.png b/lib/matplotlib/tests/baseline_images/test_colorbar/contourf_extend_patches.png new file mode 100644 index 000000000000..0e5ef52cf549 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/contourf_extend_patches.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/extend_drawedges.png b/lib/matplotlib/tests/baseline_images/test_colorbar/extend_drawedges.png new file mode 100644 index 000000000000..864eeefbae10 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/extend_drawedges.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_contour/contour_all_algorithms.png b/lib/matplotlib/tests/baseline_images/test_contour/contour_all_algorithms.png new file mode 100644 index 000000000000..1ef0c15a678f Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_contour/contour_all_algorithms.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_contour/contour_log_locator.svg b/lib/matplotlib/tests/baseline_images/test_contour/contour_log_locator.svg new file mode 100644 index 000000000000..a4a397104ef7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_contour/contour_log_locator.svg @@ -0,0 +1,3375 @@ + + + + + + + + 2022-12-11T14:01:58.700972 + image/svg+xml + + + Matplotlib v3.6.0.dev2642+g907c10911d.d20221211, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_lines/striped_line.png b/lib/matplotlib/tests/baseline_images/test_lines/striped_line.png new file mode 100644 index 000000000000..957129cb981f Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_lines/striped_line.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext0_cm_00.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext0_cm_00.svg new file mode 100644 index 000000000000..619549bbbe68 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext0_cm_00.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + ¡ + - + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext0_dejavusans_00.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext0_dejavusans_00.svg new file mode 100644 index 000000000000..72ba7d0a32cc --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext0_dejavusans_00.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + −- + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_02.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_02.png new file mode 100644 index 000000000000..b37505cc62b0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_02.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_03.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_03.png new file mode 100644 index 000000000000..f28fb31c542f Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext1_dejavusans_03.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.pdf deleted file mode 100644 index 503cd69d37ec..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.png deleted file mode 100644 index c160c75b2d2c..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.svg deleted file mode 100644 index c0d0e3b91162..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_30.svg +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.pdf index 94cdddaf6dcf..c463850d8d0f 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.pdf and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.png index ae23ecedc0ce..2cf6829952b2 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.png and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.svg index 1d225fbb273a..ea21bf1f35e2 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_68.svg @@ -1,160 +1,216 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + 2022-10-24T12:21:21.236679 + image/svg+xml + + + Matplotlib v3.6.0.dev4028+gdd27a7f3c3.d20221024, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.pdf deleted file mode 100644 index 8b8bc68dfd5b..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.png deleted file mode 100644 index 9ed5a18bf57b..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.svg deleted file mode 100644 index f423ee824f38..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_cm_77.svg +++ /dev/null @@ -1,222 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.pdf deleted file mode 100644 index 1783edfa14e6..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.png deleted file mode 100644 index 05b1e65e1419..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.svg deleted file mode 100644 index 13ba043bf787..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_30.svg +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.pdf index 3a95452967ac..a33f6b7d8385 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.pdf and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.png index d655592d81c5..5cc8c6ef7b31 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.png and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.svg index bdb560153175..94bd4b633188 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_68.svg @@ -1,128 +1,184 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + 2022-10-24T13:47:25.928529 + image/svg+xml + + + Matplotlib v3.6.0.dev4028+g98b55b4eed.d20221024, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.pdf deleted file mode 100644 index 94ca3d3c6d94..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.png deleted file mode 100644 index 82e0821cd3ea..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.svg deleted file mode 100644 index 98902d769804..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavusans_77.svg +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.pdf deleted file mode 100644 index 35697c58a00e..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.png deleted file mode 100644 index 718302dfb8ca..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.svg deleted file mode 100644 index eb8e7376d155..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_30.svg +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.pdf index dc4c1802c743..64e9b2fe1a97 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.pdf and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.png index 35caf2d2b6ad..9336942936a6 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.png and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.svg index 0284835785a3..f8773bd214fc 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_68.svg @@ -1,117 +1,173 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + 2022-10-24T13:47:45.388857 + image/svg+xml + + + Matplotlib v3.6.0.dev4028+g98b55b4eed.d20221024, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.pdf deleted file mode 100644 index d6fc89ad9f25..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.png deleted file mode 100644 index b3892b25c4ee..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.svg deleted file mode 100644 index 3c9961669b69..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_dejavuserif_77.svg +++ /dev/null @@ -1,217 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.pdf deleted file mode 100644 index 49ad0e80b04a..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.png deleted file mode 100644 index 7001c4f0e3dd..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.svg deleted file mode 100644 index 70746902f681..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_30.svg +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.pdf index 2aca1bfd4a52..c6cf56ddba12 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.pdf and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.png index f51fc6032d83..0aa0ac62b063 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.png and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.svg index be7cdc9f2da8..078cb0fdb8c4 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_68.svg @@ -1,127 +1,183 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + 2022-10-24T13:46:49.965486 + image/svg+xml + + + Matplotlib v3.6.0.dev4028+g98b55b4eed.d20221024, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.pdf deleted file mode 100644 index 3966ee6ddd59..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.png deleted file mode 100644 index 9ed5a18bf57b..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.svg deleted file mode 100644 index f423ee824f38..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stix_77.svg +++ /dev/null @@ -1,222 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.pdf deleted file mode 100644 index ab1a2ee082ae..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.png deleted file mode 100644 index 58bb828044e7..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.svg deleted file mode 100644 index 7bc674a36430..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_30.svg +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.pdf index 323afa6acf0d..e277346b7f1e 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.pdf and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.png index a8ea567f675e..1ea8e26f8d17 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.png and b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.svg index 7fdb080a70a9..0a7baa3550a4 100644 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.svg +++ b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_68.svg @@ -1,111 +1,167 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + 2022-10-24T13:47:05.108205 + image/svg+xml + + + Matplotlib v3.6.0.dev4028+g98b55b4eed.d20221024, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.pdf b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.pdf deleted file mode 100644 index 98512f54330d..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.pdf and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.png b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.png deleted file mode 100644 index 9ed5a18bf57b..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.svg b/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.svg deleted file mode 100644 index f423ee824f38..000000000000 --- a/lib/matplotlib/tests/baseline_images/test_mathtext/mathtext_stixsans_77.svg +++ /dev/null @@ -1,222 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/matplotlib/tests/baseline_images/test_patches/autoscale_arc.png b/lib/matplotlib/tests/baseline_images/test_patches/autoscale_arc.png new file mode 100644 index 000000000000..3a6d3324e9d8 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_patches/autoscale_arc.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_patches/autoscale_arc.svg b/lib/matplotlib/tests/baseline_images/test_patches/autoscale_arc.svg new file mode 100644 index 000000000000..dca6c1f226b3 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_patches/autoscale_arc.svg @@ -0,0 +1,289 @@ + + + + + + + + 2022-07-01T10:48:08.014263 + image/svg+xml + + + Matplotlib v3.6.0.dev2827+g83cb036.d20220701, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_patches/units_rectangle.png b/lib/matplotlib/tests/baseline_images/test_patches/units_rectangle.png index 651e1e2ef95a..f60e962f342c 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_patches/units_rectangle.png and b/lib/matplotlib/tests/baseline_images/test_patches/units_rectangle.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.pdf b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.pdf index 8d0cc584e567..ba027d57a34b 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.pdf and b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.png b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.png index ebd8d95dc0ab..af91778e7d80 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.png and b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.svg b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.svg index e067b3ed266d..a075b6c60e54 100644 --- a/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.svg +++ b/lib/matplotlib/tests/baseline_images/test_patheffects/patheffect2.svg @@ -1,23 +1,23 @@ - + - + - 2020-11-06T19:00:51.436188 + 2022-02-19T11:16:23.155823 image/svg+xml - Matplotlib v3.3.2.post1573+gcdb08ceb8, https://matplotlib.org/ + Matplotlib v3.6.0.dev1697+g00762ef54b, https://matplotlib.org/ - + @@ -26,7 +26,7 @@ L 460.8 345.6 L 460.8 0 L 0 0 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> @@ -35,50 +35,50 @@ L 369.216 307.584 L 369.216 41.472 L 103.104 41.472 z -" style="fill:#ffffff;"/> +" style="fill: #ffffff"/> - - + + - +" style="stroke: #000000; stroke-width: 0.8"/> - + - + - + - + - + @@ -87,187 +87,197 @@ L 0 3.5 - +" style="stroke: #000000; stroke-width: 0.8"/> - + - + - + - + - + - - - - - + + + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - - + + - + - + - + - +" clip-path="url(#p9eb69e71cc)" style="fill: none; stroke: #000000; stroke-width: 1.5"/> + +" style="fill: none; stroke: #000000; stroke-width: 0.8; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-width: 0.8; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-width: 0.8; stroke-linejoin: miter; stroke-linecap: square"/> +" style="fill: none; stroke: #000000; stroke-width: 0.8; stroke-linejoin: miter; stroke-linecap: square"/> - - + +" clip-path="url(#p9eb69e71cc)"/> - - + +" clip-path="url(#p9eb69e71cc)"/> - - + +" clip-path="url(#p9eb69e71cc)"/> - - + +" clip-path="url(#p9eb69e71cc)"/> - - + +" clip-path="url(#p9eb69e71cc)"/> - - + + - - + +" clip-path="url(#p9eb69e71cc)"/> - - + + diff --git a/lib/matplotlib/tests/baseline_images/test_pickle/multi_pickle.png b/lib/matplotlib/tests/baseline_images/test_pickle/multi_pickle.png deleted file mode 100644 index 3a6f41fa2948..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_pickle/multi_pickle.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_polar/polar_log.png b/lib/matplotlib/tests/baseline_images/test_polar/polar_log.png new file mode 100644 index 000000000000..ab8e20b482a5 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_polar/polar_log.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_spines/black_axes.pdf b/lib/matplotlib/tests/baseline_images/test_spines/black_axes.pdf new file mode 100644 index 000000000000..96eacb9308d9 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_spines/black_axes.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_spines/black_axes.png b/lib/matplotlib/tests/baseline_images/test_spines/black_axes.png new file mode 100644 index 000000000000..4fbcbe9b6756 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_spines/black_axes.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_spines/black_axes.svg b/lib/matplotlib/tests/baseline_images/test_spines/black_axes.svg new file mode 100644 index 000000000000..8f7ff932859d --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_spines/black_axes.svg @@ -0,0 +1,64 @@ + + + + + + + + 2022-10-24T17:29:00.034663 + image/svg+xml + + + Matplotlib v3.6.0.dev4027+g68c78c9fb1.d20221024, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_streamplot/streamplot_maxlength_no_broken.png b/lib/matplotlib/tests/baseline_images/test_streamplot/streamplot_maxlength_no_broken.png new file mode 100644 index 000000000000..69b7ca96a9c1 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_streamplot/streamplot_maxlength_no_broken.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_text/text_as_text_opacity.svg b/lib/matplotlib/tests/baseline_images/test_text/text_as_text_opacity.svg index 69d287e3536c..63cdeb8fbd47 100644 --- a/lib/matplotlib/tests/baseline_images/test_text/text_as_text_opacity.svg +++ b/lib/matplotlib/tests/baseline_images/test_text/text_as_text_opacity.svg @@ -10,22 +10,22 @@ - - 50% using `color` + 50% using `color` - 50% using `alpha` + 50% using `alpha` - 50% using `alpha` and 100% `color` + 50% using `alpha` and 100% `color` diff --git a/lib/matplotlib/tests/baseline_images/test_usetex/eqnarray.png b/lib/matplotlib/tests/baseline_images/test_usetex/eqnarray.png new file mode 100644 index 000000000000..249f15d238dd Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_usetex/eqnarray.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_widgets/check_bunch_of_radio_buttons.png b/lib/matplotlib/tests/baseline_images/test_widgets/check_bunch_of_radio_buttons.png deleted file mode 100644 index e071860dfde6..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/test_widgets/check_bunch_of_radio_buttons.png and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/test_widgets/check_radio_buttons.png b/lib/matplotlib/tests/baseline_images/test_widgets/check_radio_buttons.png index e96085d9bffd..d6e6004a1732 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_widgets/check_radio_buttons.png and b/lib/matplotlib/tests/baseline_images/test_widgets/check_radio_buttons.png differ diff --git a/lib/matplotlib/tests/conftest.py b/lib/matplotlib/tests/conftest.py index 06c6d150f31b..54a1bc6cae94 100644 --- a/lib/matplotlib/tests/conftest.py +++ b/lib/matplotlib/tests/conftest.py @@ -1,3 +1,2 @@ -from matplotlib.testing.conftest import (mpl_test_settings, - pytest_configure, pytest_unconfigure, - pd, xr) +from matplotlib.testing.conftest import ( # noqa + mpl_test_settings, pytest_configure, pytest_unconfigure, pd, xr) diff --git a/lib/matplotlib/tests/test_afm.py b/lib/matplotlib/tests/test_afm.py index 2d54c16cb402..e5c6a83937cd 100644 --- a/lib/matplotlib/tests/test_afm.py +++ b/lib/matplotlib/tests/test_afm.py @@ -2,7 +2,7 @@ import pytest import logging -from matplotlib import afm +from matplotlib import _afm from matplotlib import font_manager as fm @@ -39,13 +39,13 @@ def test_nonascii_str(): inp_str = "привет" byte_str = inp_str.encode("utf8") - ret = afm._to_str(byte_str) + ret = _afm._to_str(byte_str) assert ret == inp_str def test_parse_header(): fh = BytesIO(AFM_TEST_DATA) - header = afm._parse_header(fh) + header = _afm._parse_header(fh) assert header == { b'StartFontMetrics': 2.0, b'FontName': 'MyFont-Bold', @@ -66,8 +66,8 @@ def test_parse_header(): def test_parse_char_metrics(): fh = BytesIO(AFM_TEST_DATA) - afm._parse_header(fh) # position - metrics = afm._parse_char_metrics(fh) + _afm._parse_header(fh) # position + metrics = _afm._parse_char_metrics(fh) assert metrics == ( {0: (250.0, 'space', [0, 0, 0, 0]), 42: (1141.0, 'foo', [40, 60, 800, 360]), @@ -81,13 +81,13 @@ def test_parse_char_metrics(): def test_get_familyname_guessed(): fh = BytesIO(AFM_TEST_DATA) - font = afm.AFM(fh) + font = _afm.AFM(fh) del font._header[b'FamilyName'] # remove FamilyName, so we have to guess assert font.get_familyname() == 'My Font' def test_font_manager_weight_normalization(): - font = afm.AFM(BytesIO( + font = _afm.AFM(BytesIO( AFM_TEST_DATA.replace(b"Weight Bold\n", b"Weight Custom\n"))) assert fm.afmFontProperty("", font).weight == "normal" @@ -107,7 +107,7 @@ def test_font_manager_weight_normalization(): def test_bad_afm(afm_data): fh = BytesIO(afm_data) with pytest.raises(RuntimeError): - afm._parse_header(fh) + _afm._parse_header(fh) @pytest.mark.parametrize( @@ -132,6 +132,6 @@ def test_bad_afm(afm_data): def test_malformed_header(afm_data, caplog): fh = BytesIO(afm_data) with caplog.at_level(logging.ERROR): - afm._parse_header(fh) + _afm._parse_header(fh) assert len(caplog.records) == 1 diff --git a/lib/matplotlib/tests/test_agg.py b/lib/matplotlib/tests/test_agg.py index 0e4abf86fe02..5285a24f01f6 100644 --- a/lib/matplotlib/tests/test_agg.py +++ b/lib/matplotlib/tests/test_agg.py @@ -7,7 +7,8 @@ from matplotlib import ( - collections, path, pyplot as plt, transforms as mtransforms, rcParams) + collections, patheffects, pyplot as plt, transforms as mtransforms, + rcParams, rc_context) from matplotlib.backends.backend_agg import RendererAgg from matplotlib.figure import Figure from matplotlib.image import imread @@ -55,7 +56,7 @@ def test_large_single_path_collection(): # applied. f, ax = plt.subplots() collection = collections.PathCollection( - [path.Path([[-10, 5], [10, 5], [10, -5], [-10, -5], [-10, 5]])]) + [Path([[-10, 5], [10, 5], [10, -5], [-10, -5], [-10, 5]])]) ax.add_artist(collection) ax.set_xlim(10**-3, 1) plt.savefig(buff) @@ -180,10 +181,9 @@ def process_image(self, padded_src, dpi): shadow.update_from(line) # offset transform - ot = mtransforms.offset_copy(line.get_transform(), ax.figure, - x=4.0, y=-6.0, units='points') - - shadow.set_transform(ot) + transform = mtransforms.offset_copy(line.get_transform(), ax.figure, + x=4.0, y=-6.0, units='points') + shadow.set_transform(transform) # adjust zorder of the shadow lines so that it is drawn below the # original lines @@ -249,84 +249,90 @@ def test_pil_kwargs_tiff(): assert tags["ImageDescription"] == "test image" +def test_pil_kwargs_webp(): + plt.plot([0, 1, 2], [0, 1, 0]) + buf_small = io.BytesIO() + pil_kwargs_low = {"quality": 1} + plt.savefig(buf_small, format="webp", pil_kwargs=pil_kwargs_low) + assert len(pil_kwargs_low) == 1 + buf_large = io.BytesIO() + pil_kwargs_high = {"quality": 100} + plt.savefig(buf_large, format="webp", pil_kwargs=pil_kwargs_high) + assert len(pil_kwargs_high) == 1 + assert buf_large.getbuffer().nbytes > buf_small.getbuffer().nbytes + + +def test_webp_alpha(): + plt.plot([0, 1, 2], [0, 1, 0]) + buf = io.BytesIO() + plt.savefig(buf, format="webp", transparent=True) + im = Image.open(buf) + assert im.mode == "RGBA" + + def test_draw_path_collection_error_handling(): fig, ax = plt.subplots() - ax.scatter([1], [1]).set_paths(path.Path([(0, 1), (2, 3)])) + ax.scatter([1], [1]).set_paths(Path([(0, 1), (2, 3)])) with pytest.raises(TypeError): fig.canvas.draw() -@pytest.fixture -def chunk_limit_setup(): +def test_chunksize_fails(): + # NOTE: This test covers multiple independent test scenarios in a single + # function, because each scenario uses ~2GB of memory and we don't + # want parallel test executors to accidentally run multiple of these + # at the same time. + N = 100_000 dpi = 500 w = 5*dpi h = 6*dpi - # just fit in the width + # make a Path that spans the whole w-h rectangle x = np.linspace(0, w, N) - # and go top-to-bottom y = np.ones(N) * h y[::2] = 0 + path = Path(np.vstack((x, y)).T) + # effectively disable path simplification (but leaving it "on") + path.simplify_threshold = 0 - idt = IdentityTransform() - # make a renderer + # setup the minimal GraphicsContext to draw a Path ra = RendererAgg(w, h, dpi) - # setup the minimal gc to draw a line gc = ra.new_gc() gc.set_linewidth(1) gc.set_foreground('r') - # make a Path - p = Path(np.vstack((x, y)).T) - # effectively disable path simplification (but leaving it "on") - p.simplify_threshold = 0 - - return ra, gc, p, idt - - -def test_chunksize_hatch_fail(chunk_limit_setup): - ra, gc, p, idt = chunk_limit_setup gc.set_hatch('/') + with pytest.raises(OverflowError, match='can not split hatched path'): + ra.draw_path(gc, path, IdentityTransform()) + gc.set_hatch(None) - with pytest.raises(OverflowError, match='hatched path'): - ra.draw_path(gc, p, idt) + with pytest.raises(OverflowError, match='can not split filled path'): + ra.draw_path(gc, path, IdentityTransform(), (1, 0, 0)) + # Set to zero to disable, currently defaults to 0, but let's be sure. + with rc_context({'agg.path.chunksize': 0}): + with pytest.raises(OverflowError, match='Please set'): + ra.draw_path(gc, path, IdentityTransform()) -def test_chunksize_rgbFace_fail(chunk_limit_setup): - ra, gc, p, idt = chunk_limit_setup + # Set big enough that we do not try to chunk. + with rc_context({'agg.path.chunksize': 1_000_000}): + with pytest.raises(OverflowError, match='Please reduce'): + ra.draw_path(gc, path, IdentityTransform()) - with pytest.raises(OverflowError, match='filled path'): - ra.draw_path(gc, p, idt, (1, 0, 0)) + # Small enough we will try to chunk, but big enough we will fail to render. + with rc_context({'agg.path.chunksize': 90_000}): + with pytest.raises(OverflowError, match='Please reduce'): + ra.draw_path(gc, path, IdentityTransform()) - -def test_chunksize_no_simplify_fail(chunk_limit_setup): - ra, gc, p, idt = chunk_limit_setup - p.should_simplify = False + path.should_simplify = False with pytest.raises(OverflowError, match="should_simplify is False"): - ra.draw_path(gc, p, idt) - + ra.draw_path(gc, path, IdentityTransform()) -def test_chunksize_zero(chunk_limit_setup): - ra, gc, p, idt = chunk_limit_setup - # set to zero to disable, currently defaults to 0, but lets be sure - rcParams['agg.path.chunksize'] = 0 - with pytest.raises(OverflowError, match='Please set'): - ra.draw_path(gc, p, idt) - -def test_chunksize_too_big_to_chunk(chunk_limit_setup): - ra, gc, p, idt = chunk_limit_setup - # set big enough that we do not try to chunk - rcParams['agg.path.chunksize'] = 1_000_000 - with pytest.raises(OverflowError, match='Please reduce'): - ra.draw_path(gc, p, idt) - - -def test_chunksize_toobig_chunks(chunk_limit_setup): - ra, gc, p, idt = chunk_limit_setup - # small enough we will try to chunk, but big enough we will fail - # to render - rcParams['agg.path.chunksize'] = 90_000 - with pytest.raises(OverflowError, match='Please reduce'): - ra.draw_path(gc, p, idt) +def test_non_tuple_rgbaface(): + # This passes rgbaFace as a ndarray to draw_path. + fig = plt.figure() + fig.add_subplot(projection="3d").scatter( + [0, 1, 2], [0, 1, 2], path_effects=[patheffects.Stroke(linewidth=4)]) + fig.canvas.draw() diff --git a/lib/matplotlib/tests/test_animation.py b/lib/matplotlib/tests/test_animation.py index a7ba927494b1..2124493d3132 100644 --- a/lib/matplotlib/tests/test_animation.py +++ b/lib/matplotlib/tests/test_animation.py @@ -1,6 +1,7 @@ import os from pathlib import Path import platform +import re import subprocess import sys import weakref @@ -11,6 +12,7 @@ import matplotlib as mpl from matplotlib import pyplot as plt from matplotlib import animation +from matplotlib.testing.decorators import check_figures_equal @pytest.fixture() @@ -69,6 +71,7 @@ def finish(self): def test_null_movie_writer(anim): # Test running an animation with NullMovieWriter. + plt.rcParams["savefig.facecolor"] = "auto" filename = "unused.null" dpi = 50 savefig_kwargs = dict(foo=0) @@ -81,8 +84,11 @@ def test_null_movie_writer(anim): assert writer.outfile == filename assert writer.dpi == dpi assert writer.args == () - assert writer.savefig_kwargs == savefig_kwargs - assert writer._count == anim.save_count + # we enrich the savefig kwargs to ensure we composite transparent + # output to an opaque background + for k, v in savefig_kwargs.items(): + assert writer.savefig_kwargs[k] == v + assert writer._count == anim._save_count @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim']) @@ -138,8 +144,6 @@ def isAvailable(cls): WRITER_OUTPUT = [ ('ffmpeg', 'movie.mp4'), ('ffmpeg_file', 'movie.mp4'), - ('avconv', 'movie.mp4'), - ('avconv_file', 'movie.mp4'), ('imagemagick', 'movie.gif'), ('imagemagick_file', 'movie.gif'), ('pillow', 'movie.gif'), @@ -230,8 +234,11 @@ def test_animation_repr_html(writer, html, want, anim): assert want in html -@pytest.mark.parametrize('anim', [dict(frames=iter(range(5)))], - indirect=['anim']) +@pytest.mark.parametrize( + 'anim', + [{'save_count': 10, 'frames': iter(range(5))}], + indirect=['anim'] +) def test_no_length_frames(anim): anim.save('unused.null', writer=NullMovieWriter()) @@ -289,9 +296,8 @@ def test_failing_ffmpeg(tmpdir, monkeypatch, anim): with tmpdir.as_cwd(): monkeypatch.setenv("PATH", ".:" + os.environ["PATH"]) exe_path = Path(str(tmpdir), "ffmpeg") - exe_path.write_text("#!/bin/sh\n" - "[[ $@ -eq 0 ]]\n") - os.chmod(str(exe_path), 0o755) + exe_path.write_bytes(b"#!/bin/sh\n[[ $@ -eq 0 ]]\n") + os.chmod(exe_path, 0o755) with pytest.raises(subprocess.CalledProcessError): anim.save("test.mpeg") @@ -328,9 +334,11 @@ def frames_generator(): yield frame + MAX_FRAMES = 100 anim = animation.FuncAnimation(fig, animate, init_func=init, frames=frames_generator, - cache_frame_data=cache_frame_data) + cache_frame_data=cache_frame_data, + save_count=MAX_FRAMES) writer = NullMovieWriter() anim.save('unused.null', writer=writer) @@ -370,7 +378,9 @@ def animate(i): return return_value with pytest.raises(RuntimeError): - animation.FuncAnimation(fig, animate, blit=True) + animation.FuncAnimation( + fig, animate, blit=True, cache_frame_data=False + ) def test_exhausted_animation(tmpdir): @@ -404,3 +414,95 @@ def update(frame): with pytest.warns(UserWarning, match="exhausted"): anim._start() + + +@check_figures_equal(extensions=["png"]) +def test_animation_frame(tmpdir, fig_test, fig_ref): + # Test the expected image after iterating through a few frames + # we save the animation to get the iteration because we are not + # in an interactive framework. + ax = fig_test.add_subplot() + ax.set_xlim(0, 2 * np.pi) + ax.set_ylim(-1, 1) + x = np.linspace(0, 2 * np.pi, 100) + line, = ax.plot([], []) + + def init(): + line.set_data([], []) + return line, + + def animate(i): + line.set_data(x, np.sin(x + i / 100)) + return line, + + anim = animation.FuncAnimation( + fig_test, animate, init_func=init, frames=5, + blit=True, repeat=False) + with tmpdir.as_cwd(): + anim.save("test.gif") + + # Reference figure without animation + ax = fig_ref.add_subplot() + ax.set_xlim(0, 2 * np.pi) + ax.set_ylim(-1, 1) + + # 5th frame's data + ax.plot(x, np.sin(x + 4 / 100)) + + +@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim']) +def test_save_count_override_warnings_has_length(anim): + + save_count = 5 + frames = list(range(2)) + match_target = ( + f'You passed in an explicit {save_count=} ' + "which is being ignored in favor of " + f"{len(frames)=}." + ) + + with pytest.warns(UserWarning, match=re.escape(match_target)): + anim = animation.FuncAnimation( + **{**anim, 'frames': frames, 'save_count': save_count} + ) + assert anim._save_count == len(frames) + anim._init_draw() + + +@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim']) +def test_save_count_override_warnings_scaler(anim): + save_count = 5 + frames = 7 + match_target = ( + f'You passed in an explicit {save_count=} ' + + "which is being ignored in favor of " + + f"{frames=}." + ) + + with pytest.warns(UserWarning, match=re.escape(match_target)): + anim = animation.FuncAnimation( + **{**anim, 'frames': frames, 'save_count': save_count} + ) + + assert anim._save_count == frames + anim._init_draw() + + +@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim']) +def test_disable_cache_warning(anim): + cache_frame_data = True + frames = iter(range(5)) + match_target = ( + f"{frames=!r} which we can infer the length of, " + "did not pass an explicit *save_count* " + f"and passed {cache_frame_data=}. To avoid a possibly " + "unbounded cache, frame data caching has been disabled. " + "To suppress this warning either pass " + "`cache_frame_data=False` or `save_count=MAX_FRAMES`." + ) + with pytest.warns(UserWarning, match=re.escape(match_target)): + anim = animation.FuncAnimation( + **{**anim, 'cache_frame_data': cache_frame_data, 'frames': frames} + ) + assert anim._cache_frame_data is False + anim._init_draw() diff --git a/lib/matplotlib/tests/test_api.py b/lib/matplotlib/tests/test_api.py index 42e7e72c88b1..28933ff63fa1 100644 --- a/lib/matplotlib/tests/test_api.py +++ b/lib/matplotlib/tests/test_api.py @@ -93,3 +93,8 @@ def test_deprecation_alternative(): def f(): pass assert alternative in f.__doc__ + + +def test_empty_check_in_list(): + with pytest.raises(TypeError, match="No argument to check!"): + _api.check_in_list(["a"]) diff --git a/lib/matplotlib/tests/test_artist.py b/lib/matplotlib/tests/test_artist.py index 74c58d34d991..9bfb4ebce1bd 100644 --- a/lib/matplotlib/tests/test_artist.py +++ b/lib/matplotlib/tests/test_artist.py @@ -5,6 +5,7 @@ import pytest +import matplotlib.colors as mcolors import matplotlib.pyplot as plt import matplotlib.patches as mpatches import matplotlib.lines as mlines @@ -12,6 +13,8 @@ import matplotlib.transforms as mtransforms import matplotlib.collections as mcollections import matplotlib.artist as martist +import matplotlib.backend_bases as mbackend_bases +import matplotlib as mpl from matplotlib.testing.decorators import check_figures_equal, image_comparison @@ -90,7 +93,7 @@ def test_collection_transform_of_none(): transform=mtransforms.IdentityTransform(), alpha=0.5) ax.add_collection(c) - assert isinstance(c._transOffset, mtransforms.IdentityTransform) + assert isinstance(c.get_offset_transform(), mtransforms.IdentityTransform) @image_comparison(["clip_path_clipping"], remove_text=True) @@ -165,20 +168,18 @@ def test_hatching(): rect1 = mpatches.Rectangle((0, 0), 3, 4, hatch='/') ax.add_patch(rect1) - rect2 = mcollections.RegularPolyCollection(4, sizes=[16000], - offsets=[(1.5, 6.5)], - transOffset=ax.transData, - hatch='/') + rect2 = mcollections.RegularPolyCollection( + 4, sizes=[16000], offsets=[(1.5, 6.5)], offset_transform=ax.transData, + hatch='/') ax.add_collection(rect2) # Ensure edge color is not applied to hatching. rect3 = mpatches.Rectangle((4, 0), 3, 4, hatch='/', edgecolor='C1') ax.add_patch(rect3) - rect4 = mcollections.RegularPolyCollection(4, sizes=[16000], - offsets=[(5.5, 6.5)], - transOffset=ax.transData, - hatch='/', edgecolor='C1') + rect4 = mcollections.RegularPolyCollection( + 4, sizes=[16000], offsets=[(5.5, 6.5)], offset_transform=ax.transData, + hatch='/', edgecolor='C1') ax.add_collection(rect4) ax.set_xlim(0, 7) @@ -374,3 +375,190 @@ class MyArtist4(MyArtist3): pass assert MyArtist4.set is MyArtist3.set + + +def test_format_cursor_data_BoundaryNorm(): + """Test if cursor data is correct when using BoundaryNorm.""" + X = np.empty((3, 3)) + X[0, 0] = 0.9 + X[0, 1] = 0.99 + X[0, 2] = 0.999 + X[1, 0] = -1 + X[1, 1] = 0 + X[1, 2] = 1 + X[2, 0] = 0.09 + X[2, 1] = 0.009 + X[2, 2] = 0.0009 + + # map range -1..1 to 0..256 in 0.1 steps + fig, ax = plt.subplots() + fig.suptitle("-1..1 to 0..256 in 0.1") + norm = mcolors.BoundaryNorm(np.linspace(-1, 1, 20), 256) + img = ax.imshow(X, cmap='RdBu_r', norm=norm) + + labels_list = [ + "[0.9]", + "[1.]", + "[1.]", + "[-1.0]", + "[0.0]", + "[1.0]", + "[0.09]", + "[0.009]", + "[0.0009]", + ] + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.1)) + assert img.format_cursor_data(v) == label + + plt.close() + + # map range -1..1 to 0..256 in 0.01 steps + fig, ax = plt.subplots() + fig.suptitle("-1..1 to 0..256 in 0.01") + cmap = mpl.colormaps['RdBu_r'].resampled(200) + norm = mcolors.BoundaryNorm(np.linspace(-1, 1, 200), 200) + img = ax.imshow(X, cmap=cmap, norm=norm) + + labels_list = [ + "[0.90]", + "[0.99]", + "[1.0]", + "[-1.00]", + "[0.00]", + "[1.00]", + "[0.09]", + "[0.009]", + "[0.0009]", + ] + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.01)) + assert img.format_cursor_data(v) == label + + plt.close() + + # map range -1..1 to 0..256 in 0.01 steps + fig, ax = plt.subplots() + fig.suptitle("-1..1 to 0..256 in 0.001") + cmap = mpl.colormaps['RdBu_r'].resampled(2000) + norm = mcolors.BoundaryNorm(np.linspace(-1, 1, 2000), 2000) + img = ax.imshow(X, cmap=cmap, norm=norm) + + labels_list = [ + "[0.900]", + "[0.990]", + "[0.999]", + "[-1.000]", + "[0.000]", + "[1.000]", + "[0.090]", + "[0.009]", + "[0.0009]", + ] + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.001)) + assert img.format_cursor_data(v) == label + + plt.close() + + # different testing data set with + # out of bounds values for 0..1 range + X = np.empty((7, 1)) + X[0] = -1.0 + X[1] = 0.0 + X[2] = 0.1 + X[3] = 0.5 + X[4] = 0.9 + X[5] = 1.0 + X[6] = 2.0 + + labels_list = [ + "[-1.0]", + "[0.0]", + "[0.1]", + "[0.5]", + "[0.9]", + "[1.0]", + "[2.0]", + ] + + fig, ax = plt.subplots() + fig.suptitle("noclip, neither") + norm = mcolors.BoundaryNorm( + np.linspace(0, 1, 4, endpoint=True), 256, clip=False, extend='neither') + img = ax.imshow(X, cmap='RdBu_r', norm=norm) + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.33)) + assert img.format_cursor_data(v) == label + + plt.close() + + fig, ax = plt.subplots() + fig.suptitle("noclip, min") + norm = mcolors.BoundaryNorm( + np.linspace(0, 1, 4, endpoint=True), 256, clip=False, extend='min') + img = ax.imshow(X, cmap='RdBu_r', norm=norm) + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.33)) + assert img.format_cursor_data(v) == label + + plt.close() + + fig, ax = plt.subplots() + fig.suptitle("noclip, max") + norm = mcolors.BoundaryNorm( + np.linspace(0, 1, 4, endpoint=True), 256, clip=False, extend='max') + img = ax.imshow(X, cmap='RdBu_r', norm=norm) + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.33)) + assert img.format_cursor_data(v) == label + + plt.close() + + fig, ax = plt.subplots() + fig.suptitle("noclip, both") + norm = mcolors.BoundaryNorm( + np.linspace(0, 1, 4, endpoint=True), 256, clip=False, extend='both') + img = ax.imshow(X, cmap='RdBu_r', norm=norm) + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.33)) + assert img.format_cursor_data(v) == label + + plt.close() + + fig, ax = plt.subplots() + fig.suptitle("clip, neither") + norm = mcolors.BoundaryNorm( + np.linspace(0, 1, 4, endpoint=True), 256, clip=True, extend='neither') + img = ax.imshow(X, cmap='RdBu_r', norm=norm) + for v, label in zip(X.flat, labels_list): + # label = "[{:-#.{}g}]".format(v, cbook._g_sig_digits(v, 0.33)) + assert img.format_cursor_data(v) == label + + plt.close() + + +def test_auto_no_rasterize(): + class Gen1(martist.Artist): + ... + + assert 'draw' in Gen1.__dict__ + assert Gen1.__dict__['draw'] is Gen1.draw + + class Gen2(Gen1): + ... + + assert 'draw' not in Gen2.__dict__ + assert Gen2.draw is Gen1.draw + + +def test_draw_wraper_forward_input(): + class TestKlass(martist.Artist): + def draw(self, renderer, extra): + return extra + + art = TestKlass() + renderer = mbackend_bases.RendererBase() + + assert 'aardvark' == art.draw(renderer, 'aardvark') + assert 'aardvark' == art.draw(renderer, extra='aardvark') diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index eb4c34382b02..8bf6051b3e8a 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1,6 +1,9 @@ +import contextlib from collections import namedtuple import datetime from decimal import Decimal +from functools import partial +import inspect import io from itertools import product import platform @@ -15,21 +18,27 @@ import matplotlib import matplotlib as mpl -from matplotlib.testing.decorators import ( - image_comparison, check_figures_equal, remove_ticks_and_titles) +from matplotlib import rc_context +from matplotlib._api import MatplotlibDeprecationWarning import matplotlib.colors as mcolors import matplotlib.dates as mdates from matplotlib.figure import Figure +from matplotlib.axes import Axes import matplotlib.font_manager as mfont_manager import matplotlib.markers as mmarkers import matplotlib.patches as mpatches +import matplotlib.path as mpath +from matplotlib.projections.geo import HammerAxes +from matplotlib.projections.polar import PolarAxes import matplotlib.pyplot as plt +import matplotlib.text as mtext import matplotlib.ticker as mticker import matplotlib.transforms as mtransforms +import mpl_toolkits.axisartist as AA from numpy.testing import ( assert_allclose, assert_array_equal, assert_array_almost_equal) -from matplotlib import rc_context -from matplotlib.cbook import MatplotlibDeprecationWarning +from matplotlib.testing.decorators import ( + image_comparison, check_figures_equal, remove_ticks_and_titles) # Note: Some test cases are run twice: once normally and once with labeled data # These two must be defined in the same test function or need to have @@ -37,6 +46,12 @@ # the tests with multiple threads. +@check_figures_equal(extensions=["png"]) +def test_invisible_axes(fig_test, fig_ref): + ax = fig_test.subplots() + ax.set_visible(False) + + def test_get_labels(): fig, ax = plt.subplots() ax.set_xlabel('x label') @@ -45,6 +60,17 @@ def test_get_labels(): assert ax.get_ylabel() == 'y label' +def test_repr(): + fig, ax = plt.subplots() + ax.set_label('label') + ax.set_title('title') + ax.set_xlabel('x') + ax.set_ylabel('y') + assert repr(ax) == ( + "") + + @check_figures_equal() def test_label_loc_vertical(fig_test, fig_ref): ax = fig_test.subplots() @@ -438,8 +464,10 @@ def test_inverted_cla(): assert not ax.xaxis_inverted() assert ax.yaxis_inverted() - # 5. two shared axes. Inverting the master axis should invert the shared - # axes; clearing the master axis should bring axes in shared + for ax in fig.axes: + ax.remove() + # 5. two shared axes. Inverting the leader axis should invert the shared + # axes; clearing the leader axis should bring axes in shared # axes back to normal. ax0 = plt.subplot(211) ax1 = plt.subplot(212, sharey=ax0) @@ -449,7 +477,7 @@ def test_inverted_cla(): ax0.cla() assert not ax1.yaxis_inverted() ax1.cla() - # 6. clearing the nonmaster should not touch limits + # 6. clearing the follower should not touch limits ax0.imshow(img) ax1.plot(x, np.cos(x)) ax1.cla() @@ -459,6 +487,65 @@ def test_inverted_cla(): plt.close(fig) +def test_subclass_clear_cla(): + # Ensure that subclasses of Axes call cla/clear correctly. + # Note, we cannot use mocking here as we want to be sure that the + # superclass fallback does not recurse. + + with pytest.warns(PendingDeprecationWarning, + match='Overriding `Axes.cla`'): + class ClaAxes(Axes): + def cla(self): + nonlocal called + called = True + + with pytest.warns(PendingDeprecationWarning, + match='Overriding `Axes.cla`'): + class ClaSuperAxes(Axes): + def cla(self): + nonlocal called + called = True + super().cla() + + class SubClaAxes(ClaAxes): + pass + + class ClearAxes(Axes): + def clear(self): + nonlocal called + called = True + + class ClearSuperAxes(Axes): + def clear(self): + nonlocal called + called = True + super().clear() + + class SubClearAxes(ClearAxes): + pass + + fig = Figure() + for axes_class in [ClaAxes, ClaSuperAxes, SubClaAxes, + ClearAxes, ClearSuperAxes, SubClearAxes]: + called = False + ax = axes_class(fig, [0, 0, 1, 1]) + # Axes.__init__ has already called clear (which aliases to cla or is in + # the subclass). + assert called + + called = False + ax.cla() + assert called + + +def test_cla_not_redefined_internally(): + for klass in Axes.__subclasses__(): + # Check that cla does not get redefined in our Axes subclasses, except + # for in the above test function. + if 'test_subclass_clear_cla' not in klass.__qualname__: + assert 'cla' not in klass.__dict__ + + @check_figures_equal(extensions=["png"]) def test_minorticks_on_rcParams_both(fig_test, fig_ref): with matplotlib.rc_context({"xtick.minor.visible": True, @@ -542,8 +629,8 @@ def test_use_sticky_edges(): @check_figures_equal(extensions=["png"]) def test_sticky_shared_axes(fig_test, fig_ref): - # Check that sticky edges work whether they are set in an axes that is a - # "master" in a share, or an axes that is a "follower". + # Check that sticky edges work whether they are set in an Axes that is a + # "leader" in a share, or an Axes that is a "follower". Z = np.arange(15).reshape(3, 5) ax0 = fig_test.add_subplot(211) @@ -620,6 +707,16 @@ def test_annotate_default_arrow(): assert ann.arrow_patch is not None +def test_annotate_signature(): + """Check that the signature of Axes.annotate() matches Annotation.""" + fig, ax = plt.subplots() + annotate_params = inspect.signature(ax.annotate).parameters + annotation_params = inspect.signature(mtext.Annotation).parameters + assert list(annotate_params.keys()) == list(annotation_params.keys()) + for p1, p2 in zip(annotate_params.values(), annotation_params.values()): + assert p1 == p2 + + @image_comparison(['fill_units.png'], savefig_kwarg={'dpi': 60}) def test_fill_units(): import matplotlib.testing.jpl_units as units @@ -800,11 +897,15 @@ def test_hexbin_extent(): ax.hexbin("x", "y", extent=[.1, .3, .6, .7], data=data) -@image_comparison(['hexbin_empty.png'], remove_text=True) +@image_comparison(['hexbin_empty.png', 'hexbin_empty.png'], remove_text=True) def test_hexbin_empty(): # From #3886: creating hexbin from empty dataset raises ValueError - ax = plt.gca() + fig, ax = plt.subplots() ax.hexbin([], []) + fig, ax = plt.subplots() + # From #23922: creating hexbin with log scaling from empty + # dataset raises ValueError + ax.hexbin([], [], bins='log') def test_hexbin_pickable(): @@ -836,6 +937,19 @@ def test_hexbin_log(): plt.colorbar(h) +@image_comparison(["hexbin_linear.png"], style="mpl20", remove_text=True) +def test_hexbin_linear(): + # Issue #21165 + np.random.seed(19680801) + n = 100000 + x = np.random.standard_normal(n) + y = 2.0 + 3.0 * x + 4.0 * np.random.standard_normal(n) + + fig, ax = plt.subplots() + ax.hexbin(x, y, gridsize=(10, 5), marginals=True, + reduce_C_function=np.sum) + + def test_hexbin_log_clim(): x, y = np.arange(200).reshape((2, 100)) fig, ax = plt.subplots() @@ -918,7 +1032,9 @@ def test_imshow(): ax.imshow("r", data=data) -@image_comparison(['imshow_clip'], style='mpl20') +@image_comparison( + ['imshow_clip'], style='mpl20', + tol=1.24 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_imshow_clip(): # As originally reported by Gellule Xg # use former defaults to match existing baseline image @@ -950,8 +1066,8 @@ def test_imshow_norm_vminvmax(): a = [[1, 2], [3, 4]] ax = plt.axes() with pytest.raises(ValueError, - match="Passing parameters norm and vmin/vmax " - "simultaneously is not supported."): + match="Passing a Normalize instance simultaneously " + "with vmin/vmax is not supported."): ax.imshow(a, norm=mcolors.Normalize(-10, 10), vmin=0, vmax=5) @@ -1052,19 +1168,13 @@ def test_fill_between_interpolate_nan(): y1 = np.asarray([8, 18, np.nan, 18, 8, 18, 24, 18, 8, 18]) y2 = np.asarray([18, 11, 8, 11, 18, 26, 32, 30, np.nan, np.nan]) - # NumPy <1.19 issues warning 'invalid value encountered in greater_equal' - # for comparisons that include nan. - with np.errstate(invalid='ignore'): - greater2 = y2 >= y1 - greater1 = y1 >= y2 - fig, ax = plt.subplots() ax.plot(x, y1, c='k') ax.plot(x, y2, c='b') - ax.fill_between(x, y1, y2, where=greater2, facecolor="green", + ax.fill_between(x, y1, y2, where=y2 >= y1, facecolor="green", interpolate=True, alpha=0.5) - ax.fill_between(x, y1, y2, where=greater1, facecolor="red", + ax.fill_between(x, y1, y2, where=y1 >= y2, facecolor="red", interpolate=True, alpha=0.5) @@ -1134,6 +1244,27 @@ def test_pcolormesh(): ax3.pcolormesh(Qx, Qz, Z, shading="gouraud") +@image_comparison(['pcolormesh_small'], extensions=["eps"]) +def test_pcolormesh_small(): + n = 3 + x = np.linspace(-1.5, 1.5, n) + y = np.linspace(-1.5, 1.5, n*2) + X, Y = np.meshgrid(x, y) + Qx = np.cos(Y) - np.cos(X) + Qz = np.sin(Y) + np.sin(X) + Qx = (Qx + 1.1) + Z = np.hypot(X, Y) / 5 + Z = (Z - Z.min()) / Z.ptp() + Zm = ma.masked_where(np.abs(Qz) < 0.5 * np.max(Qz), Z) + + fig, (ax1, ax2, ax3) = plt.subplots(1, 3) + ax1.pcolormesh(Qx, Qz, Zm[:-1, :-1], lw=0.5, edgecolors='k') + ax2.pcolormesh(Qx, Qz, Zm[:-1, :-1], lw=2, edgecolors=['b', 'w']) + ax3.pcolormesh(Qx, Qz, Zm, shading="gouraud") + for ax in fig.axes: + ax.set_axis_off() + + @image_comparison(['pcolormesh_alpha'], extensions=["png", "pdf"], remove_text=True) def test_pcolormesh_alpha(): @@ -1149,7 +1280,7 @@ def test_pcolormesh_alpha(): Qy = Y + np.sin(X) Z = np.hypot(X, Y) / 5 Z = (Z - Z.min()) / Z.ptp() - vir = plt.get_cmap("viridis", 16) + vir = mpl.colormaps["viridis"].resampled(16) # make another colormap with varying alpha colors = vir(np.arange(16)) colors[:, 3] = 0.5 + 0.5*np.sin(np.arange(16)) @@ -1169,8 +1300,18 @@ def test_pcolormesh_alpha(): ax4.pcolormesh(Qx, Qy, Z, cmap=cmap, shading='gouraud', zorder=1) -@image_comparison(['pcolormesh_datetime_axis.png'], - remove_text=False, style='mpl20') +@pytest.mark.parametrize("dims,alpha", [(3, 1), (4, 0.5)]) +@check_figures_equal(extensions=["png"]) +def test_pcolormesh_rgba(fig_test, fig_ref, dims, alpha): + ax = fig_test.subplots() + c = np.ones((5, 6, dims), dtype=float) / 2 + ax.pcolormesh(c) + + ax = fig_ref.subplots() + ax.pcolormesh(c[..., 0], cmap="gray", vmin=0, vmax=1, alpha=alpha) + + +@image_comparison(['pcolormesh_datetime_axis.png'], style='mpl20') def test_pcolormesh_datetime_axis(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False @@ -1198,8 +1339,7 @@ def test_pcolormesh_datetime_axis(): label.set_rotation(30) -@image_comparison(['pcolor_datetime_axis.png'], - remove_text=False, style='mpl20') +@image_comparison(['pcolor_datetime_axis.png'], style='mpl20') def test_pcolor_datetime_axis(): fig = plt.figure() fig.subplots_adjust(hspace=0.4, top=0.98, bottom=.15) @@ -1299,6 +1439,14 @@ def test_pcolorflaterror(): ax.pcolormesh(x, y, Z, shading='flat') +def test_samesizepcolorflaterror(): + fig, ax = plt.subplots() + x, y = np.meshgrid(np.arange(5), np.arange(3)) + Z = x + y + with pytest.raises(TypeError, match=r".*one smaller than X"): + ax.pcolormesh(x, y, Z, shading='flat') + + @pytest.mark.parametrize('snap', [False, True]) @check_figures_equal(extensions=["png"]) def test_pcolorauto(fig_test, fig_ref, snap): @@ -1373,7 +1521,7 @@ def test_arc_ellipse(): [np.cos(rtheta), -np.sin(rtheta)], [np.sin(rtheta), np.cos(rtheta)]]) - x, y = np.dot(R, np.array([x, y])) + x, y = np.dot(R, [x, y]) x += xcenter y += ycenter @@ -1538,6 +1686,32 @@ def test_markevery_polar(): plt.plot(theta, r, 'o', ls='-', ms=4, markevery=case) +@image_comparison(['markevery_linear_scales_nans'], remove_text=True) +def test_markevery_linear_scales_nans(): + cases = [None, + 8, + (30, 8), + [16, 24, 30], [0, -1], + slice(100, 200, 3), + 0.1, 0.3, 1.5, + (0.0, 0.1), (0.45, 0.1)] + + cols = 3 + gs = matplotlib.gridspec.GridSpec(len(cases) // cols + 1, cols) + + delta = 0.11 + x = np.linspace(0, 10 - 2 * delta, 200) + delta + y = np.sin(x) + 1.0 + delta + y[:10] = y[-20:] = y[50:70] = np.nan + + for i, case in enumerate(cases): + row = (i // cols) + col = i % cols + plt.subplot(gs[row, col]) + plt.title('markevery=%s' % str(case)) + plt.plot(x, y, 'o', ls='-', ms=4, markevery=case) + + @image_comparison(['marker_edges'], remove_text=True) def test_marker_edges(): x = np.linspace(0, 1, 10) @@ -1702,6 +1876,23 @@ def test_boxplot_dates_pandas(pd): plt.boxplot(data, positions=years) +def test_boxplot_capwidths(): + data = np.random.rand(5, 3) + fig, axs = plt.subplots(9) + + axs[0].boxplot(data, capwidths=[0.3, 0.2, 0.1], widths=[0.1, 0.2, 0.3]) + axs[1].boxplot(data, capwidths=[0.3, 0.2, 0.1], widths=0.2) + axs[2].boxplot(data, capwidths=[0.3, 0.2, 0.1]) + + axs[3].boxplot(data, capwidths=0.5, widths=[0.1, 0.2, 0.3]) + axs[4].boxplot(data, capwidths=0.5, widths=0.2) + axs[5].boxplot(data, capwidths=0.5) + + axs[6].boxplot(data, widths=[0.1, 0.2, 0.3]) + axs[7].boxplot(data, widths=0.2) + axs[8].boxplot(data) + + def test_pcolor_regression(pd): from pandas.plotting import ( register_matplotlib_converters, @@ -1774,8 +1965,37 @@ def test_bar_hatches(fig_test, fig_ref): ax_test.bar(x, y, hatch=hatches) +@pytest.mark.parametrize( + ("x", "width", "label", "expected_labels", "container_label"), + [ + ("x", 1, "x", ["_nolegend_"], "x"), + (["a", "b", "c"], [10, 20, 15], ["A", "B", "C"], + ["A", "B", "C"], "_nolegend_"), + (["a", "b", "c"], [10, 20, 15], ["R", "Y", "_nolegend_"], + ["R", "Y", "_nolegend_"], "_nolegend_"), + (["a", "b", "c"], [10, 20, 15], "bars", + ["_nolegend_", "_nolegend_", "_nolegend_"], "bars"), + ] +) +def test_bar_labels(x, width, label, expected_labels, container_label): + _, ax = plt.subplots() + bar_container = ax.bar(x, width, label=label) + bar_labels = [bar.get_label() for bar in bar_container] + assert expected_labels == bar_labels + assert bar_container.get_label() == container_label + + +def test_bar_labels_length(): + _, ax = plt.subplots() + with pytest.raises(ValueError): + ax.bar(["x", "y"], [1, 2], label=["X", "Y", "Z"]) + _, ax = plt.subplots() + with pytest.raises(ValueError): + ax.bar(["x", "y"], [1, 2], label=["X"]) + + def test_pandas_minimal_plot(pd): - # smoke test that series and index objcets do not warn + # smoke test that series and index objects do not warn for x in [pd.Series([1, 2], dtype="float64"), pd.Series([1, 2], dtype="Float64")]: plt.plot(x, x) @@ -1828,6 +2048,21 @@ def test_hist_bar_empty(): ax.hist([], histtype='bar') +def test_hist_float16(): + np.random.seed(19680801) + values = np.clip( + np.random.normal(0.5, 0.3, size=1000), 0, 1).astype(np.float16) + h = plt.hist(values, bins=3, alpha=0.5) + bc = h[2] + # Check that there are no overlapping rectangles + for r in range(1, len(bc)): + rleft = bc[r-1].get_corners() + rright = bc[r].get_corners() + # right hand position of left rectangle <= + # left hand position of right rectangle + assert rleft[1][0] <= rright[0][0] + + @image_comparison(['hist_step_empty.png'], remove_text=True) def test_hist_step_empty(): # From #3886: creating hist from empty dataset raises ValueError @@ -1884,7 +2119,7 @@ def test_hist_datetime_datasets(): @pytest.mark.parametrize("bins_preprocess", [mpl.dates.date2num, lambda bins: bins, - lambda bins: np.asarray(bins).astype('datetime64')], + lambda bins: np.asarray(bins, 'datetime64')], ids=['date2num', 'datetime.datetime', 'np.datetime64']) def test_hist_datetime_datasets_bins(bins_preprocess): @@ -2123,17 +2358,19 @@ def test_contour_hatching(): x, y, z = contour_dat() fig, ax = plt.subplots() ax.contourf(x, y, z, 7, hatches=['/', '\\', '//', '-'], - cmap=plt.get_cmap('gray'), + cmap=mpl.colormaps['gray'], extend='both', alpha=0.5) -@image_comparison(['contour_colorbar'], style='mpl20') +@image_comparison( + ['contour_colorbar'], style='mpl20', + tol=0.02 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_contour_colorbar(): x, y, z = contour_dat() fig, ax = plt.subplots() cs = ax.contourf(x, y, z, levels=np.arange(-1.8, 1.801, 0.2), - cmap=plt.get_cmap('RdBu'), + cmap=mpl.colormaps['RdBu'], vmin=-0.6, vmax=0.6, extend='both') @@ -2256,6 +2493,25 @@ def test_scatter_color(self): with pytest.raises(ValueError): plt.scatter([1, 2, 3], [1, 2, 3], color=[1, 2, 3]) + @pytest.mark.parametrize('kwargs', + [ + {'cmap': 'gray'}, + {'norm': mcolors.Normalize()}, + {'vmin': 0}, + {'vmax': 0} + ]) + def test_scatter_color_warning(self, kwargs): + warn_match = "No data for colormapping provided " + # Warn for cases where 'cmap', 'norm', 'vmin', 'vmax' + # kwargs are being overridden + with pytest.warns(Warning, match=warn_match): + plt.scatter([], [], **kwargs) + with pytest.warns(Warning, match=warn_match): + plt.scatter([1, 2], [3, 4], c=[], **kwargs) + # Do not warn for cases where 'c' matches 'x' and 'y' + plt.scatter([], [], c=[], **kwargs) + plt.scatter([1, 2], [3, 4], c=[4, 5], **kwargs) + def test_scatter_unfilled(self): coll = plt.scatter([0, 1, 2], [1, 3, 2], c=['0.1', '0.3', '0.5'], marker=mmarkers.MarkerStyle('o', fillstyle='none'), @@ -2287,7 +2543,7 @@ def test_scatter_size_arg_size(self): plt.scatter(x, x, 'foo') def test_scatter_edgecolor_RGB(self): - # Github issue 19066 + # GitHub issue 19066 coll = plt.scatter([1, 2, 3], [1, np.nan, np.nan], edgecolor=(1, 0, 0)) assert mcolors.same_color(coll.get_edgecolor(), (1, 0, 0)) @@ -2298,7 +2554,7 @@ def test_scatter_edgecolor_RGB(self): @check_figures_equal(extensions=["png"]) def test_scatter_invalid_color(self, fig_test, fig_ref): ax = fig_test.subplots() - cmap = plt.get_cmap("viridis", 16) + cmap = mpl.colormaps["viridis"].resampled(16) cmap.set_bad("k", 1) # Set a nonuniform size to prevent the last call to `scatter` (plotting # the invalid points separately in fig_ref) from using the marker @@ -2307,15 +2563,15 @@ def test_scatter_invalid_color(self, fig_test, fig_ref): c=[1, np.nan, 2, np.nan], s=[1, 2, 3, 4], cmap=cmap, plotnonfinite=True) ax = fig_ref.subplots() - cmap = plt.get_cmap("viridis", 16) + cmap = mpl.colormaps["viridis"].resampled(16) ax.scatter([0, 2], [0, 2], c=[1, 2], s=[1, 3], cmap=cmap) ax.scatter([1, 3], [1, 3], s=[2, 4], color="k") @check_figures_equal(extensions=["png"]) def test_scatter_no_invalid_color(self, fig_test, fig_ref): - # With plotninfinite=False we plot only 2 points. + # With plotnonfinite=False we plot only 2 points. ax = fig_test.subplots() - cmap = plt.get_cmap("viridis", 16) + cmap = mpl.colormaps["viridis"].resampled(16) cmap.set_bad("k", 1) ax.scatter(range(4), range(4), c=[1, np.nan, 2, np.nan], s=[1, 2, 3, 4], @@ -2328,8 +2584,8 @@ def test_scatter_norm_vminvmax(self): x = [1, 2, 3] ax = plt.axes() with pytest.raises(ValueError, - match="Passing parameters norm and vmin/vmax " - "simultaneously is not supported."): + match="Passing a Normalize instance simultaneously " + "with vmin/vmax is not supported."): ax.scatter(x, x, c=x, norm=mcolors.Normalize(-10, 10), vmin=0, vmax=5) @@ -2406,15 +2662,17 @@ def get_next_color(): "conversion": "^'c' argument must be a color", # bad vals } - if re_key is None: + assert_context = ( + pytest.raises(ValueError, match=REGEXP[re_key]) + if re_key is not None + else pytest.warns(match="argument looks like a single numeric RGB") + if isinstance(c_case, list) and len(c_case) == 3 + else contextlib.nullcontext() + ) + with assert_context: mpl.axes.Axes._parse_scatter_color_args( c=c_case, edgecolors="black", kwargs={}, xsize=xsize, get_next_color_func=get_next_color) - else: - with pytest.raises(ValueError, match=REGEXP[re_key]): - mpl.axes.Axes._parse_scatter_color_args( - c=c_case, edgecolors="black", kwargs={}, xsize=xsize, - get_next_color_func=get_next_color) @mpl.style.context('default') @check_figures_equal(extensions=["png"]) @@ -2518,8 +2776,6 @@ def get_next_color(): def test_as_mpl_axes_api(): # tests the _as_mpl_axes api - from matplotlib.projections.polar import PolarAxes - class Polar: def __init__(self): self.theta_offset = 0 @@ -2531,47 +2787,15 @@ def _as_mpl_axes(self): prj = Polar() prj2 = Polar() prj2.theta_offset = np.pi - prj3 = Polar() # testing axes creation with plt.axes ax = plt.axes([0, 0, 1, 1], projection=prj) assert type(ax) == PolarAxes - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax_via_gca = plt.gca(projection=prj) - assert ax_via_gca is ax - plt.close() - - # testing axes creation with gca - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax = plt.gca(projection=prj) - assert type(ax) == mpl.axes._subplots.subplot_class_factory(PolarAxes) - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax_via_gca = plt.gca(projection=prj) - assert ax_via_gca is ax - # try getting the axes given a different polar projection - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax_via_gca = plt.gca(projection=prj2) - assert ax_via_gca is ax - assert ax.get_theta_offset() == 0 - # try getting the axes given an == (not is) polar projection - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax_via_gca = plt.gca(projection=prj3) - assert ax_via_gca is ax plt.close() # testing axes creation with subplot ax = plt.subplot(121, projection=prj) - assert type(ax) == mpl.axes._subplots.subplot_class_factory(PolarAxes) + assert type(ax) == PolarAxes plt.close() @@ -2586,13 +2810,48 @@ def test_pyplot_axes(): plt.close(fig2) -@image_comparison(['log_scales']) def test_log_scales(): fig, ax = plt.subplots() ax.plot(np.log(np.linspace(0.1, 100))) ax.set_yscale('log', base=5.5) ax.invert_yaxis() ax.set_xscale('log', base=9.0) + xticks, yticks = [ + [(t.get_loc(), t.label1.get_text()) for t in axis._update_ticks()] + for axis in [ax.xaxis, ax.yaxis] + ] + assert xticks == [ + (1.0, '$\\mathdefault{9^{0}}$'), + (9.0, '$\\mathdefault{9^{1}}$'), + (81.0, '$\\mathdefault{9^{2}}$'), + (2.0, ''), + (3.0, ''), + (4.0, ''), + (5.0, ''), + (6.0, ''), + (7.0, ''), + (8.0, ''), + (18.0, ''), + (27.0, ''), + (36.0, ''), + (45.0, ''), + (54.0, ''), + (63.0, ''), + (72.0, ''), + ] + assert yticks == [ + (0.18181818181818182, '$\\mathdefault{5.5^{-1}}$'), + (1.0, '$\\mathdefault{5.5^{0}}$'), + (5.5, '$\\mathdefault{5.5^{1}}$'), + (0.36363636363636365, ''), + (0.5454545454545454, ''), + (0.7272727272727273, ''), + (0.9090909090909092, ''), + (2.0, ''), + (3.0, ''), + (4.0, ''), + (5.0, ''), + ] def test_log_scales_no_data(): @@ -2605,10 +2864,10 @@ def test_log_scales_no_data(): def test_log_scales_invalid(): fig, ax = plt.subplots() ax.set_xscale('log') - with pytest.warns(UserWarning, match='Attempted to set non-positive'): + with pytest.warns(UserWarning, match='Attempt to set non-positive'): ax.set_xlim(-1, 10) ax.set_yscale('log') - with pytest.warns(UserWarning, match='Attempted to set non-positive'): + with pytest.warns(UserWarning, match='Attempt to set non-positive'): ax.set_ylim(-1, 10) @@ -2624,10 +2883,11 @@ def test_stackplot(): ax.set_xlim((0, 10)) ax.set_ylim((0, 70)) - # Reuse testcase from above for a labeled data test + # Reuse testcase from above for a test with labeled data and with colours + # from the Axes property cycle. data = {"x": x, "y1": y1, "y2": y2, "y3": y3} fig, ax = plt.subplots() - ax.stackplot("x", "y1", "y2", "y3", data=data) + ax.stackplot("x", "y1", "y2", "y3", data=data, colors=["C0", "C1", "C2"]) ax.set_xlim((0, 10)) ax.set_ylim((0, 70)) @@ -2892,6 +3152,25 @@ def test_bxp_bad_positions(): _bxp_test_helper(bxp_kwargs=dict(positions=[2, 3])) +@image_comparison(['bxp_custom_capwidths.png'], + savefig_kwarg={'dpi': 40}, + style='default') +def test_bxp_custom_capwidths(): + _bxp_test_helper(bxp_kwargs=dict(capwidths=[0.0, 0.1, 0.5, 1.0])) + + +@image_comparison(['bxp_custom_capwidth.png'], + savefig_kwarg={'dpi': 40}, + style='default') +def test_bxp_custom_capwidth(): + _bxp_test_helper(bxp_kwargs=dict(capwidths=0.6)) + + +def test_bxp_bad_capwidths(): + with pytest.raises(ValueError): + _bxp_test_helper(bxp_kwargs=dict(capwidths=[1])) + + @image_comparison(['boxplot', 'boxplot'], tol=1.28, style='default') def test_boxplot(): # Randomness used for bootstrapping. @@ -2911,6 +3190,17 @@ def test_boxplot(): ax.set_ylim((-30, 30)) +@image_comparison(['boxplot_custom_capwidths.png'], + savefig_kwarg={'dpi': 40}, style='default') +def test_boxplot_custom_capwidths(): + + x = np.linspace(-7, 7, 140) + x = np.hstack([-25, x, 25]) + fig, ax = plt.subplots() + + ax.boxplot([x, x], notch=1, capwidths=[0.01, 0.2]) + + @image_comparison(['boxplot_sym2.png'], remove_text=True, style='default') def test_boxplot_sym2(): # Randomness used for bootstrapping. @@ -3410,6 +3700,8 @@ def test_errorbar(): ax.errorbar(x, y, yerr=[yerr_lower, 2*yerr], xerr=xerr, fmt='o', ecolor='g', capthick=2) ax.set_title('Mixed sym., log y') + # Force limits due to floating point slop potentially expanding the range + ax.set_ylim(1e-2, 1e1) fig.suptitle('Variable errorbars') @@ -3421,6 +3713,41 @@ def test_errorbar(): ax.set_title("Simplest errorbars, 0.2 in x, 0.4 in y") +@image_comparison(['mixed_errorbar_polar_caps'], extensions=['png'], + remove_text=True) +def test_mixed_errorbar_polar_caps(): + """ + Mix several polar errorbar use cases in a single test figure. + + It is advisable to position individual points off the grid. If there are + problems with reproducibility of this test, consider removing grid. + """ + fig = plt.figure() + ax = plt.subplot(111, projection='polar') + + # symmetric errorbars + th_sym = [1, 2, 3] + r_sym = [0.9]*3 + ax.errorbar(th_sym, r_sym, xerr=0.35, yerr=0.2, fmt="o") + + # long errorbars + th_long = [np.pi/2 + .1, np.pi + .1] + r_long = [1.8, 2.2] + ax.errorbar(th_long, r_long, xerr=0.8 * np.pi, yerr=0.15, fmt="o") + + # asymmetric errorbars + th_asym = [4*np.pi/3 + .1, 5*np.pi/3 + .1, 2*np.pi-0.1] + r_asym = [1.1]*3 + xerr = [[.3, .3, .2], [.2, .3, .3]] + yerr = [[.35, .5, .5], [.5, .35, .5]] + ax.errorbar(th_asym, r_asym, xerr=xerr, yerr=yerr, fmt="o") + + # overlapping errorbar + th_over = [2.1] + r_over = [3.1] + ax.errorbar(th_over, r_over, xerr=10, yerr=.2, fmt="o") + + def test_errorbar_colorcycle(): f, ax = plt.subplots() @@ -3516,7 +3843,7 @@ def test_errorbar_limits(): ax.set_title('Errorbar upper and lower limits') -def test_errobar_nonefmt(): +def test_errorbar_nonefmt(): # Check that passing 'none' as a format still plots errorbars x = np.arange(5) y = np.arange(5) @@ -3582,6 +3909,27 @@ def test_errorbar_every_invalid(): ax.errorbar(x, y, yerr, errorevery='foobar') +def test_xerr_yerr_not_negative(): + ax = plt.figure().subplots() + + with pytest.raises(ValueError, + match="'xerr' must not contain negative values"): + ax.errorbar(x=[0], y=[0], xerr=[[-0.5], [1]], yerr=[[-0.5], [1]]) + with pytest.raises(ValueError, + match="'xerr' must not contain negative values"): + ax.errorbar(x=[0], y=[0], xerr=[[-0.5], [1]]) + with pytest.raises(ValueError, + match="'yerr' must not contain negative values"): + ax.errorbar(x=[0], y=[0], yerr=[[-0.5], [1]]) + with pytest.raises(ValueError, + match="'yerr' must not contain negative values"): + x = np.arange(5) + y = [datetime.datetime(2021, 9, i * 2 + 1) for i in x] + ax.errorbar(x=x, + y=y, + yerr=datetime.timedelta(days=-10)) + + @check_figures_equal() def test_errorbar_every(fig_test, fig_ref): x = np.linspace(0, 1, 15) @@ -3633,6 +3981,18 @@ def test_errorbar_linewidth_type(elinewidth): plt.errorbar([1, 2, 3], [1, 2, 3], yerr=[1, 2, 3], elinewidth=elinewidth) +@check_figures_equal(extensions=["png"]) +def test_errorbar_nan(fig_test, fig_ref): + ax = fig_test.add_subplot() + xs = range(5) + ys = np.array([1, 2, np.nan, np.nan, 3]) + es = np.array([4, 5, np.nan, np.nan, 6]) + ax.errorbar(xs, ys, es) + ax = fig_ref.add_subplot() + ax.errorbar([0, 1], [1, 2], [4, 5]) + ax.errorbar([4], [3], [6], fmt="C0") + + @image_comparison(['hist_stacked_stepfilled', 'hist_stacked_stepfilled']) def test_hist_stacked_stepfilled(): # make some data @@ -3696,23 +4056,102 @@ def test_stem(use_line_collection): fig, ax = plt.subplots() # Label is a single space to force a legend to be drawn, but to avoid any # text being drawn - ax.stem(x, np.cos(x), - linefmt='C2-.', markerfmt='k+', basefmt='C1-.', label=' ', - use_line_collection=use_line_collection) + if use_line_collection: + ax.stem(x, np.cos(x), + linefmt='C2-.', markerfmt='k+', basefmt='C1-.', label=' ') + else: + with pytest.warns(MatplotlibDeprecationWarning, match='deprecated'): + ax.stem(x, np.cos(x), + linefmt='C2-.', markerfmt='k+', basefmt='C1-.', label=' ', + use_line_collection=False) ax.legend() def test_stem_args(): + """Test that stem() correctly identifies x and y values.""" + def _assert_equal(stem_container, expected): + x, y = map(list, stem_container.markerline.get_data()) + assert x == expected[0] + assert y == expected[1] + fig, ax = plt.subplots() - x = list(range(10)) - y = list(range(10)) + x = [1, 3, 5] + y = [9, 8, 7] # Test the call signatures - ax.stem(y) - ax.stem(x, y) - ax.stem(x, y, linefmt='r--') - ax.stem(x, y, linefmt='r--', basefmt='b--') + _assert_equal(ax.stem(y), expected=([0, 1, 2], y)) + _assert_equal(ax.stem(x, y), expected=(x, y)) + _assert_equal(ax.stem(x, y, linefmt='r--'), expected=(x, y)) + _assert_equal(ax.stem(x, y, 'r--'), expected=(x, y)) + _assert_equal(ax.stem(x, y, linefmt='r--', basefmt='b--'), expected=(x, y)) + _assert_equal(ax.stem(y, linefmt='r--'), expected=([0, 1, 2], y)) + _assert_equal(ax.stem(y, 'r--'), expected=([0, 1, 2], y)) + + +def test_stem_markerfmt(): + """Test that stem(..., markerfmt=...) produces the intended markers.""" + def _assert_equal(stem_container, linecolor=None, markercolor=None, + marker=None): + """ + Check that the given StemContainer has the properties listed as + keyword-arguments. + """ + if linecolor is not None: + assert mcolors.same_color( + stem_container.stemlines.get_color(), + linecolor) + if markercolor is not None: + assert mcolors.same_color( + stem_container.markerline.get_color(), + markercolor) + if marker is not None: + assert stem_container.markerline.get_marker() == marker + assert stem_container.markerline.get_linestyle() == 'None' + + fig, ax = plt.subplots() + + x = [1, 3, 5] + y = [9, 8, 7] + + # no linefmt + _assert_equal(ax.stem(x, y), markercolor='C0', marker='o') + _assert_equal(ax.stem(x, y, markerfmt='x'), markercolor='C0', marker='x') + _assert_equal(ax.stem(x, y, markerfmt='rx'), markercolor='r', marker='x') + + # positional linefmt + _assert_equal( + ax.stem(x, y, 'r'), # marker color follows linefmt if not given + linecolor='r', markercolor='r', marker='o') + _assert_equal( + ax.stem(x, y, 'rx'), # the marker is currently not taken from linefmt + linecolor='r', markercolor='r', marker='o') + _assert_equal( + ax.stem(x, y, 'r', markerfmt='x'), # only marker type specified + linecolor='r', markercolor='r', marker='x') + _assert_equal( + ax.stem(x, y, 'r', markerfmt='g'), # only marker color specified + linecolor='r', markercolor='g', marker='o') + _assert_equal( + ax.stem(x, y, 'r', markerfmt='gx'), # marker type and color specified + linecolor='r', markercolor='g', marker='x') + _assert_equal( + ax.stem(x, y, 'r', markerfmt=' '), # markerfmt=' ' for no marker + linecolor='r', markercolor='r', marker='None') + _assert_equal( + ax.stem(x, y, 'r', markerfmt=''), # markerfmt='' for no marker + linecolor='r', markercolor='r', marker='None') + + # with linefmt kwarg + _assert_equal( + ax.stem(x, y, linefmt='r'), + linecolor='r', markercolor='r', marker='o') + _assert_equal( + ax.stem(x, y, linefmt='r', markerfmt='x'), + linecolor='r', markercolor='r', marker='x') + _assert_equal( + ax.stem(x, y, linefmt='r', markerfmt='gx'), + linecolor='r', markercolor='g', marker='x') def test_stem_dates(): @@ -3730,9 +4169,16 @@ def test_stem_orientation(use_line_collection): x = np.linspace(0.1, 2*np.pi, 50) fig, ax = plt.subplots() - ax.stem(x, np.cos(x), - linefmt='C2-.', markerfmt='kx', basefmt='C1-.', - use_line_collection=use_line_collection, orientation='horizontal') + if use_line_collection: + ax.stem(x, np.cos(x), + linefmt='C2-.', markerfmt='kx', basefmt='C1-.', + orientation='horizontal') + else: + with pytest.warns(MatplotlibDeprecationWarning, match='deprecated'): + ax.stem(x, np.cos(x), + linefmt='C2-.', markerfmt='kx', basefmt='C1-.', + use_line_collection=False, + orientation='horizontal') @image_comparison(['hist_stacked_stepfilled_alpha']) @@ -3915,7 +4361,7 @@ def test_hist_stacked_bar(): fig, ax = plt.subplots() ax.hist(d, bins=10, histtype='barstacked', align='mid', color=colors, label=labels) - ax.legend(loc='upper right', bbox_to_anchor=(1.0, 1.0), ncol=1) + ax.legend(loc='upper right', bbox_to_anchor=(1.0, 1.0), ncols=1) def test_hist_barstacked_bottom_unchanged(): @@ -4139,6 +4585,26 @@ def test_eventplot_colors(colors): assert_allclose(coll.get_color(), color) +def test_eventplot_alpha(): + fig, ax = plt.subplots() + + # one alpha for all + collections = ax.eventplot([[0, 2, 4], [1, 3, 5, 7]], alpha=0.7) + assert collections[0].get_alpha() == 0.7 + assert collections[1].get_alpha() == 0.7 + + # one alpha per collection + collections = ax.eventplot([[0, 2, 4], [1, 3, 5, 7]], alpha=[0.5, 0.7]) + assert collections[0].get_alpha() == 0.5 + assert collections[1].get_alpha() == 0.7 + + with pytest.raises(ValueError, match="alpha and positions are unequal"): + ax.eventplot([[0, 2, 4], [1, 3, 5, 7]], alpha=[0.5, 0.7, 0.9]) + + with pytest.raises(ValueError, match="alpha and positions are unequal"): + ax.eventplot([0, 2, 4], alpha=[0.5, 0.7]) + + @image_comparison(['test_eventplot_problem_kwargs.png'], remove_text=True) def test_eventplot_problem_kwargs(recwarn): """ @@ -4202,8 +4668,15 @@ def test_eventplot_units_list(fig_test, fig_ref): @image_comparison(['marker_styles.png'], remove_text=True) def test_marker_styles(): fig, ax = plt.subplots() - for y, marker in enumerate(sorted(matplotlib.markers.MarkerStyle.markers, - key=lambda x: str(type(x))+str(x))): + # Since generation of the test image, None was removed but 'none' was + # added. By moving 'none' to the front (=former sorted place of None) + # we can avoid regenerating the test image. This can be removed if the + # test image has to be regenerated for other reasons. + markers = sorted(matplotlib.markers.MarkerStyle.markers, + key=lambda x: str(type(x))+str(x)) + markers.remove('none') + markers = ['none', *markers] + for y, marker in enumerate(markers): ax.plot((y % 2)*5 + np.arange(10)*10, np.ones(10)*10*y, linestyle='', marker=marker, markersize=10+y/5, label=marker) @@ -4993,12 +5466,20 @@ def test_shared_with_aspect_3(): assert round(expected, 4) == round(ax.get_aspect(), 4) +def test_shared_aspect_error(): + fig, axes = plt.subplots(1, 2, sharex=True, sharey=True) + axes[0].axis("equal") + with pytest.raises(RuntimeError, match=r"set_aspect\(..., adjustable="): + fig.draw_without_rendering() + + @pytest.mark.parametrize('err, args, kwargs, match', ((TypeError, (1, 2), {}, - r"axis\(\) takes 0 or 1 positional arguments but 2" - " were given"), + r"axis\(\) takes from 0 to 1 positional arguments " + "but 2 were given"), (ValueError, ('foo', ), {}, - "Unrecognized string foo to axis; try on or off"), + "Unrecognized string 'foo' to axis; try 'on' or " + "'off'"), (TypeError, ([1, 2], ), {}, "the first argument to axis*"), (TypeError, tuple(), {'foo': None}, @@ -5010,6 +5491,32 @@ def test_axis_errors(err, args, kwargs, match): plt.axis(*args, **kwargs) +def test_axis_method_errors(): + ax = plt.gca() + with pytest.raises(ValueError, match="unknown value for which: 'foo'"): + ax.get_xaxis_transform('foo') + with pytest.raises(ValueError, match="unknown value for which: 'foo'"): + ax.get_yaxis_transform('foo') + with pytest.raises(TypeError, match="Cannot supply both positional and"): + ax.set_prop_cycle('foo', label='bar') + with pytest.raises(ValueError, match="argument must be among"): + ax.set_anchor('foo') + with pytest.raises(ValueError, match="scilimits must be a sequence"): + ax.ticklabel_format(scilimits=1) + with pytest.raises(TypeError, match="Specifying 'loc' is disallowed"): + ax.set_xlabel('foo', loc='left', x=1) + with pytest.raises(TypeError, match="Specifying 'loc' is disallowed"): + ax.set_ylabel('foo', loc='top', y=1) + with pytest.raises(TypeError, match="Cannot pass both 'left'"): + ax.set_xlim(left=0, xmin=1) + with pytest.raises(TypeError, match="Cannot pass both 'right'"): + ax.set_xlim(right=0, xmax=1) + with pytest.raises(TypeError, match="Cannot pass both 'bottom'"): + ax.set_ylim(bottom=0, ymin=1) + with pytest.raises(TypeError, match="Cannot pass both 'top'"): + ax.set_ylim(top=0, ymax=1) + + @pytest.mark.parametrize('twin', ('x', 'y')) def test_twin_with_aspect(twin): fig, ax = plt.subplots() @@ -5069,7 +5576,7 @@ def test_pie_default(): @image_comparison(['pie_linewidth_0', 'pie_linewidth_0', 'pie_linewidth_0'], - extensions=['png']) + extensions=['png'], style='mpl20') def test_pie_linewidth_0(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -5101,7 +5608,7 @@ def test_pie_linewidth_0(): plt.axis('equal') -@image_comparison(['pie_center_radius.png']) +@image_comparison(['pie_center_radius.png'], style='mpl20') def test_pie_center_radius(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -5121,7 +5628,7 @@ def test_pie_center_radius(): plt.axis('equal') -@image_comparison(['pie_linewidth_2.png']) +@image_comparison(['pie_linewidth_2.png'], style='mpl20') def test_pie_linewidth_2(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -5136,7 +5643,7 @@ def test_pie_linewidth_2(): plt.axis('equal') -@image_comparison(['pie_ccw_true.png']) +@image_comparison(['pie_ccw_true.png'], style='mpl20') def test_pie_ccw_true(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -5151,7 +5658,7 @@ def test_pie_ccw_true(): plt.axis('equal') -@image_comparison(['pie_frame_grid.png']) +@image_comparison(['pie_frame_grid.png'], style='mpl20') def test_pie_frame_grid(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -5178,7 +5685,7 @@ def test_pie_frame_grid(): plt.axis('equal') -@image_comparison(['pie_rotatelabels_true.png']) +@image_comparison(['pie_rotatelabels_true.png'], style='mpl20') def test_pie_rotatelabels_true(): # The slices will be ordered and plotted counter-clockwise. labels = 'Hogwarts', 'Frogs', 'Dogs', 'Logs' @@ -5245,6 +5752,24 @@ def test_normalize_kwarg_pie(): assert abs(t2[0][-1].theta2 - 360.) > 1e-3 +@check_figures_equal() +def test_pie_hatch_single(fig_test, fig_ref): + x = [0.3, 0.3, 0.1] + hatch = '+' + fig_test.subplots().pie(x, hatch=hatch) + wedges, _ = fig_ref.subplots().pie(x) + [w.set_hatch(hatch) for w in wedges] + + +@check_figures_equal() +def test_pie_hatch_multi(fig_test, fig_ref): + x = [0.3, 0.3, 0.1] + hatch = ['/', '+', '.'] + fig_test.subplots().pie(x, hatch=hatch) + wedges, _ = fig_ref.subplots().pie(x) + [w.set_hatch(hp) for w, hp in zip(wedges, hatch)] + + @image_comparison(['set_get_ticklabels.png']) def test_set_get_ticklabels(): # test issue 2246 @@ -5271,6 +5796,17 @@ def test_set_get_ticklabels(): ax[1].set_yticklabels(ax[0].get_yticklabels()) +def test_set_ticks_kwargs_raise_error_without_labels(): + """ + When labels=None and any kwarg is passed, axis.set_ticks() raises a + ValueError. + """ + fig, ax = plt.subplots() + ticks = [1, 2, 3] + with pytest.raises(ValueError): + ax.xaxis.set_ticks(ticks, alpha=0.5) + + @check_figures_equal(extensions=["png"]) def test_set_ticks_with_labels(fig_test, fig_ref): """ @@ -5293,6 +5829,20 @@ def test_set_ticks_with_labels(fig_test, fig_ref): ax.set_yticks([2, 4], ['A', 'B'], minor=True) +def test_xticks_bad_args(): + ax = plt.figure().add_subplot() + with pytest.raises(TypeError, match='must be a sequence'): + ax.set_xticks([2, 9], 3.1) + with pytest.raises(ValueError, match='must be 1D'): + plt.xticks(np.arange(4).reshape((-1, 1))) + with pytest.raises(ValueError, match='must be 1D'): + plt.xticks(np.arange(4).reshape((1, -1))) + with pytest.raises(ValueError, match='must be 1D'): + plt.xticks(np.arange(4).reshape((-1, 1)), labels=range(4)) + with pytest.raises(ValueError, match='must be 1D'): + plt.xticks(np.arange(4).reshape((1, -1)), labels=range(4)) + + def test_subsampled_ticklabels(): # test issue 11937 fig, ax = plt.subplots() @@ -5403,21 +5953,17 @@ def test_set_margin_updates_limits(): assert ax.get_xlim() == (1, 2) -@pytest.mark.parametrize('err, args, kwargs, match', - ((ValueError, (-1,), {}, - 'margin must be greater than -0.5'), - (ValueError, (1, -1), {}, - 'margin must be greater than -0.5'), - (ValueError, tuple(), {'x': -1}, - 'margin must be greater than -0.5'), - (ValueError, tuple(), {'y': -1}, - 'margin must be greater than -0.5'), - (TypeError, (1, ), {'x': 1, 'y': 1}, - 'Cannot pass both positional and keyword ' - 'arguments for x and/or y.'), - (TypeError, (1, 1, 1), {}, - 'Must pass a single positional argument for all*'), - )) +@pytest.mark.parametrize('err, args, kwargs, match', ( + (ValueError, (-1,), {}, r'margin must be greater than -0\.5'), + (ValueError, (1, -1), {}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'x': -1}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'y': -1}, r'margin must be greater than -0\.5'), + (TypeError, (1, ), {'x': 1, 'y': 1}, + 'Cannot pass both positional and keyword arguments for x and/or y'), + (TypeError, (1, ), {'x': 1}, + 'Cannot pass both positional and keyword arguments for x and/or y'), + (TypeError, (1, 1, 1), {}, 'Must pass a single positional argument'), +)) def test_margins_errors(err, args, kwargs, match): with pytest.raises(err, match=match): fig = plt.figure() @@ -5745,6 +6291,17 @@ def test_title_location_roundtrip(): ax.set_title('fail', loc='foo') +@pytest.mark.parametrize('sharex', [True, False]) +def test_title_location_shared(sharex): + fig, axs = plt.subplots(2, 1, sharex=sharex) + axs[0].set_title('A', pad=-40) + axs[1].set_title('B', pad=-40) + fig.draw_without_rendering() + x, y1 = axs[0].title.get_position() + x, y2 = axs[1].title.get_position() + assert y1 == y2 == 1.0 + + @image_comparison(["loglog.png"], remove_text=True, tol=0.02) def test_loglog(): fig, ax = plt.subplots() @@ -5890,18 +6447,21 @@ def test_adjust_numtick_aspect(): assert len(ax.yaxis.get_major_locator()()) > 2 -@image_comparison(["auto_numticks.png"], style='default') +@mpl.style.context("default") def test_auto_numticks(): - # Make tiny, empty subplots, verify that there are only 3 ticks. - plt.subplots(4, 4) + axs = plt.figure().subplots(4, 4) + for ax in axs.flat: # Tiny, empty subplots have only 3 ticks. + assert [*ax.get_xticks()] == [*ax.get_yticks()] == [0, 0.5, 1] -@image_comparison(["auto_numticks_log.png"], style='default') +@mpl.style.context("default") def test_auto_numticks_log(): # Verify that there are not too many ticks with a large log range. fig, ax = plt.subplots() - matplotlib.rcParams['axes.autolimit_mode'] = 'round_numbers' + mpl.rcParams['axes.autolimit_mode'] = 'round_numbers' ax.loglog([1e-20, 1e5], [1e-16, 10]) + assert (np.log10(ax.get_xticks()) == np.arange(-26, 18, 4)).all() + assert (np.log10(ax.get_yticks()) == np.arange(-20, 10, 3)).all() def test_broken_barh_empty(): @@ -5929,7 +6489,7 @@ def test_pandas_pcolormesh(pd): def test_pandas_indexing_dates(pd): dates = np.arange('2005-02', '2005-03', dtype='datetime64[D]') - values = np.sin(np.array(range(len(dates)))) + values = np.sin(range(len(dates))) df = pd.DataFrame({'dates': dates, 'values': values}) ax = plt.gca() @@ -5972,22 +6532,31 @@ def test_pandas_bar_align_center(pd): fig.canvas.draw() -def test_tick_apply_tickdir_deprecation(): - # Remove this test when the deprecation expires. - import matplotlib.axis as maxis - ax = plt.axes() +def test_axis_get_tick_params(): + axis = plt.subplot().yaxis + initial_major_style_translated = {**axis.get_tick_params(which='major')} + initial_minor_style_translated = {**axis.get_tick_params(which='minor')} + + translated_major_kw = axis._translate_tick_params( + axis._major_tick_kw, reverse=True + ) + translated_minor_kw = axis._translate_tick_params( + axis._minor_tick_kw, reverse=True + ) - tick = maxis.XTick(ax, 0) - with pytest.warns(MatplotlibDeprecationWarning, - match="The apply_tickdir function was deprecated in " - "Matplotlib 3.5"): - tick.apply_tickdir('out') + assert translated_major_kw == initial_major_style_translated + assert translated_minor_kw == initial_minor_style_translated + axis.set_tick_params(labelsize=30, labelcolor='red', + direction='out', which='both') - tick = maxis.YTick(ax, 0) - with pytest.warns(MatplotlibDeprecationWarning, - match="The apply_tickdir function was deprecated in " - "Matplotlib 3.5"): - tick.apply_tickdir('out') + new_major_style_translated = {**axis.get_tick_params(which='major')} + new_minor_style_translated = {**axis.get_tick_params(which='minor')} + new_major_style = axis._translate_tick_params(new_major_style_translated) + new_minor_style = axis._translate_tick_params(new_minor_style_translated) + assert initial_major_style_translated != new_major_style_translated + assert axis._major_tick_kw == new_major_style + assert initial_minor_style_translated != new_minor_style_translated + assert axis._minor_tick_kw == new_minor_style def test_axis_set_tick_params_labelsize_labelcolor(): @@ -6181,8 +6750,38 @@ def test_title_xticks_top_both(): assert ax.title.get_position()[1] > 1.04 +@pytest.mark.parametrize( + 'left, center', [ + ('left', ''), + ('', 'center'), + ('left', 'center') + ], ids=[ + 'left title moved', + 'center title kept', + 'both titles aligned' + ] +) +def test_title_above_offset(left, center): + # Test that title moves if overlaps with yaxis offset text. + mpl.rcParams['axes.titley'] = None + fig, ax = plt.subplots() + ax.set_ylim(1e11) + ax.set_title(left, loc='left') + ax.set_title(center) + fig.draw_without_rendering() + if left and not center: + assert ax._left_title.get_position()[1] > 1.0 + elif not left and center: + assert ax.title.get_position()[1] == 1.0 + else: + yleft = ax._left_title.get_position()[1] + ycenter = ax.title.get_position()[1] + assert yleft > 1.0 + assert ycenter == yleft + + def test_title_no_move_off_page(): - # If an axes is off the figure (ie. if it is cropped during a save) + # If an Axes is off the figure (ie. if it is cropped during a save) # make sure that the automatic title repositioning does not get done. mpl.rcParams['axes.titley'] = None fig = plt.figure() @@ -6311,9 +6910,9 @@ def test_color_length_mismatch(): fig, ax = plt.subplots() with pytest.raises(ValueError): ax.scatter(x, y, c=colors) - c_rgb = (0.5, 0.5, 0.5) - ax.scatter(x, y, c=c_rgb) - ax.scatter(x, y, c=[c_rgb] * N) + with pytest.warns(match="argument looks like a single numeric RGB"): + ax.scatter(x, y, c=(0.5, 0.5, 0.5)) + ax.scatter(x, y, c=[(0.5, 0.5, 0.5)] * N) def test_eventplot_legend(): @@ -6475,6 +7074,31 @@ def test_zoom_inset(): axin1.get_position().get_points(), xx, rtol=1e-4) +@image_comparison(['inset_polar.png'], remove_text=True, style='mpl20') +def test_inset_polar(): + _, ax = plt.subplots() + axins = ax.inset_axes([0.5, 0.1, 0.45, 0.45], polar=True) + assert isinstance(axins, PolarAxes) + + r = np.arange(0, 2, 0.01) + theta = 2 * np.pi * r + + ax.plot(theta, r) + axins.plot(theta, r) + + +def test_inset_projection(): + _, ax = plt.subplots() + axins = ax.inset_axes([0.2, 0.2, 0.3, 0.3], projection="hammer") + assert isinstance(axins, HammerAxes) + + +def test_inset_subclass(): + _, ax = plt.subplots() + axins = ax.inset_axes([0.2, 0.2, 0.3, 0.3], axes_class=AA.Axes) + assert isinstance(axins, AA.Axes) + + @pytest.mark.parametrize('x_inverted', [False, True]) @pytest.mark.parametrize('y_inverted', [False, True]) def test_indicate_inset_inverted(x_inverted, y_inverted): @@ -6524,6 +7148,42 @@ def test_spines_properbbox_after_zoom(): np.testing.assert_allclose(bb.get_points(), bb2.get_points(), rtol=1e-6) +def test_limits_after_scroll_zoom(): + fig, ax = plt.subplots() + # + xlim = (-0.5, 0.5) + ylim = (-1, 2) + ax.set_xlim(xlim) + ax.set_ylim(ymin=ylim[0], ymax=ylim[1]) + # This is what scroll zoom calls: + # Zoom with factor 1, small numerical change + ax._set_view_from_bbox((200, 200, 1.)) + np.testing.assert_allclose(xlim, ax.get_xlim(), atol=1e-16) + np.testing.assert_allclose(ylim, ax.get_ylim(), atol=1e-16) + + # Zoom in + ax._set_view_from_bbox((200, 200, 2.)) + # Hard-coded values + new_xlim = (-0.3790322580645161, 0.12096774193548387) + new_ylim = (-0.40625, 1.09375) + + res_xlim = ax.get_xlim() + res_ylim = ax.get_ylim() + np.testing.assert_allclose(res_xlim[1] - res_xlim[0], 0.5) + np.testing.assert_allclose(res_ylim[1] - res_ylim[0], 1.5) + np.testing.assert_allclose(new_xlim, res_xlim, atol=1e-16) + np.testing.assert_allclose(new_ylim, res_ylim) + + # Zoom out, should be same as before, except for numerical issues + ax._set_view_from_bbox((200, 200, 0.5)) + res_xlim = ax.get_xlim() + res_ylim = ax.get_ylim() + np.testing.assert_allclose(res_xlim[1] - res_xlim[0], 1) + np.testing.assert_allclose(res_ylim[1] - res_ylim[0], 3) + np.testing.assert_allclose(xlim, res_xlim, atol=1e-16) + np.testing.assert_allclose(ylim, res_ylim, atol=1e-16) + + def test_gettightbbox_ignore_nan(): fig, ax = plt.subplots() remove_ticks_and_titles(fig) @@ -6647,7 +7307,25 @@ def test_secondary_formatter(): def test_secondary_repr(): fig, ax = plt.subplots() secax = ax.secondary_xaxis("top") - assert repr(secax) == '' + assert repr(secax) == '' + + +@image_comparison(['axis_options.png'], remove_text=True, style='mpl20') +def test_axis_options(): + fig, axes = plt.subplots(2, 3) + for i, option in enumerate(('scaled', 'tight', 'image')): + # Draw a line and a circle fitting within the boundaries of the line + # The circle should look like a circle for 'scaled' and 'image' + # High/narrow aspect ratio + axes[0, i].plot((1, 2), (1, 3.2)) + axes[0, i].axis(option) + axes[0, i].add_artist(mpatches.Circle((1.5, 1.5), radius=0.5, + facecolor='none', edgecolor='k')) + # Low/wide aspect ratio + axes[1, i].plot((1, 2.25), (1, 1.75)) + axes[1, i].axis(option) + axes[1, i].add_artist(mpatches.Circle((1.5, 1.25), radius=0.25, + facecolor='none', edgecolor='k')) def color_boxes(fig, ax): @@ -6661,7 +7339,7 @@ def color_boxes(fig, ax): for nn, axx in enumerate([ax.xaxis, ax.yaxis]): bb = axx.get_tightbbox(renderer) if bb: - axisr = plt.Rectangle( + axisr = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, linewidth=0.7, edgecolor='y', facecolor="none", transform=None, zorder=3) @@ -6671,7 +7349,7 @@ def color_boxes(fig, ax): bbspines = [] for nn, a in enumerate(['bottom', 'top', 'left', 'right']): bb = ax.spines[a].get_window_extent(renderer) - spiner = plt.Rectangle( + spiner = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, linewidth=0.7, edgecolor="green", facecolor="none", transform=None, zorder=3) @@ -6679,7 +7357,7 @@ def color_boxes(fig, ax): bbspines += [bb] bb = ax.get_window_extent() - rect2 = plt.Rectangle( + rect2 = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, linewidth=1.5, edgecolor="magenta", facecolor="none", transform=None, zorder=2) @@ -6687,7 +7365,7 @@ def color_boxes(fig, ax): bbax = bb bb2 = ax.get_tightbbox(renderer) - rect2 = plt.Rectangle( + rect2 = mpatches.Rectangle( (bb2.x0, bb2.y0), width=bb2.width, height=bb2.height, linewidth=3, edgecolor="red", facecolor="none", transform=None, zorder=1) @@ -6890,19 +7568,6 @@ def test_axis_extent_arg2(): assert (ymin, ymax) == ax.get_ylim() -def test_datetime_masked(): - # make sure that all-masked data falls back to the viewlim - # set in convert.axisinfo.... - x = np.array([datetime.datetime(2017, 1, n) for n in range(1, 6)]) - y = np.array([1, 2, 3, 4, 5]) - m = np.ma.masked_greater(y, 0) - - fig, ax = plt.subplots() - ax.plot(x, m) - dt = mdates.date2num(np.datetime64('0000-12-31')) - assert ax.get_xlim() == (730120.0 + dt, 733773.0 + dt) - - def test_hist_auto_bins(): _, bins, _ = plt.hist([[1, 2, 3], [3, 4, 5, 6]], bins='auto') assert bins[0] <= 1 @@ -6950,6 +7615,8 @@ def test_set_ticks_inverted(): ax.invert_xaxis() ax.set_xticks([.3, .7]) assert ax.get_xlim() == (1, 0) + ax.set_xticks([-1]) + assert ax.get_xlim() == (1, -1) def test_aspect_nonlinear_adjustable_box(): @@ -6989,6 +7656,7 @@ def test_box_aspect(): axtwin.plot([12, 344]) ax1.set_box_aspect(1) + assert ax1.get_box_aspect() == 1.0 fig2, ax2 = plt.subplots() ax2.margins(0) @@ -7044,6 +7712,18 @@ def test_bbox_aspect_axes_init(): assert_allclose(sizes, sizes[0]) +def test_set_aspect_negative(): + fig, ax = plt.subplots() + with pytest.raises(ValueError, match="must be finite and positive"): + ax.set_aspect(-1) + with pytest.raises(ValueError, match="must be finite and positive"): + ax.set_aspect(0) + with pytest.raises(ValueError, match="must be finite and positive"): + ax.set_aspect(np.inf) + with pytest.raises(ValueError, match="must be finite and positive"): + ax.set_aspect(-np.inf) + + def test_redraw_in_frame(): fig, ax = plt.subplots(1, 1) ax.plot([1, 2, 3]) @@ -7051,7 +7731,7 @@ def test_redraw_in_frame(): ax.redraw_in_frame() -def test_invisible_axes(): +def test_invisible_axes_events(): # invisible axes should not respond to events... fig, ax = plt.subplots() assert fig.canvas.inaxes((200, 200)) is not None @@ -7169,7 +7849,8 @@ def test_2dcolor_plot(fig_test, fig_ref): # plot with 1D-color: axs = fig_test.subplots(5) axs[0].plot([1, 2], [1, 2], c=color.reshape(-1)) - axs[1].scatter([1, 2], [1, 2], c=color.reshape(-1)) + with pytest.warns(match="argument looks like a single numeric RGB"): + axs[1].scatter([1, 2], [1, 2], c=color.reshape(-1)) axs[2].step([1, 2], [1, 2], c=color.reshape(-1)) axs[3].hist(np.arange(10), color=color.reshape(-1)) axs[4].bar(np.arange(10), np.arange(10), color=color.reshape(-1)) @@ -7234,6 +7915,20 @@ def test_bar_label_location_vertical(): assert labels[1].get_va() == 'top' +def test_bar_label_location_vertical_yinverted(): + ax = plt.gca() + ax.invert_yaxis() + xs, heights = [1, 2], [3, -4] + rects = ax.bar(xs, heights) + labels = ax.bar_label(rects) + assert labels[0].xy == (xs[0], heights[0]) + assert labels[0].get_ha() == 'center' + assert labels[0].get_va() == 'top' + assert labels[1].xy == (xs[1], heights[1]) + assert labels[1].get_ha() == 'center' + assert labels[1].get_va() == 'bottom' + + def test_bar_label_location_horizontal(): ax = plt.gca() ys, widths = [1, 2], [3, -4] @@ -7247,19 +7942,72 @@ def test_bar_label_location_horizontal(): assert labels[1].get_va() == 'center' +def test_bar_label_location_horizontal_yinverted(): + ax = plt.gca() + ax.invert_yaxis() + ys, widths = [1, 2], [3, -4] + rects = ax.barh(ys, widths) + labels = ax.bar_label(rects) + assert labels[0].xy == (widths[0], ys[0]) + assert labels[0].get_ha() == 'left' + assert labels[0].get_va() == 'center' + assert labels[1].xy == (widths[1], ys[1]) + assert labels[1].get_ha() == 'right' + assert labels[1].get_va() == 'center' + + +def test_bar_label_location_horizontal_xinverted(): + ax = plt.gca() + ax.invert_xaxis() + ys, widths = [1, 2], [3, -4] + rects = ax.barh(ys, widths) + labels = ax.bar_label(rects) + assert labels[0].xy == (widths[0], ys[0]) + assert labels[0].get_ha() == 'right' + assert labels[0].get_va() == 'center' + assert labels[1].xy == (widths[1], ys[1]) + assert labels[1].get_ha() == 'left' + assert labels[1].get_va() == 'center' + + +def test_bar_label_location_horizontal_xyinverted(): + ax = plt.gca() + ax.invert_xaxis() + ax.invert_yaxis() + ys, widths = [1, 2], [3, -4] + rects = ax.barh(ys, widths) + labels = ax.bar_label(rects) + assert labels[0].xy == (widths[0], ys[0]) + assert labels[0].get_ha() == 'right' + assert labels[0].get_va() == 'center' + assert labels[1].xy == (widths[1], ys[1]) + assert labels[1].get_ha() == 'left' + assert labels[1].get_va() == 'center' + + def test_bar_label_location_center(): ax = plt.gca() ys, widths = [1, 2], [3, -4] rects = ax.barh(ys, widths) labels = ax.bar_label(rects, label_type='center') - assert labels[0].xy == (widths[0] / 2, ys[0]) + assert labels[0].xy == (0.5, 0.5) assert labels[0].get_ha() == 'center' assert labels[0].get_va() == 'center' - assert labels[1].xy == (widths[1] / 2, ys[1]) + assert labels[1].xy == (0.5, 0.5) assert labels[1].get_ha() == 'center' assert labels[1].get_va() == 'center' +@image_comparison(['test_centered_bar_label_nonlinear.svg']) +def test_centered_bar_label_nonlinear(): + _, ax = plt.subplots() + bar_container = ax.barh(['c', 'b', 'a'], [1_000, 5_000, 7_000]) + ax.set_xscale('log') + ax.set_xlim(1, None) + ax.bar_label(bar_container, label_type='center') + ax.set_axis_off() + + def test_bar_label_location_errorbars(): ax = plt.gca() xs, heights = [1, 2], [3, -4] @@ -7273,14 +8021,24 @@ def test_bar_label_location_errorbars(): assert labels[1].get_va() == 'top' -def test_bar_label_fmt(): +@pytest.mark.parametrize('fmt', [ + '%.2f', '{:.2f}', '{:.2f}'.format +]) +def test_bar_label_fmt(fmt): ax = plt.gca() rects = ax.bar([1, 2], [3, -4]) - labels = ax.bar_label(rects, fmt='%.2f') + labels = ax.bar_label(rects, fmt=fmt) assert labels[0].get_text() == '3.00' assert labels[1].get_text() == '-4.00' +def test_bar_label_fmt_error(): + ax = plt.gca() + rects = ax.bar([1, 2], [3, -4]) + with pytest.raises(TypeError, match='str or callable'): + _ = ax.bar_label(rects, fmt=10) + + def test_bar_label_labels(): ax = plt.gca() rects = ax.bar([1, 2], [3, -4]) @@ -7298,9 +8056,39 @@ def test_bar_label_nan_ydata(): assert labels[0].get_va() == 'bottom' +def test_bar_label_nan_ydata_inverted(): + ax = plt.gca() + ax.yaxis_inverted() + bars = ax.bar([2, 3], [np.nan, 1]) + labels = ax.bar_label(bars) + assert [l.get_text() for l in labels] == ['', '1'] + assert labels[0].xy == (2, 0) + assert labels[0].get_va() == 'bottom' + + +def test_nan_barlabels(): + fig, ax = plt.subplots() + bars = ax.bar([1, 2, 3], [np.nan, 1, 2], yerr=[0.2, 0.4, 0.6]) + labels = ax.bar_label(bars) + assert [l.get_text() for l in labels] == ['', '1', '2'] + assert np.allclose(ax.get_ylim(), (0.0, 3.0)) + + fig, ax = plt.subplots() + bars = ax.bar([1, 2, 3], [0, 1, 2], yerr=[0.2, np.nan, 0.6]) + labels = ax.bar_label(bars) + assert [l.get_text() for l in labels] == ['0', '1', '2'] + assert np.allclose(ax.get_ylim(), (-0.5, 3.0)) + + fig, ax = plt.subplots() + bars = ax.bar([1, 2, 3], [np.nan, 1, 2], yerr=[np.nan, np.nan, 0.6]) + labels = ax.bar_label(bars) + assert [l.get_text() for l in labels] == ['', '1', '2'] + assert np.allclose(ax.get_ylim(), (0.0, 3.0)) + + def test_patch_bounds(): # PR 19078 fig, ax = plt.subplots() - ax.add_patch(mpatches.Wedge((0, -1), 1.05, 60, 120, 0.1)) + ax.add_patch(mpatches.Wedge((0, -1), 1.05, 60, 120, width=0.1)) bot = 1.9*np.sin(15*np.pi/180)**2 np.testing.assert_array_almost_equal_nulp( np.array((-0.525, -(bot+0.05), 1.05, bot+0.1)), ax.dataLim.bounds, 16) @@ -7310,10 +8098,7 @@ def test_patch_bounds(): # PR 19078 def test_warn_ignored_scatter_kwargs(): with pytest.warns(UserWarning, match=r"You passed a edgecolor/edgecolors"): - - c = plt.scatter( - [0], [0], marker="+", s=500, facecolor="r", edgecolor="b" - ) + plt.scatter([0], [0], marker="+", s=500, facecolor="r", edgecolor="b") def test_artist_sublists(): @@ -7338,19 +8123,13 @@ def test_artist_sublists(): with pytest.raises(IndexError, match='out of range'): ax.lines[len(lines) + 1] - # Deleting items (multiple or single) should warn. - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - del ax.lines[-1] - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - del ax.lines[-1:] - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - del ax.lines[1:] - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - del ax.lines[0] + # Adding to other lists should produce a regular list. + assert ax.lines + [1, 2, 3] == [*lines, 1, 2, 3] + assert [1, 2, 3] + ax.lines == [1, 2, 3, *lines] + + # Adding to other tuples should produce a regular tuples. + assert ax.lines + (1, 2, 3) == (*lines, 1, 2, 3) + assert (1, 2, 3) + ax.lines == (1, 2, 3, *lines) # Lists should be empty after removing items. col.remove() @@ -7359,58 +8138,13 @@ def test_artist_sublists(): assert not ax.images patch.remove() assert not ax.patches + assert not ax.tables text.remove() assert not ax.texts - # Everything else should remain empty. - assert not ax.lines - assert not ax.tables - - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.texts property'): - ax.texts.append(text) - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.collections property'): - ax.collections.append(col) - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.images property'): - ax.images.append(im) - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.patches property'): - ax.patches.append(patch) - # verify things are back - assert list(ax.collections) == [col] - assert list(ax.images) == [im] - assert list(ax.patches) == [patch] - assert list(ax.texts) == [text] - - # Adding items should warn. - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - ax.lines.append(lines[-2]) - assert list(ax.lines) == [lines[-2]] - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - ax.lines.append(lines[-1]) - assert list(ax.lines) == lines[-2:] - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - ax.lines.insert(-2, lines[0]) - assert list(ax.lines) == [lines[0], lines[-2], lines[-1]] - - # Modifying items should warn. - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - ax.lines[0] = lines[0] - assert list(ax.lines) == [lines[0], lines[-2], lines[-1]] - with pytest.warns(MatplotlibDeprecationWarning, - match='modification of the Axes.lines property'): - ax.lines[1:1] = lines[1:-2] - assert list(ax.lines) == lines - - # Adding to other lists should produce a regular list. - assert ax.lines + [1, 2, 3] == [*lines, 1, 2, 3] - assert [1, 2, 3] + ax.lines == [1, 2, 3, *lines] + for ln in ax.lines: + ln.remove() + assert len(ax.lines) == 0 def test_empty_line_plots(): @@ -7427,9 +8161,273 @@ def test_empty_line_plots(): assert len(line) == 1 +@pytest.mark.parametrize('fmt, match', ( + ("f", r"'f' is not a valid format string \(unrecognized character 'f'\)"), + ("o+", r"'o\+' is not a valid format string \(two marker symbols\)"), + (":-", r"':-' is not a valid format string \(two linestyle symbols\)"), + ("rk", r"'rk' is not a valid format string \(two color symbols\)"), + (":o-r", r"':o-r' is not a valid format string \(two linestyle symbols\)"), +)) +@pytest.mark.parametrize("data", [None, {"string": range(3)}]) +def test_plot_format_errors(fmt, match, data): + fig, ax = plt.subplots() + if data is not None: + match = match.replace("not", "neither a data key nor") + with pytest.raises(ValueError, match=r"\A" + match + r"\Z"): + ax.plot("string", fmt, data=data) + + +def test_plot_format(): + fig, ax = plt.subplots() + line = ax.plot([1, 2, 3], '1.0') + assert line[0].get_color() == (1.0, 1.0, 1.0, 1.0) + assert line[0].get_marker() == 'None' + fig, ax = plt.subplots() + line = ax.plot([1, 2, 3], '1') + assert line[0].get_marker() == '1' + fig, ax = plt.subplots() + line = ax.plot([1, 2], [1, 2], '1.0', "1") + fig.canvas.draw() + assert line[0].get_color() == (1.0, 1.0, 1.0, 1.0) + assert ax.get_yticklabels()[0].get_text() == '1' + fig, ax = plt.subplots() + line = ax.plot([1, 2], [1, 2], '1', "1.0") + fig.canvas.draw() + assert line[0].get_marker() == '1' + assert ax.get_yticklabels()[0].get_text() == '1.0' + fig, ax = plt.subplots() + line = ax.plot([1, 2, 3], 'k3') + assert line[0].get_marker() == '3' + assert line[0].get_color() == 'k' + + +def test_automatic_legend(): + fig, ax = plt.subplots() + ax.plot("a", "b", data={"d": 2}) + leg = ax.legend() + fig.canvas.draw() + assert leg.get_texts()[0].get_text() == 'a' + assert ax.get_yticklabels()[0].get_text() == 'a' + + fig, ax = plt.subplots() + ax.plot("a", "b", "c", data={"d": 2}) + leg = ax.legend() + fig.canvas.draw() + assert leg.get_texts()[0].get_text() == 'b' + assert ax.get_xticklabels()[0].get_text() == 'a' + assert ax.get_yticklabels()[0].get_text() == 'b' + + +def test_plot_errors(): + with pytest.raises(TypeError, match=r"plot\(\) got an unexpected keyword"): + plt.plot([1, 2, 3], x=1) + with pytest.raises(ValueError, match=r"plot\(\) with multiple groups"): + plt.plot([1, 2, 3], [1, 2, 3], [2, 3, 4], [2, 3, 4], label=['1', '2']) + with pytest.raises(ValueError, match="x and y must have same first"): + plt.plot([1, 2, 3], [1]) + with pytest.raises(ValueError, match="x and y can be no greater than"): + plt.plot(np.ones((2, 2, 2))) + with pytest.raises(ValueError, match="Using arbitrary long args with"): + plt.plot("a", "b", "c", "d", data={"a": 2}) + + def test_clim(): ax = plt.figure().add_subplot() - for plot_method in [ax.imshow, ax.pcolor, ax.pcolormesh, ax.pcolorfast]: + for plot_method in [ + partial(ax.scatter, range(3), range(3), c=range(3)), + partial(ax.imshow, [[0, 1], [2, 3]]), + partial(ax.pcolor, [[0, 1], [2, 3]]), + partial(ax.pcolormesh, [[0, 1], [2, 3]]), + partial(ax.pcolorfast, [[0, 1], [2, 3]]), + ]: clim = (7, 8) - norm = plot_method([[0, 1], [2, 3]], clim=clim).norm + norm = plot_method(clim=clim).norm assert (norm.vmin, norm.vmax) == clim + + +def test_bezier_autoscale(): + # Check that bezier curves autoscale to their curves, and not their + # control points + verts = [[-1, 0], + [0, -1], + [1, 0], + [1, 0]] + codes = [mpath.Path.MOVETO, + mpath.Path.CURVE3, + mpath.Path.CURVE3, + mpath.Path.CLOSEPOLY] + p = mpath.Path(verts, codes) + + fig, ax = plt.subplots() + ax.add_patch(mpatches.PathPatch(p)) + ax.autoscale() + # Bottom ylim should be at the edge of the curve (-0.5), and not include + # the control point (at -1) + assert ax.get_ylim()[0] == -0.5 + + +def test_small_autoscale(): + # Check that paths with small values autoscale correctly #24097. + verts = np.array([ + [-5.45, 0.00], [-5.45, 0.00], [-5.29, 0.00], [-5.29, 0.00], + [-5.13, 0.00], [-5.13, 0.00], [-4.97, 0.00], [-4.97, 0.00], + [-4.81, 0.00], [-4.81, 0.00], [-4.65, 0.00], [-4.65, 0.00], + [-4.49, 0.00], [-4.49, 0.00], [-4.33, 0.00], [-4.33, 0.00], + [-4.17, 0.00], [-4.17, 0.00], [-4.01, 0.00], [-4.01, 0.00], + [-3.85, 0.00], [-3.85, 0.00], [-3.69, 0.00], [-3.69, 0.00], + [-3.53, 0.00], [-3.53, 0.00], [-3.37, 0.00], [-3.37, 0.00], + [-3.21, 0.00], [-3.21, 0.01], [-3.05, 0.01], [-3.05, 0.01], + [-2.89, 0.01], [-2.89, 0.01], [-2.73, 0.01], [-2.73, 0.02], + [-2.57, 0.02], [-2.57, 0.04], [-2.41, 0.04], [-2.41, 0.04], + [-2.25, 0.04], [-2.25, 0.06], [-2.09, 0.06], [-2.09, 0.08], + [-1.93, 0.08], [-1.93, 0.10], [-1.77, 0.10], [-1.77, 0.12], + [-1.61, 0.12], [-1.61, 0.14], [-1.45, 0.14], [-1.45, 0.17], + [-1.30, 0.17], [-1.30, 0.19], [-1.14, 0.19], [-1.14, 0.22], + [-0.98, 0.22], [-0.98, 0.25], [-0.82, 0.25], [-0.82, 0.27], + [-0.66, 0.27], [-0.66, 0.29], [-0.50, 0.29], [-0.50, 0.30], + [-0.34, 0.30], [-0.34, 0.32], [-0.18, 0.32], [-0.18, 0.33], + [-0.02, 0.33], [-0.02, 0.32], [0.13, 0.32], [0.13, 0.33], [0.29, 0.33], + [0.29, 0.31], [0.45, 0.31], [0.45, 0.30], [0.61, 0.30], [0.61, 0.28], + [0.77, 0.28], [0.77, 0.25], [0.93, 0.25], [0.93, 0.22], [1.09, 0.22], + [1.09, 0.19], [1.25, 0.19], [1.25, 0.17], [1.41, 0.17], [1.41, 0.15], + [1.57, 0.15], [1.57, 0.12], [1.73, 0.12], [1.73, 0.10], [1.89, 0.10], + [1.89, 0.08], [2.05, 0.08], [2.05, 0.07], [2.21, 0.07], [2.21, 0.05], + [2.37, 0.05], [2.37, 0.04], [2.53, 0.04], [2.53, 0.02], [2.69, 0.02], + [2.69, 0.02], [2.85, 0.02], [2.85, 0.01], [3.01, 0.01], [3.01, 0.01], + [3.17, 0.01], [3.17, 0.00], [3.33, 0.00], [3.33, 0.00], [3.49, 0.00], + [3.49, 0.00], [3.65, 0.00], [3.65, 0.00], [3.81, 0.00], [3.81, 0.00], + [3.97, 0.00], [3.97, 0.00], [4.13, 0.00], [4.13, 0.00], [4.29, 0.00], + [4.29, 0.00], [4.45, 0.00], [4.45, 0.00], [4.61, 0.00], [4.61, 0.00], + [4.77, 0.00], [4.77, 0.00], [4.93, 0.00], [4.93, 0.00], + ]) + + minx = np.min(verts[:, 0]) + miny = np.min(verts[:, 1]) + maxx = np.max(verts[:, 0]) + maxy = np.max(verts[:, 1]) + + p = mpath.Path(verts) + + fig, ax = plt.subplots() + ax.add_patch(mpatches.PathPatch(p)) + ax.autoscale() + + assert ax.get_xlim()[0] <= minx + assert ax.get_xlim()[1] >= maxx + assert ax.get_ylim()[0] <= miny + assert ax.get_ylim()[1] >= maxy + + +def test_get_xticklabel(): + fig, ax = plt.subplots() + ax.plot(np.arange(10)) + for ind in range(10): + assert ax.get_xticklabels()[ind].get_text() == f'{ind}' + assert ax.get_yticklabels()[ind].get_text() == f'{ind}' + + +def test_bar_leading_nan(): + + barx = np.arange(3, dtype=float) + barheights = np.array([0.5, 1.5, 2.0]) + barstarts = np.array([0.77]*3) + + barx[0] = np.NaN + + fig, ax = plt.subplots() + + bars = ax.bar(barx, barheights, bottom=barstarts) + + hbars = ax.barh(barx, barheights, left=barstarts) + + for bar_set in (bars, hbars): + # the first bar should have a nan in the location + nanful, *rest = bar_set + assert (~np.isfinite(nanful.xy)).any() + assert np.isfinite(nanful.get_width()) + for b in rest: + assert np.isfinite(b.xy).all() + assert np.isfinite(b.get_width()) + + +@check_figures_equal(extensions=["png"]) +def test_bar_all_nan(fig_test, fig_ref): + mpl.style.use("mpl20") + ax_test = fig_test.subplots() + ax_ref = fig_ref.subplots() + + ax_test.bar([np.nan], [np.nan]) + ax_test.bar([1], [1]) + + ax_ref.bar([1], [1]).remove() + ax_ref.bar([1], [1]) + + +@image_comparison(["extent_units.png"], style="mpl20") +def test_extent_units(): + _, axs = plt.subplots(2, 2) + date_first = np.datetime64('2020-01-01', 'D') + date_last = np.datetime64('2020-01-11', 'D') + arr = [[i+j for i in range(10)] for j in range(10)] + + axs[0, 0].set_title('Date extents on y axis') + im = axs[0, 0].imshow(arr, origin='lower', + extent=[1, 11, date_first, date_last], + cmap=mpl.colormaps["plasma"]) + + axs[0, 1].set_title('Date extents on x axis (Day of Jan 2020)') + im = axs[0, 1].imshow(arr, origin='lower', + extent=[date_first, date_last, 1, 11], + cmap=mpl.colormaps["plasma"]) + axs[0, 1].xaxis.set_major_formatter(mdates.DateFormatter('%d')) + + im = axs[1, 0].imshow(arr, origin='lower', + extent=[date_first, date_last, + date_first, date_last], + cmap=mpl.colormaps["plasma"]) + axs[1, 0].xaxis.set_major_formatter(mdates.DateFormatter('%d')) + axs[1, 0].set(xlabel='Day of Jan 2020') + + im = axs[1, 1].imshow(arr, origin='lower', + cmap=mpl.colormaps["plasma"]) + im.set_extent([date_last, date_first, date_last, date_first]) + axs[1, 1].xaxis.set_major_formatter(mdates.DateFormatter('%d')) + axs[1, 1].set(xlabel='Day of Jan 2020') + + with pytest.raises(TypeError, match=r"set_extent\(\) got an unexpected"): + im.set_extent([2, 12, date_first, date_last], clip=False) + + +def test_cla_clears_children_axes_and_fig(): + fig, ax = plt.subplots() + lines = ax.plot([], [], [], []) + img = ax.imshow([[1]]) + for art in lines + [img]: + assert art.axes is ax + assert art.figure is fig + ax.clear() + for art in lines + [img]: + assert art.axes is None + assert art.figure is None + + +def test_scatter_color_repr_error(): + + def get_next_color(): + return 'blue' # pragma: no cover + msg = ( + r"'c' argument must be a color, a sequence of colors" + r", or a sequence of numbers, not 'red\\n'" + ) + with pytest.raises(ValueError, match=msg): + c = 'red\n' + mpl.axes.Axes._parse_scatter_color_args( + c, None, kwargs={}, xsize=2, get_next_color_func=get_next_color) + + +def test_zorder_and_explicit_rasterization(): + fig, ax = plt.subplots() + ax.set_rasterization_zorder(5) + ln, = ax.plot(range(5), rasterized=True, zorder=1) + with io.BytesIO() as b: + fig.savefig(b, format='pdf') diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 4abaf1a78ed5..4cbd1bc98b67 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -1,21 +1,22 @@ import re -from matplotlib.testing import _check_for_pgf +from matplotlib import path, transforms from matplotlib.backend_bases import ( - FigureCanvasBase, LocationEvent, MouseButton, MouseEvent, + FigureCanvasBase, KeyEvent, LocationEvent, MouseButton, MouseEvent, NavigationToolbar2, RendererBase) -from matplotlib.backend_tools import (ToolZoom, ToolPan, RubberbandBase, - ToolViewsPositions, _views_positions) +from matplotlib.backend_tools import RubberbandBase from matplotlib.figure import Figure +from matplotlib.testing._markers import needs_pgf_xelatex import matplotlib.pyplot as plt -import matplotlib.transforms as transforms -import matplotlib.path as path import numpy as np import pytest -needs_xelatex = pytest.mark.skipif(not _check_for_pgf('xelatex'), - reason='xelatex + pgf is required') + +_EXPECTED_WARNING_TOOLMANAGER = ( + r"Treat the new Tool classes introduced in " + r"v[0-9]*.[0-9]* as experimental for now; " + "the API and rcParam may change in future versions.") def test_uses_per_path(): @@ -34,8 +35,7 @@ def check(master_transform, paths, all_transforms, gc = rb.new_gc() ids = [path_id for xo, yo, path_id, gc0, rgbFace in rb._iter_collection( - gc, master_transform, all_transforms, - range(len(raw_paths)), offsets, + gc, range(len(raw_paths)), offsets, transforms.AffineDeltaTransform(master_transform), facecolors, edgecolors, [], [], [False], [], 'screen')] @@ -124,12 +124,18 @@ def test_pick(): fig = plt.figure() fig.text(.5, .5, "hello", ha="center", va="center", picker=True) fig.canvas.draw() + picks = [] - fig.canvas.mpl_connect("pick_event", lambda event: picks.append(event)) - start_event = MouseEvent( - "button_press_event", fig.canvas, *fig.transFigure.transform((.5, .5)), - MouseButton.LEFT) - fig.canvas.callbacks.process(start_event.name, start_event) + def handle_pick(event): + assert event.mouseevent.key == "a" + picks.append(event) + fig.canvas.mpl_connect("pick_event", handle_pick) + + KeyEvent("key_press_event", fig.canvas, "a")._process() + MouseEvent("button_press_event", fig.canvas, + *fig.transFigure.transform((.5, .5)), + MouseButton.LEFT)._process() + KeyEvent("key_release_event", fig.canvas, "a")._process() assert len(picks) == 1 @@ -181,12 +187,24 @@ def test_interactive_zoom(): assert not ax.get_autoscalex_on() and not ax.get_autoscaley_on() +def test_widgetlock_zoompan(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + fig.canvas.widgetlock(ax) + tb = NavigationToolbar2(fig.canvas) + tb.zoom() + assert ax.get_navigate_mode() is None + tb.pan() + assert ax.get_navigate_mode() is None + + @pytest.mark.parametrize("plot_func", ["imshow", "contourf"]) @pytest.mark.parametrize("orientation", ["vertical", "horizontal"]) @pytest.mark.parametrize("tool,button,expected", [("zoom", MouseButton.LEFT, (4, 6)), # zoom in ("zoom", MouseButton.RIGHT, (-20, 30)), # zoom out - ("pan", MouseButton.LEFT, (-2, 8))]) + ("pan", MouseButton.LEFT, (-2, 8)), + ("pan", MouseButton.RIGHT, (1.47, 7.78))]) # zoom def test_interactive_colorbar(plot_func, orientation, tool, button, expected): fig, ax = plt.subplots() data = np.arange(12).reshape((4, 3)) @@ -242,22 +260,10 @@ def test_interactive_colorbar(plot_func, orientation, tool, button, expected): def test_toolbar_zoompan(): - expected_warning_regex = ( - r"Treat the new Tool classes introduced in " - r"v[0-9]*.[0-9]* as experimental for now; " - "the API and rcParam may change in future versions.") - with pytest.warns(UserWarning, match=expected_warning_regex): + with pytest.warns(UserWarning, match=_EXPECTED_WARNING_TOOLMANAGER): plt.rcParams['toolbar'] = 'toolmanager' ax = plt.gca() assert ax.get_navigate_mode() is None - ax.figure.canvas.manager.toolmanager.add_tool(name="zoom", - tool=ToolZoom) - ax.figure.canvas.manager.toolmanager.add_tool(name="pan", - tool=ToolPan) - ax.figure.canvas.manager.toolmanager.add_tool(name=_views_positions, - tool=ToolViewsPositions) - ax.figure.canvas.manager.toolmanager.add_tool(name='rubberband', - tool=RubberbandBase) ax.figure.canvas.manager.toolmanager.trigger_tool('zoom') assert ax.get_navigate_mode() == "ZOOM" ax.figure.canvas.manager.toolmanager.trigger_tool('pan') @@ -265,7 +271,8 @@ def test_toolbar_zoompan(): @pytest.mark.parametrize( - "backend", ['svg', 'ps', 'pdf', pytest.param('pgf', marks=needs_xelatex)] + "backend", ['svg', 'ps', 'pdf', + pytest.param('pgf', marks=needs_pgf_xelatex)] ) def test_draw(backend): from matplotlib.figure import Figure @@ -297,3 +304,98 @@ def test_draw(backend): for ref, test in zip(layed_out_pos_agg, layed_out_pos_test): np.testing.assert_allclose(ref, test, atol=0.005) + + +@pytest.mark.parametrize( + "key,mouseend,expectedxlim,expectedylim", + [(None, (0.2, 0.2), (3.49, 12.49), (2.7, 11.7)), + (None, (0.2, 0.5), (3.49, 12.49), (0, 9)), + (None, (0.5, 0.2), (0, 9), (2.7, 11.7)), + (None, (0.5, 0.5), (0, 9), (0, 9)), # No move + (None, (0.8, 0.25), (-3.47, 5.53), (2.25, 11.25)), + (None, (0.2, 0.25), (3.49, 12.49), (2.25, 11.25)), + (None, (0.8, 0.85), (-3.47, 5.53), (-3.14, 5.86)), + (None, (0.2, 0.85), (3.49, 12.49), (-3.14, 5.86)), + ("shift", (0.2, 0.4), (3.49, 12.49), (0, 9)), # snap to x + ("shift", (0.4, 0.2), (0, 9), (2.7, 11.7)), # snap to y + ("shift", (0.2, 0.25), (3.49, 12.49), (3.49, 12.49)), # snap to diagonal + ("shift", (0.8, 0.25), (-3.47, 5.53), (3.47, 12.47)), # snap to diagonal + ("shift", (0.8, 0.9), (-3.58, 5.41), (-3.58, 5.41)), # snap to diagonal + ("shift", (0.2, 0.85), (3.49, 12.49), (-3.49, 5.51)), # snap to diagonal + ("x", (0.2, 0.1), (3.49, 12.49), (0, 9)), # only x + ("y", (0.1, 0.2), (0, 9), (2.7, 11.7)), # only y + ("control", (0.2, 0.2), (3.49, 12.49), (3.49, 12.49)), # diagonal + ("control", (0.4, 0.2), (2.72, 11.72), (2.72, 11.72)), # diagonal + ]) +def test_interactive_pan(key, mouseend, expectedxlim, expectedylim): + fig, ax = plt.subplots() + ax.plot(np.arange(10)) + assert ax.get_navigate() + # Set equal aspect ratio to easier see diagonal snap + ax.set_aspect('equal') + + # Mouse move starts from 0.5, 0.5 + mousestart = (0.5, 0.5) + # Convert to screen coordinates ("s"). Events are defined only with pixel + # precision, so round the pixel values, and below, check against the + # corresponding xdata/ydata, which are close but not equal to d0/d1. + sstart = ax.transData.transform(mousestart).astype(int) + send = ax.transData.transform(mouseend).astype(int) + + # Set up the mouse movements + start_event = MouseEvent( + "button_press_event", fig.canvas, *sstart, button=MouseButton.LEFT, + key=key) + stop_event = MouseEvent( + "button_release_event", fig.canvas, *send, button=MouseButton.LEFT, + key=key) + + tb = NavigationToolbar2(fig.canvas) + tb.pan() + tb.press_pan(start_event) + tb.drag_pan(stop_event) + tb.release_pan(stop_event) + # Should be close, but won't be exact due to screen integer resolution + assert tuple(ax.get_xlim()) == pytest.approx(expectedxlim, abs=0.02) + assert tuple(ax.get_ylim()) == pytest.approx(expectedylim, abs=0.02) + + +def test_toolmanager_remove(): + with pytest.warns(UserWarning, match=_EXPECTED_WARNING_TOOLMANAGER): + plt.rcParams['toolbar'] = 'toolmanager' + fig = plt.gcf() + initial_len = len(fig.canvas.manager.toolmanager.tools) + assert 'forward' in fig.canvas.manager.toolmanager.tools + fig.canvas.manager.toolmanager.remove_tool('forward') + assert len(fig.canvas.manager.toolmanager.tools) == initial_len - 1 + assert 'forward' not in fig.canvas.manager.toolmanager.tools + + +def test_toolmanager_get_tool(): + with pytest.warns(UserWarning, match=_EXPECTED_WARNING_TOOLMANAGER): + plt.rcParams['toolbar'] = 'toolmanager' + fig = plt.gcf() + rubberband = fig.canvas.manager.toolmanager.get_tool('rubberband') + assert isinstance(rubberband, RubberbandBase) + assert fig.canvas.manager.toolmanager.get_tool(rubberband) is rubberband + with pytest.warns(UserWarning, + match="ToolManager does not control tool 'foo'"): + assert fig.canvas.manager.toolmanager.get_tool('foo') is None + assert fig.canvas.manager.toolmanager.get_tool('foo', warn=False) is None + + with pytest.warns(UserWarning, + match="ToolManager does not control tool 'foo'"): + assert fig.canvas.manager.toolmanager.trigger_tool('foo') is None + + +def test_toolmanager_update_keymap(): + with pytest.warns(UserWarning, match=_EXPECTED_WARNING_TOOLMANAGER): + plt.rcParams['toolbar'] = 'toolmanager' + fig = plt.gcf() + assert 'v' in fig.canvas.manager.toolmanager.get_tool_keymap('forward') + with pytest.warns(UserWarning, + match="Key c changed from back to forward"): + fig.canvas.manager.toolmanager.update_keymap('forward', 'c') + assert fig.canvas.manager.toolmanager.get_tool_keymap('forward') == ['c'] + with pytest.raises(KeyError, match="'foo' not in Tools"): + fig.canvas.manager.toolmanager.update_keymap('foo', 'c') diff --git a/lib/matplotlib/tests/test_backend_gtk3.py b/lib/matplotlib/tests/test_backend_gtk3.py index 5442930d117f..937ddef5a13f 100644 --- a/lib/matplotlib/tests/test_backend_gtk3.py +++ b/lib/matplotlib/tests/test_backend_gtk3.py @@ -6,7 +6,7 @@ pytest.importorskip("matplotlib.backends.backend_gtk3agg") -@pytest.mark.backend("gtk3agg") +@pytest.mark.backend("gtk3agg", skip_on_importerror=True) def test_correct_key(): pytest.xfail("test_widget_send_event is not triggering key_press_event") diff --git a/lib/matplotlib/tests/test_backend_macosx.py b/lib/matplotlib/tests/test_backend_macosx.py index df1066e54d84..c460da374c8c 100644 --- a/lib/matplotlib/tests/test_backend_macosx.py +++ b/lib/matplotlib/tests/test_backend_macosx.py @@ -1,10 +1,13 @@ +import os + import pytest +import matplotlib as mpl import matplotlib.pyplot as plt - - -pytest.importorskip("matplotlib.backends.backend_macosx", - reason="These are mac only tests") +try: + from matplotlib.backends import _macosx +except ImportError: + pytest.skip("These are mac only tests", allow_module_level=True) @pytest.mark.backend('macosx') @@ -13,8 +16,31 @@ def test_cached_renderer(): # a fig.canvas.draw() call fig = plt.figure(1) fig.canvas.draw() - assert fig._cachedRenderer is not None + assert fig.canvas.get_renderer()._renderer is not None fig = plt.figure(2) fig.draw_without_rendering() - assert fig._cachedRenderer is not None + assert fig.canvas.get_renderer()._renderer is not None + + +@pytest.mark.backend('macosx') +def test_savefig_rcparam(monkeypatch, tmp_path): + + def new_choose_save_file(title, directory, filename): + # Replacement function instead of opening a GUI window + # Make a new directory for testing the update of the rcParams + assert directory == str(tmp_path) + os.makedirs(f"{directory}/test") + return f"{directory}/test/{filename}" + + monkeypatch.setattr(_macosx, "choose_save_file", new_choose_save_file) + fig = plt.figure() + with mpl.rc_context({"savefig.directory": tmp_path}): + fig.canvas.toolbar.save_figure() + # Check the saved location got created + save_file = f"{tmp_path}/test/{fig.canvas.get_default_filename()}" + assert os.path.exists(save_file) + + # Check the savefig.directory rcParam got updated because + # we added a subdirectory "test" + assert mpl.rcParams["savefig.directory"] == f"{tmp_path}/test" diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 410fcf6e2025..8ffca8295ea5 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -9,19 +9,17 @@ import pytest import matplotlib as mpl -from matplotlib import dviread, pyplot as plt, checkdep_usetex, rcParams +from matplotlib import ( + pyplot as plt, rcParams, font_manager as fm +) from matplotlib.cbook import _get_data_path from matplotlib.ft2font import FT2Font from matplotlib.font_manager import findfont, FontProperties from matplotlib.backends._backend_pdf_ps import get_glyphs_subset from matplotlib.backends.backend_pdf import PdfPages - +from matplotlib.patches import Rectangle from matplotlib.testing.decorators import check_figures_equal, image_comparison - - -needs_usetex = pytest.mark.skipif( - not checkdep_usetex(True), - reason="This test needs a TeX installation") +from matplotlib.testing._markers import needs_usetex @image_comparison(['pdf_use14corefonts.pdf']) @@ -247,10 +245,37 @@ def test_text_urls(): (a for a in annots if a.A.URI == f'{test_url}{fragment}'), None) assert annot is not None + assert getattr(annot, 'QuadPoints', None) is None # Positions in points (72 per inch.) assert annot.Rect[1] == decimal.Decimal(y) * 72 +def test_text_rotated_urls(): + pikepdf = pytest.importorskip('pikepdf') + + test_url = 'https://test_text_urls.matplotlib.org/' + + fig = plt.figure(figsize=(1, 1)) + fig.text(0.1, 0.1, 'N', rotation=45, url=f'{test_url}') + + with io.BytesIO() as fd: + fig.savefig(fd, format='pdf') + + with pikepdf.Pdf.open(fd) as pdf: + annots = pdf.pages[0].Annots + + # Iteration over Annots must occur within the context manager, + # otherwise it may fail depending on the pdf structure. + annot = next( + (a for a in annots if a.A.URI == f'{test_url}'), + None) + assert annot is not None + assert getattr(annot, 'QuadPoints', None) is not None + # Positions in points (72 per inch) + assert annot.Rect[0] == \ + annot.QuadPoints[6] - decimal.Decimal('0.00001') + + @needs_usetex def test_text_urls_tex(): pikepdf = pytest.importorskip('pikepdf') @@ -286,8 +311,8 @@ def test_hatching_legend(): """Test for correct hatching on patches in legend""" fig = plt.figure(figsize=(1, 2)) - a = plt.Rectangle([0, 0], 0, 0, facecolor="green", hatch="XXXX") - b = plt.Rectangle([0, 0], 0, 0, facecolor="blue", hatch="XXXX") + a = Rectangle([0, 0], 0, 0, facecolor="green", hatch="XXXX") + b = Rectangle([0, 0], 0, 0, facecolor="blue", hatch="XXXX") fig.legend([a, b, a, b], ["", "", "", ""]) @@ -304,23 +329,6 @@ def test_grayscale_alpha(): ax.set_yticks([]) -# This tests tends to hit a TeX cache lock on AppVeyor. -@pytest.mark.flaky(reruns=3) -@needs_usetex -def test_missing_psfont(monkeypatch): - """An error is raised if a TeX font lacks a Type-1 equivalent""" - def psfont(*args, **kwargs): - return dviread.PsFont(texname='texfont', psname='Some Font', - effects=None, encoding=None, filename=None) - - monkeypatch.setattr(dviread.PsfontsMap, '__getitem__', psfont) - rcParams['text.usetex'] = True - fig, ax = plt.subplots() - ax.text(0.5, 0.5, 'hello') - with NamedTemporaryFile() as tmpfile, pytest.raises(ValueError): - fig.savefig(tmpfile, format='pdf') - - @mpl.style.context('default') @check_figures_equal(extensions=["pdf", "eps"]) def test_pdf_eps_savefig_when_color_is_none(fig_test, fig_ref): @@ -377,3 +385,29 @@ def test_glyphs_subset(): # since both objects are assigned same characters assert subfont.get_num_glyphs() == nosubfont.get_num_glyphs() + + +@image_comparison(["multi_font_type3.pdf"], tol=4.6) +def test_multi_font_type3(): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27) + plt.rc('pdf', fonttype=3) + + fig = plt.figure() + fig.text(0.15, 0.475, "There are 几个汉字 in between!") + + +@image_comparison(["multi_font_type42.pdf"], tol=2.2) +def test_multi_font_type42(): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27) + plt.rc('pdf', fonttype=42) + + fig = plt.figure() + fig.text(0.15, 0.475, "There are 几个汉字 in between!") diff --git a/lib/matplotlib/tests/test_backend_pgf.py b/lib/matplotlib/tests/test_backend_pgf.py index 9b5b0b28ee3f..482bc073a766 100644 --- a/lib/matplotlib/tests/test_backend_pgf.py +++ b/lib/matplotlib/tests/test_backend_pgf.py @@ -11,22 +11,15 @@ import matplotlib.pyplot as plt from matplotlib.testing import _has_tex_package, _check_for_pgf from matplotlib.testing.compare import compare_images, ImageComparisonFailure -from matplotlib.backends.backend_pgf import PdfPages, common_texification -from matplotlib.testing.decorators import (_image_directories, - check_figures_equal, - image_comparison) +from matplotlib.backends.backend_pgf import PdfPages, _tex_escape +from matplotlib.testing.decorators import ( + _image_directories, check_figures_equal, image_comparison) +from matplotlib.testing._markers import ( + needs_ghostscript, needs_pgf_lualatex, needs_pgf_pdflatex, + needs_pgf_xelatex) -baseline_dir, result_dir = _image_directories(lambda: 'dummy func') -needs_xelatex = pytest.mark.skipif(not _check_for_pgf('xelatex'), - reason='xelatex + pgf is required') -needs_pdflatex = pytest.mark.skipif(not _check_for_pgf('pdflatex'), - reason='pdflatex + pgf is required') -needs_lualatex = pytest.mark.skipif(not _check_for_pgf('lualatex'), - reason='lualatex + pgf is required') -needs_ghostscript = pytest.mark.skipif( - "eps" not in mpl.testing.compare.converter, - reason="This test needs a ghostscript installation") +baseline_dir, result_dir = _image_directories(lambda: 'dummy func') def compare_figure(fname, savefig_kwargs={}, tol=0): @@ -40,6 +33,23 @@ def compare_figure(fname, savefig_kwargs={}, tol=0): raise ImageComparisonFailure(err) +@pytest.mark.parametrize('plain_text, escaped_text', [ + (r'quad_sum: $\sum x_i^2$', r'quad_sum: \(\displaystyle \sum x_i^2\)'), + ('% not a comment', r'\% not a comment'), + ('^not', r'\^not'), +]) +def test_tex_escape(plain_text, escaped_text): + assert _tex_escape(plain_text) == escaped_text + + +@needs_pgf_xelatex +@pytest.mark.backend('pgf') +def test_tex_special_chars(tmp_path): + fig = plt.figure() + fig.text(.5, .5, "_^ $a_b^c$") + fig.savefig(tmp_path / "test.pdf") # Should not error. + + def create_figure(): plt.figure() x = np.linspace(0, 1, 15) @@ -66,19 +76,8 @@ def create_figure(): plt.ylim(0, 1) -@pytest.mark.parametrize('plain_text, escaped_text', [ - (r'quad_sum: $\sum x_i^2$', r'quad\_sum: \(\displaystyle \sum x_i^2\)'), - (r'no \$splits \$ here', r'no \$splits \$ here'), - ('with_underscores', r'with\_underscores'), - ('% not a comment', r'\% not a comment'), - ('^not', r'\^not'), -]) -def test_common_texification(plain_text, escaped_text): - assert common_texification(plain_text) == escaped_text - - # test compiling a figure to pdf with xelatex -@needs_xelatex +@needs_pgf_xelatex @pytest.mark.backend('pgf') @image_comparison(['pgf_xelatex.pdf'], style='default') def test_xelatex(): @@ -96,7 +95,7 @@ def test_xelatex(): # test compiling a figure to pdf with pdflatex -@needs_pdflatex +@needs_pgf_pdflatex @pytest.mark.skipif(not _has_tex_package('ucs'), reason='needs ucs.sty') @pytest.mark.backend('pgf') @image_comparison(['pgf_pdflatex.pdf'], style='default', @@ -116,8 +115,8 @@ def test_pdflatex(): # test updating the rc parameters for each figure -@needs_xelatex -@needs_pdflatex +@needs_pgf_xelatex +@needs_pgf_pdflatex @mpl.style.context('default') @pytest.mark.backend('pgf') def test_rcupdate(): @@ -148,7 +147,7 @@ def test_rcupdate(): # test backend-side clipping, since large numbers are not supported by TeX -@needs_xelatex +@needs_pgf_xelatex @mpl.style.context('default') @pytest.mark.backend('pgf') def test_pathclip(): @@ -168,7 +167,7 @@ def test_pathclip(): # test mixed mode rendering -@needs_xelatex +@needs_pgf_xelatex @pytest.mark.backend('pgf') @image_comparison(['pgf_mixedmode.pdf'], style='default') def test_mixedmode(): @@ -178,7 +177,7 @@ def test_mixedmode(): # test bbox_inches clipping -@needs_xelatex +@needs_pgf_xelatex @mpl.style.context('default') @pytest.mark.backend('pgf') def test_bbox_inches(): @@ -195,9 +194,9 @@ def test_bbox_inches(): @mpl.style.context('default') @pytest.mark.backend('pgf') @pytest.mark.parametrize('system', [ - pytest.param('lualatex', marks=[needs_lualatex]), - pytest.param('pdflatex', marks=[needs_pdflatex]), - pytest.param('xelatex', marks=[needs_xelatex]), + pytest.param('lualatex', marks=[needs_pgf_lualatex]), + pytest.param('pdflatex', marks=[needs_pgf_pdflatex]), + pytest.param('xelatex', marks=[needs_pgf_xelatex]), ]) def test_pdf_pages(system): rc_pdflatex = { @@ -237,9 +236,9 @@ def test_pdf_pages(system): @mpl.style.context('default') @pytest.mark.backend('pgf') @pytest.mark.parametrize('system', [ - pytest.param('lualatex', marks=[needs_lualatex]), - pytest.param('pdflatex', marks=[needs_pdflatex]), - pytest.param('xelatex', marks=[needs_xelatex]), + pytest.param('lualatex', marks=[needs_pgf_lualatex]), + pytest.param('pdflatex', marks=[needs_pgf_pdflatex]), + pytest.param('xelatex', marks=[needs_pgf_xelatex]), ]) def test_pdf_pages_metadata_check(monkeypatch, system): # Basically the same as test_pdf_pages, but we keep it separate to leave @@ -291,7 +290,7 @@ def test_pdf_pages_metadata_check(monkeypatch, system): } -@needs_xelatex +@needs_pgf_xelatex def test_tex_restart_after_error(): fig = plt.figure() fig.suptitle(r"\oops") @@ -303,22 +302,24 @@ def test_tex_restart_after_error(): fig.savefig(BytesIO(), format="pgf") -@needs_xelatex +@needs_pgf_xelatex def test_bbox_inches_tight(): fig, ax = plt.subplots() ax.imshow([[0, 1], [2, 3]]) fig.savefig(BytesIO(), format="pdf", backend="pgf", bbox_inches="tight") -@needs_xelatex +@needs_pgf_xelatex @needs_ghostscript -def test_png(): - # Just a smoketest. - fig, ax = plt.subplots() - fig.savefig(BytesIO(), format="png", backend="pgf") +def test_png_transparency(): # Actually, also just testing that png works. + buf = BytesIO() + plt.figure().savefig(buf, format="png", backend="pgf", transparent=True) + buf.seek(0) + t = plt.imread(buf) + assert (t[..., 3] == 0).all() # fully transparent. -@needs_xelatex +@needs_pgf_xelatex def test_unknown_font(caplog): with caplog.at_level("WARNING"): mpl.rcParams["font.family"] = "this-font-does-not-exist" diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index 9223d9f85c7d..57d1172126f8 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -4,22 +4,20 @@ import re import tempfile +import numpy as np import pytest -from matplotlib import cbook, patheffects -from matplotlib.cbook import MatplotlibDeprecationWarning +from matplotlib import cbook, path, patheffects, font_manager as fm +from matplotlib._api import MatplotlibDeprecationWarning from matplotlib.figure import Figure +from matplotlib.patches import Ellipse +from matplotlib.testing._markers import needs_ghostscript, needs_usetex from matplotlib.testing.decorators import check_figures_equal, image_comparison import matplotlib as mpl +import matplotlib.collections as mcollections +import matplotlib.colors as mcolors import matplotlib.pyplot as plt -needs_ghostscript = pytest.mark.skipif( - "eps" not in mpl.testing.compare.converter, - reason="This test needs a ghostscript installation") -needs_usetex = pytest.mark.skipif( - not mpl.checkdep_usetex(True), - reason="This test needs a TeX installation") - # This tests tends to hit a TeX cache lock on AppVeyor. @pytest.mark.flaky(reruns=3) @@ -71,13 +69,6 @@ def test_savefig_to_stringio(format, use_log, rcParams, orientation): assert not s_buf.closed assert not b_buf.closed - if '\x80' in s_buf.getvalue(): - pytest.skip( - "This appears to be the result of a bug in ghostscript or one " - "of its dependencies that fails to ascii85 encode the " - "compressed fonts which results in encoding the string as " - "ascii failing just below." - ) s_val = s_buf.getvalue().encode('ascii') b_val = b_buf.getvalue() @@ -199,6 +190,18 @@ def test_type3_font(): plt.figtext(.5, .5, "I/J") +@image_comparison(["coloredhatcheszerolw.eps"]) +def test_colored_hatch_zero_linewidth(): + ax = plt.gca() + ax.add_patch(Ellipse((0, 0), 1, 1, hatch='/', facecolor='none', + edgecolor='r', linewidth=0)) + ax.add_patch(Ellipse((0.5, 0.5), 0.5, 0.5, hatch='+', facecolor='none', + edgecolor='g', linewidth=0.2)) + ax.add_patch(Ellipse((1, 1), 0.3, 0.8, hatch='\\', facecolor='none', + edgecolor='b', linewidth=0)) + ax.set_axis_off() + + @check_figures_equal(extensions=["eps"]) def test_text_clip(fig_test, fig_ref): ax = fig_test.add_subplot() @@ -254,6 +257,15 @@ def test_linedash(): assert buf.tell() > 0 +def test_empty_line(): + # Smoke-test for gh#23954 + figure = Figure() + figure.text(0.5, 0.5, "\nfoo\n\n") + buf = io.BytesIO() + figure.savefig(buf, format='eps') + figure.savefig(buf, format='ps') + + def test_no_duplicate_definition(): fig = Figure() @@ -272,3 +284,56 @@ def test_no_duplicate_definition(): if ln.startswith('/')] assert max(Counter(wds).values()) == 1 + + +@image_comparison(["multi_font_type3.eps"], tol=0.51) +def test_multi_font_type3(): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27) + plt.rc('ps', fonttype=3) + + fig = plt.figure() + fig.text(0.15, 0.475, "There are 几个汉字 in between!") + + +@image_comparison(["multi_font_type42.eps"], tol=1.6) +def test_multi_font_type42(): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27) + plt.rc('ps', fonttype=42) + + fig = plt.figure() + fig.text(0.15, 0.475, "There are 几个汉字 in between!") + + +@image_comparison(["scatter.eps"]) +def test_path_collection(): + rng = np.random.default_rng(19680801) + xvals = rng.uniform(0, 1, 10) + yvals = rng.uniform(0, 1, 10) + sizes = rng.uniform(30, 100, 10) + fig, ax = plt.subplots() + ax.scatter(xvals, yvals, sizes, edgecolor=[0.9, 0.2, 0.1], marker='<') + ax.set_axis_off() + paths = [path.Path.unit_regular_polygon(i) for i in range(3, 7)] + offsets = rng.uniform(0, 200, 20).reshape(10, 2) + sizes = [0.02, 0.04] + pc = mcollections.PathCollection(paths, sizes, zorder=-1, + facecolors='yellow', offsets=offsets) + ax.add_collection(pc) + ax.set_xlim(0, 1) + + +@image_comparison(["colorbar_shift.eps"], savefig_kwarg={"bbox_inches": "tight"}, + style="mpl20") +def test_colorbar_shift(tmp_path): + cmap = mcolors.ListedColormap(["r", "g", "b"]) + norm = mcolors.BoundaryNorm([-1, -0.5, 0.5, 1], cmap.N) + plt.scatter([0, 1], [1, 1], c=[0, 1], cmap=cmap, norm=norm) + plt.colorbar() diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 7bdc01fe0223..f79546323c47 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -18,7 +18,7 @@ try: - from matplotlib.backends.qt_compat import QtGui, QtWidgets + from matplotlib.backends.qt_compat import QtGui, QtWidgets # noqa from matplotlib.backends.qt_editor import _formlayout except ImportError: pytestmark = pytest.mark.skip('No usable Qt bindings') @@ -29,7 +29,6 @@ @pytest.fixture def qt_core(request): - backend, = request.node.get_closest_marker('backend').args qt_compat = pytest.importorskip('matplotlib.backends.qt_compat') QtCore = qt_compat.QtCore @@ -88,9 +87,9 @@ def _test_sigint_impl(backend, target_name, kwargs): import threading plt.switch_backend(backend) - from matplotlib.backends.qt_compat import QtCore + from matplotlib.backends.qt_compat import QtCore # noqa - def interupter(): + def interrupter(): if sys.platform == 'win32': import win32api win32api.GenerateConsoleCtrlEvent(0, 0) @@ -99,7 +98,7 @@ def interupter(): os.kill(os.getpid(), signal.SIGINT) target = getattr(plt, target_name) - timer = threading.Timer(1, interupter) + timer = threading.Timer(1, interrupter) fig = plt.figure() fig.canvas.mpl_connect( 'draw_event', @@ -139,10 +138,9 @@ def test_sigint(target, kwargs): def _test_other_signal_before_sigint_impl(backend, target_name, kwargs): import signal - import sys import matplotlib.pyplot as plt plt.switch_backend(backend) - from matplotlib.backends.qt_compat import QtCore + from matplotlib.backends.qt_compat import QtCore # noqa target = getattr(plt, target_name) @@ -193,7 +191,7 @@ def test_other_signal_before_sigint(target, kwargs): plt.figure() -@pytest.mark.backend('Qt5Agg') +@pytest.mark.backend('Qt5Agg', skip_on_importerror=True) def test_fig_sigint_override(qt_core): from matplotlib.backends.backend_qt5 import _BackendQT5 # Create a figure @@ -302,7 +300,7 @@ def custom_handler(signum, frame): 'QtAgg', marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)), ]) -def test_correct_key(backend, qt_core, qt_key, qt_mods, answer): +def test_correct_key(backend, qt_core, qt_key, qt_mods, answer, monkeypatch): """ Make a figure. Send a key_press_event event (using non-public, qtX backend specific api). @@ -323,7 +321,9 @@ def test_correct_key(backend, qt_core, qt_key, qt_mods, answer): class _Event: def isAutoRepeat(self): return False def key(self): return _to_int(getattr(_enum("QtCore.Qt.Key"), qt_key)) - def modifiers(self): return qt_mod + + monkeypatch.setattr(QtWidgets.QApplication, "keyboardModifiers", + lambda self: qt_mod) def on_key_press(event): nonlocal result @@ -499,7 +499,7 @@ def test_form_widget_get_with_datetime_and_date_fields(): def _test_enums_impl(): import sys - from matplotlib.backends.qt_compat import _enum, _to_int, QtCore + from matplotlib.backends.qt_compat import _enum, _to_int from matplotlib.backend_bases import cursors, MouseButton _enum("QtGui.QDoubleValidator.State").Acceptable @@ -512,7 +512,7 @@ def _test_enums_impl(): _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied _enum("QtGui.QImage.Format").Format_ARGB32_Premultiplied - # SPECIAL_KEYS are Qt::Key that do *not* return their unicode name instead + # SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name instead # they have manually specified names. SPECIAL_KEYS = { _to_int(getattr(_enum("QtCore.Qt.Key"), k)): v diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 353603a9c093..e99a5aadcc51 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -1,22 +1,21 @@ import datetime from io import BytesIO -import tempfile +from pathlib import Path import xml.etree.ElementTree import xml.parsers.expat -import numpy as np import pytest +import numpy as np + import matplotlib as mpl -from matplotlib import dviread from matplotlib.figure import Figure +from matplotlib.text import Text import matplotlib.pyplot as plt -from matplotlib.testing.decorators import image_comparison, check_figures_equal - - -needs_usetex = pytest.mark.skipif( - not mpl.checkdep_usetex(True), - reason="This test needs a TeX installation") +from matplotlib.testing.decorators import check_figures_equal, image_comparison +from matplotlib.testing._markers import needs_usetex +from matplotlib import font_manager as fm +from matplotlib.offsetbox import (OffsetImage, AnnotationBbox) def test_visibility(): @@ -124,6 +123,27 @@ def test_rasterized_ordering(fig_test, fig_ref): ax_test.plot(x+1, y, "-", c="b", lw=10, rasterized=False, zorder=1.2) +@check_figures_equal(tol=5, extensions=['svg', 'pdf']) +def test_prevent_rasterization(fig_test, fig_ref): + loc = [0.05, 0.05] + + ax_ref = fig_ref.subplots() + + ax_ref.plot([loc[0]], [loc[1]], marker="x", c="black", zorder=2) + + b = mpl.offsetbox.TextArea("X") + abox = mpl.offsetbox.AnnotationBbox(b, loc, zorder=2.1) + ax_ref.add_artist(abox) + + ax_test = fig_test.subplots() + ax_test.plot([loc[0]], [loc[1]], marker="x", c="black", zorder=2, + rasterized=True) + + b = mpl.offsetbox.TextArea("X") + abox = mpl.offsetbox.AnnotationBbox(b, loc, zorder=2.1) + ax_test.add_artist(abox) + + def test_count_bitmaps(): def count_tag(fig, tag): with BytesIO() as fd: @@ -181,22 +201,6 @@ def count_tag(fig, tag): assert count_tag(fig5, "path") == 1 # axis patch -@needs_usetex -def test_missing_psfont(monkeypatch): - """An error is raised if a TeX font lacks a Type-1 equivalent""" - - def psfont(*args, **kwargs): - return dviread.PsFont(texname='texfont', psname='Some Font', - effects=None, encoding=None, filename=None) - - monkeypatch.setattr(dviread.PsfontsMap, '__getitem__', psfont) - mpl.rc('text', usetex=True) - fig, ax = plt.subplots() - ax.text(0.5, 0.5, 'hello') - with tempfile.TemporaryFile() as tmpfile, pytest.raises(ValueError): - fig.savefig(tmpfile, format='svg') - - # Use Computer Modern Sans Serif, not Helvetica (which has no \textwon). @mpl.style.context('default') @needs_usetex @@ -270,7 +274,7 @@ def include(gid, obj): # we need to exclude certain objects which will not appear in the svg if isinstance(obj, OffsetBox): return False - if isinstance(obj, plt.Text): + if isinstance(obj, Text): if obj.get_text() == "": return False elif obj.axes is None: @@ -308,17 +312,17 @@ def test_url(): # collections s = ax.scatter([1, 2, 3], [4, 5, 6]) - s.set_urls(['http://example.com/foo', 'http://example.com/bar', None]) + s.set_urls(['https://example.com/foo', 'https://example.com/bar', None]) # Line2D p, = plt.plot([1, 3], [6, 5]) - p.set_url('http://example.com/baz') + p.set_url('https://example.com/baz') b = BytesIO() fig.savefig(b, format='svg') b = b.getvalue() for v in [b'foo', b'bar', b'baz']: - assert b'http://example.com/' + v in b + assert b'https://example.com/' + v in b def test_url_tick(monkeypatch): @@ -327,13 +331,13 @@ def test_url_tick(monkeypatch): fig1, ax = plt.subplots() ax.scatter([1, 2, 3], [4, 5, 6]) for i, tick in enumerate(ax.yaxis.get_major_ticks()): - tick.set_url(f'http://example.com/{i}') + tick.set_url(f'https://example.com/{i}') fig2, ax = plt.subplots() ax.scatter([1, 2, 3], [4, 5, 6]) for i, tick in enumerate(ax.yaxis.get_major_ticks()): - tick.label1.set_url(f'http://example.com/{i}') - tick.label2.set_url(f'http://example.com/{i}') + tick.label1.set_url(f'https://example.com/{i}') + tick.label2.set_url(f'https://example.com/{i}') b1 = BytesIO() fig1.savefig(b1, format='svg') @@ -344,7 +348,7 @@ def test_url_tick(monkeypatch): b2 = b2.getvalue() for i in range(len(ax.yaxis.get_major_ticks())): - assert f'http://example.com/{i}'.encode('ascii') in b1 + assert f'https://example.com/{i}'.encode('ascii') in b1 assert b1 == b2 @@ -443,7 +447,7 @@ def test_svg_metadata(): **{k: [f'{k} bar', f'{k} baz'] for k in multi_value}, } - fig, ax = plt.subplots() + fig = plt.figure() with BytesIO() as fd: fig.savefig(fd, format='svg', metadata=metadata) buf = fd.getvalue().decode() @@ -486,3 +490,154 @@ def test_svg_metadata(): values = [node.text for node in rdf.findall(f'./{CCNS}Work/{DCNS}subject/{RDFNS}Bag/{RDFNS}li')] assert values == metadata['Keywords'] + + +@image_comparison(["multi_font_aspath.svg"], tol=1.8) +def test_multi_font_type3(): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27) + plt.rc('svg', fonttype='path') + + fig = plt.figure() + fig.text(0.15, 0.475, "There are 几个汉字 in between!") + + +@image_comparison(["multi_font_astext.svg"]) +def test_multi_font_type42(): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + fig = plt.figure() + plt.rc('svg', fonttype='none') + + plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27) + fig.text(0.15, 0.475, "There are 几个汉字 in between!") + + +@pytest.mark.parametrize('metadata,error,message', [ + ({'Date': 1}, TypeError, "Invalid type for Date metadata. Expected str"), + ({'Date': [1]}, TypeError, + "Invalid type for Date metadata. Expected iterable"), + ({'Keywords': 1}, TypeError, + "Invalid type for Keywords metadata. Expected str"), + ({'Keywords': [1]}, TypeError, + "Invalid type for Keywords metadata. Expected iterable"), + ({'Creator': 1}, TypeError, + "Invalid type for Creator metadata. Expected str"), + ({'Creator': [1]}, TypeError, + "Invalid type for Creator metadata. Expected iterable"), + ({'Title': 1}, TypeError, + "Invalid type for Title metadata. Expected str"), + ({'Format': 1}, TypeError, + "Invalid type for Format metadata. Expected str"), + ({'Foo': 'Bar'}, ValueError, "Unknown metadata key"), + ]) +def test_svg_incorrect_metadata(metadata, error, message): + with pytest.raises(error, match=message), BytesIO() as fd: + fig = plt.figure() + fig.savefig(fd, format='svg', metadata=metadata) + + +def test_svg_escape(): + fig = plt.figure() + fig.text(0.5, 0.5, "<\'\"&>", gid="<\'\"&>") + with BytesIO() as fd: + fig.savefig(fd, format='svg') + buf = fd.getvalue().decode() + assert '<'"&>"' in buf + + +@pytest.mark.parametrize("font_str", [ + "'DejaVu Sans', 'WenQuanYi Zen Hei', 'Arial', sans-serif", + "'DejaVu Serif', 'WenQuanYi Zen Hei', 'Times New Roman', serif", + "'Arial', 'WenQuanYi Zen Hei', cursive", + "'Impact', 'WenQuanYi Zen Hei', fantasy", + "'DejaVu Sans Mono', 'WenQuanYi Zen Hei', 'Courier New', monospace", + # These do not work because the logic to get the font metrics will not find + # WenQuanYi as the fallback logic stops with the first fallback font: + # "'DejaVu Sans Mono', 'Courier New', 'WenQuanYi Zen Hei', monospace", + # "'DejaVu Sans', 'Arial', 'WenQuanYi Zen Hei', sans-serif", + # "'DejaVu Serif', 'Times New Roman', 'WenQuanYi Zen Hei', serif", +]) +@pytest.mark.parametrize("include_generic", [True, False]) +def test_svg_font_string(font_str, include_generic): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + explicit, *rest, generic = map( + lambda x: x.strip("'"), font_str.split(", ") + ) + size = len(generic) + if include_generic: + rest = rest + [generic] + plt.rcParams[f"font.{generic}"] = rest + plt.rcParams["font.size"] = size + plt.rcParams["svg.fonttype"] = "none" + + fig, ax = plt.subplots() + if generic == "sans-serif": + generic_options = ["sans", "sans-serif", "sans serif"] + else: + generic_options = [generic] + + for generic_name in generic_options: + # test that fallback works + ax.text(0.5, 0.5, "There are 几个汉字 in between!", + family=[explicit, generic_name], ha="center") + # test deduplication works + ax.text(0.5, 0.1, "There are 几个汉字 in between!", + family=[explicit, *rest, generic_name], ha="center") + ax.axis("off") + + with BytesIO() as fd: + fig.savefig(fd, format="svg") + buf = fd.getvalue() + + tree = xml.etree.ElementTree.fromstring(buf) + ns = "http://www.w3.org/2000/svg" + text_count = 0 + for text_element in tree.findall(f".//{{{ns}}}text"): + text_count += 1 + font_info = dict( + map(lambda x: x.strip(), _.strip().split(":")) + for _ in dict(text_element.items())["style"].split(";") + )["font"] + + assert font_info == f"{size}px {font_str}" + assert text_count == len(ax.texts) + + +def test_annotationbbox_gid(): + # Test that object gid appears in the AnnotationBbox + # in output svg. + fig = plt.figure() + ax = fig.add_subplot() + arr_img = np.ones((32, 32)) + xy = (0.3, 0.55) + + imagebox = OffsetImage(arr_img, zoom=0.1) + imagebox.image.axes = ax + + ab = AnnotationBbox(imagebox, xy, + xybox=(120., -80.), + xycoords='data', + boxcoords="offset points", + pad=0.5, + arrowprops=dict( + arrowstyle="->", + connectionstyle="angle,angleA=0,angleB=90,rad=3") + ) + ab.set_gid("a test for issue 20044") + ax.add_artist(ab) + + with BytesIO() as fd: + fig.savefig(fd, format='svg') + buf = fd.getvalue().decode('utf-8') + + expected = '' + assert expected in buf diff --git a/lib/matplotlib/tests/test_backend_template.py b/lib/matplotlib/tests/test_backend_template.py new file mode 100644 index 000000000000..d7e2a5cd1266 --- /dev/null +++ b/lib/matplotlib/tests/test_backend_template.py @@ -0,0 +1,51 @@ +""" +Backend-loading machinery tests, using variations on the template backend. +""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import matplotlib as mpl +from matplotlib import pyplot as plt +from matplotlib.backends import backend_template +from matplotlib.backends.backend_template import ( + FigureCanvasTemplate, FigureManagerTemplate) + + +def test_load_template(): + mpl.use("template") + assert type(plt.figure().canvas) == FigureCanvasTemplate + + +def test_load_old_api(monkeypatch): + mpl_test_backend = SimpleNamespace(**vars(backend_template)) + mpl_test_backend.new_figure_manager = ( + lambda num, *args, FigureClass=mpl.figure.Figure, **kwargs: + FigureManagerTemplate( + FigureCanvasTemplate(FigureClass(*args, **kwargs)), num)) + monkeypatch.setitem(sys.modules, "mpl_test_backend", mpl_test_backend) + mpl.use("module://mpl_test_backend") + assert type(plt.figure().canvas) == FigureCanvasTemplate + plt.draw_if_interactive() + + +def test_show(monkeypatch): + mpl_test_backend = SimpleNamespace(**vars(backend_template)) + mock_show = MagicMock() + monkeypatch.setattr( + mpl_test_backend.FigureManagerTemplate, "pyplot_show", mock_show) + monkeypatch.setitem(sys.modules, "mpl_test_backend", mpl_test_backend) + mpl.use("module://mpl_test_backend") + plt.show() + mock_show.assert_called_with() + + +def test_show_old_global_api(monkeypatch): + mpl_test_backend = SimpleNamespace(**vars(backend_template)) + mock_show = MagicMock() + monkeypatch.setattr(mpl_test_backend, "show", mock_show, raising=False) + monkeypatch.setitem(sys.modules, "mpl_test_backend", mpl_test_backend) + mpl.use("module://mpl_test_backend") + plt.show() + mock_show.assert_called_with() diff --git a/lib/matplotlib/tests/test_backend_tk.py b/lib/matplotlib/tests/test_backend_tk.py index e6f78e435138..55a7ae0b51aa 100644 --- a/lib/matplotlib/tests/test_backend_tk.py +++ b/lib/matplotlib/tests/test_backend_tk.py @@ -1,13 +1,16 @@ import functools -import inspect +import importlib import os import platform -import re import subprocess import sys import pytest +from matplotlib import _c_internal_utils +from matplotlib.testing import subprocess_run_helper + + _test_timeout = 60 # A reasonably safe value for slower architectures. @@ -18,31 +21,32 @@ def _isolated_tk_test(success_count, func=None): TkAgg tests seem to have interactions between tests, so isolate each test in a subprocess. See GH#18261 - - The decorated function must be fully self-contained, and thus perform - all the imports it needs. Because its source is extracted and run by - itself, coverage will consider it as not being run, so it should be marked - with ``# pragma: no cover`` """ if func is None: return functools.partial(_isolated_tk_test, success_count) - # Remove decorators. - source = re.search(r"(?ms)^def .*", inspect.getsource(func)).group(0) - + if "MPL_TEST_ESCAPE_HATCH" in os.environ: + # set in subprocess_run_helper() below + return func + + @pytest.mark.skipif( + not importlib.util.find_spec('tkinter'), + reason="missing tkinter" + ) + @pytest.mark.skipif( + sys.platform == "linux" and not _c_internal_utils.display_is_valid(), + reason="$DISPLAY and $WAYLAND_DISPLAY are unset" + ) @functools.wraps(func) def test_func(): + # even if the package exists, may not actually be importable this can + # be the case on some CI systems. + pytest.importorskip('tkinter') try: - proc = subprocess.run( - [sys.executable, "-c", f"{source}\n{func.__name__}()"], - env={**os.environ, "MPLBACKEND": "TkAgg"}, - timeout=_test_timeout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - universal_newlines=True, - ) + proc = subprocess_run_helper( + func, timeout=_test_timeout, extra_env=dict( + MPLBACKEND="TkAgg", MPL_TEST_ESCAPE_HATCH="1")) except subprocess.TimeoutExpired: pytest.fail("Subprocess timed out") except subprocess.CalledProcessError as e: @@ -50,19 +54,21 @@ def test_func(): + str(e.stderr)) else: # macOS may actually emit irrelevant errors about Accelerated - # OpenGL vs. software OpenGL, so suppress them. + # OpenGL vs. software OpenGL, or some permission error on Azure, so + # suppress them. # Asserting stderr first (and printing it on failure) should be # more helpful for debugging that printing a failed success count. + ignored_lines = ["OpenGL", "CFMessagePort: bootstrap_register", + "/usr/include/servers/bootstrap_defs.h"] assert not [line for line in proc.stderr.splitlines() - if "OpenGL" not in line] + if all(msg not in line for msg in ignored_lines)] assert proc.stdout.count("success") == success_count return test_func -@pytest.mark.backend('TkAgg', skip_on_importerror=True) @_isolated_tk_test(success_count=6) # len(bad_boxes) -def test_blit(): # pragma: no cover +def test_blit(): import matplotlib.pyplot as plt import numpy as np import matplotlib.backends.backend_tkagg # noqa @@ -89,9 +95,8 @@ def test_blit(): # pragma: no cover print("success") -@pytest.mark.backend('TkAgg', skip_on_importerror=True) @_isolated_tk_test(success_count=1) -def test_figuremanager_preserves_host_mainloop(): # pragma: no cover +def test_figuremanager_preserves_host_mainloop(): import tkinter import matplotlib.pyplot as plt success = [] @@ -117,10 +122,9 @@ def legitimate_quit(): @pytest.mark.skipif(platform.python_implementation() != 'CPython', reason='PyPy does not support Tkinter threading: ' 'https://foss.heptapod.net/pypy/pypy/-/issues/1929') -@pytest.mark.backend('TkAgg', skip_on_importerror=True) @pytest.mark.flaky(reruns=3) @_isolated_tk_test(success_count=1) -def test_figuremanager_cleans_own_mainloop(): # pragma: no cover +def test_figuremanager_cleans_own_mainloop(): import tkinter import time import matplotlib.pyplot as plt @@ -148,7 +152,7 @@ def target(): @pytest.mark.backend('TkAgg', skip_on_importerror=True) @pytest.mark.flaky(reruns=3) @_isolated_tk_test(success_count=0) -def test_never_update(): # pragma: no cover +def test_never_update(): import tkinter del tkinter.Misc.update del tkinter.Misc.update_idletasks @@ -170,9 +174,8 @@ def test_never_update(): # pragma: no cover # checks them. -@pytest.mark.backend('TkAgg', skip_on_importerror=True) @_isolated_tk_test(success_count=2) -def test_missing_back_button(): # pragma: no cover +def test_missing_back_button(): import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import NavigationToolbar2Tk @@ -189,7 +192,7 @@ class Toolbar(NavigationToolbar2Tk): @pytest.mark.backend('TkAgg', skip_on_importerror=True) @_isolated_tk_test(success_count=1) -def test_canvas_focus(): # pragma: no cover +def test_canvas_focus(): import tkinter as tk import matplotlib.pyplot as plt success = [] @@ -215,3 +218,41 @@ def check_focus(): if success: print("success") + + +@_isolated_tk_test(success_count=2) +def test_embedding(): + import tkinter as tk + from matplotlib.backends.backend_tkagg import ( + FigureCanvasTkAgg, NavigationToolbar2Tk) + from matplotlib.backend_bases import key_press_handler + from matplotlib.figure import Figure + + root = tk.Tk() + + def test_figure(master): + fig = Figure() + ax = fig.add_subplot() + ax.plot([1, 2, 3]) + + canvas = FigureCanvasTkAgg(fig, master=master) + canvas.draw() + canvas.mpl_connect("key_press_event", key_press_handler) + canvas.get_tk_widget().pack(expand=True, fill="both") + + toolbar = NavigationToolbar2Tk(canvas, master, pack_toolbar=False) + toolbar.pack(expand=True, fill="x") + + canvas.get_tk_widget().forget() + toolbar.forget() + + test_figure(root) + print("success") + + # Test with a dark button color. Doesn't actually check whether the icon + # color becomes lighter, just that the code doesn't break. + + root.tk_setPalette(background="sky blue", selectColor="midnight blue", + foreground="white") + test_figure(root) + print("success") diff --git a/lib/matplotlib/tests/test_backend_webagg.py b/lib/matplotlib/tests/test_backend_webagg.py index 5c6ddfa258c0..992827863b01 100644 --- a/lib/matplotlib/tests/test_backend_webagg.py +++ b/lib/matplotlib/tests/test_backend_webagg.py @@ -2,6 +2,7 @@ import os import sys import pytest +import matplotlib.backends.backend_webagg_core @pytest.mark.parametrize("backend", ["webagg", "nbagg"]) @@ -25,3 +26,8 @@ def test_webagg_fallback(backend): ret = subprocess.call([sys.executable, "-c", test_code], env=env) assert ret == 0 + + +def test_webagg_core_no_toolbar(): + fm = matplotlib.backends.backend_webagg_core.FigureManagerWebAgg + assert fm._toolbar2_class is None diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 64a0d1ba1e93..c3b7ed206d18 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -7,13 +7,17 @@ import signal import subprocess import sys +import tempfile import time import urllib.request +from PIL import Image + import pytest import matplotlib as mpl from matplotlib import _c_internal_utils +from matplotlib.backend_tools import ToolToggleBase from matplotlib.testing import subprocess_run_helper as _run_helper @@ -59,20 +63,41 @@ def _get_testable_interactive_backends(): elif env["MPLBACKEND"].startswith('wx') and sys.platform == 'darwin': # ignore on OSX because that's currently broken (github #16849) marks.append(pytest.mark.xfail(reason='github #16849')) - envs.append(pytest.param(env, marks=marks, id=str(env))) + envs.append( + pytest.param( + {**env, 'BACKEND_DEPS': ','.join(deps)}, + marks=marks, id=str(env) + ) + ) return envs _test_timeout = 60 # A reasonably safe value for slower architectures. +def _test_toolbar_button_la_mode_icon(fig): + # test a toolbar button icon using an image in LA mode (GH issue 25174) + # create an icon in LA mode + with tempfile.TemporaryDirectory() as tempdir: + img = Image.new("LA", (26, 26)) + tmp_img_path = os.path.join(tempdir, "test_la_icon.png") + img.save(tmp_img_path) + + class CustomTool(ToolToggleBase): + image = tmp_img_path + description = "" # gtk3 backend does not allow None + + toolmanager = fig.canvas.manager.toolmanager + toolbar = fig.canvas.manager.toolbar + toolmanager.add_tool("test", CustomTool) + toolbar.add_tool("test", "group") + + # The source of this function gets extracted and run in another process, so it # must be fully self-contained. # Using a timer not only allows testing of timers (on other backends), but is -# also necessary on gtk3 and wx, where a direct call to key_press_event("q") -# from draw_event causes breakage due to the canvas widget being deleted too -# early. Also, gtk3 redefines key_press_event with a different signature, so -# we directly invoke it from the superclass instead. +# also necessary on gtk3 and wx, where directly processing a KeyEvent() for "q" +# from draw_event causes breakage as the canvas widget gets deleted too early. def _test_interactive_impl(): import importlib.util import io @@ -81,15 +106,14 @@ def _test_interactive_impl(): from unittest import TestCase import matplotlib as mpl - from matplotlib import pyplot as plt, rcParams - from matplotlib.backend_bases import FigureCanvasBase - - rcParams.update({ + from matplotlib import pyplot as plt + from matplotlib.backend_bases import KeyEvent + mpl.rcParams.update({ "webagg.open_in_browser": False, "webagg.port_retries": 1, }) - rcParams.update(json.loads(sys.argv[1])) + mpl.rcParams.update(json.loads(sys.argv[1])) backend = plt.rcParams["backend"].lower() assert_equal = TestCase().assertEqual assert_raises = TestCase().assertRaises @@ -120,7 +144,6 @@ def check_alt_backend(alt_backend): if importlib.util.find_spec("cairocffi"): check_alt_backend(backend[:-3] + "cairo") check_alt_backend("svg") - mpl.use(backend, force=True) fig, ax = plt.subplots() @@ -128,12 +151,16 @@ def check_alt_backend(alt_backend): type(fig.canvas).__module__, "matplotlib.backends.backend_{}".format(backend)) + if mpl.rcParams["toolbar"] == "toolmanager": + # test toolbar button icon LA mode see GH issue 25174 + _test_toolbar_button_la_mode_icon(fig) + ax.plot([0, 1], [2, 3]) if fig.canvas.toolbar: # i.e toolbar2. fig.canvas.toolbar.draw_rubberband(None, 1., 1, 2., 2) - timer = fig.canvas.new_timer(1.) # Test floats casting to int as needed. - timer.add_callback(FigureCanvasBase.key_press_event, fig.canvas, "q") + timer = fig.canvas.new_timer(1.) # Test that floats are cast to int. + timer.add_callback(KeyEvent("key_press_event", fig.canvas, "q")._process) # Trigger quitting upon draw. fig.canvas.mpl_connect("draw_event", lambda event: timer.start()) fig.canvas.mpl_connect("close_event", print) @@ -164,20 +191,29 @@ def test_interactive_backend(env, toolbar): if env["MPLBACKEND"] == "macosx": if toolbar == "toolmanager": pytest.skip("toolmanager is not implemented for macosx.") - proc = _run_helper(_test_interactive_impl, - json.dumps({"toolbar": toolbar}), - timeout=_test_timeout, - **env) - + if env["MPLBACKEND"] == "wx": + pytest.skip("wx backend is deprecated; tests failed on appveyor") + try: + proc = _run_helper( + _test_interactive_impl, + json.dumps({"toolbar": toolbar}), + timeout=_test_timeout, + extra_env=env, + ) + except subprocess.CalledProcessError as err: + pytest.fail( + "Subprocess failed to test intended behavior\n" + + str(err.stderr)) assert proc.stdout.count("CloseEvent") == 1 def _test_thread_impl(): from concurrent.futures import ThreadPoolExecutor - from matplotlib import pyplot as plt, rcParams + import matplotlib as mpl + from matplotlib import pyplot as plt - rcParams.update({ + mpl.rcParams.update({ "webagg.open_in_browser": False, "webagg.port_retries": 1, }) @@ -236,8 +272,7 @@ def _test_thread_impl(): @pytest.mark.parametrize("env", _thread_safe_backends) @pytest.mark.flaky(reruns=3) def test_interactive_thread_safety(env): - proc = _run_helper(_test_thread_impl, - timeout=_test_timeout, **env) + proc = _run_helper(_test_thread_impl, timeout=_test_timeout, extra_env=env) assert proc.stdout.count("CloseEvent") == 1 @@ -245,13 +280,13 @@ def _impl_test_lazy_auto_backend_selection(): import matplotlib import matplotlib.pyplot as plt # just importing pyplot should not be enough to trigger resolution - bk = dict.__getitem__(matplotlib.rcParams, 'backend') + bk = matplotlib.rcParams._get('backend') assert not isinstance(bk, str) assert plt._backend_mod is None # but actually plotting should plt.plot(5) assert plt._backend_mod is not None - bk = dict.__getitem__(matplotlib.rcParams, 'backend') + bk = matplotlib.rcParams._get('backend') assert isinstance(bk, str) @@ -269,7 +304,9 @@ def _implqt5agg(): assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules import matplotlib.backends.backend_qt5 - matplotlib.backends.backend_qt5.qApp + with pytest.warns(DeprecationWarning, + match="QtWidgets.QApplication.instance"): + matplotlib.backends.backend_qt5.qApp def _implcairo(): @@ -281,7 +318,9 @@ def _implcairo(): assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules import matplotlib.backends.backend_qt5 - matplotlib.backends.backend_qt5.qApp + with pytest.warns(DeprecationWarning, + match="QtWidgets.QApplication.instance"): + matplotlib.backends.backend_qt5.qApp def _implcore(): @@ -291,7 +330,10 @@ def _implcore(): assert 'PyQt6' not in sys.modules assert 'pyside6' not in sys.modules assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules - matplotlib.backends.backend_qt5.qApp + + with pytest.warns(DeprecationWarning, + match="QtWidgets.QApplication.instance"): + matplotlib.backends.backend_qt5.qApp def test_qt5backends_uses_qt5(): @@ -350,7 +392,11 @@ def test_cross_Qt_imports(): except subprocess.CalledProcessError as ex: # if segfault, carry on. We do try to warn the user they # are doing something that we do not expect to work - if ex.returncode == -11: + if ex.returncode == -signal.SIGSEGV: + continue + # We got the abort signal which is likely because the Qt5 / + # Qt6 cross import is unhappy, carry on. + elif ex.returncode == -signal.SIGABRT: continue raise @@ -389,22 +435,29 @@ def _lazy_headless(): import os import sys + backend, deps = sys.argv[1:] + deps = deps.split(',') + # make it look headless os.environ.pop('DISPLAY', None) os.environ.pop('WAYLAND_DISPLAY', None) + for dep in deps: + assert dep not in sys.modules # we should fast-track to Agg import matplotlib.pyplot as plt - plt.get_backend() == 'agg' - assert 'PyQt5' not in sys.modules + assert plt.get_backend() == 'agg' + for dep in deps: + assert dep not in sys.modules - # make sure we really have pyqt installed - import PyQt5 # noqa - assert 'PyQt5' in sys.modules + # make sure we really have dependencies installed + for dep in deps: + importlib.import_module(dep) + assert dep in sys.modules # try to switch and make sure we fail with ImportError try: - plt.switch_backend('qt5agg') + plt.switch_backend(backend) except ImportError: ... else: @@ -412,9 +465,99 @@ def _lazy_headless(): @pytest.mark.skipif(sys.platform != "linux", reason="this a linux-only test") +@pytest.mark.parametrize("env", _get_testable_interactive_backends()) +def test_lazy_linux_headless(env): + proc = _run_helper( + _lazy_headless, + env.pop('MPLBACKEND'), env.pop("BACKEND_DEPS"), + timeout=_test_timeout, + extra_env={**env, 'DISPLAY': '', 'WAYLAND_DISPLAY': ''} + ) + + +def _qApp_warn_impl(): + import matplotlib.backends.backend_qt + import pytest + + with pytest.warns( + DeprecationWarning, match="QtWidgets.QApplication.instance"): + matplotlib.backends.backend_qt.qApp + + @pytest.mark.backend('QtAgg', skip_on_importerror=True) -def test_lazy_linux_headless(): - proc = _run_helper(_lazy_headless, timeout=_test_timeout, MPLBACKEND="") +def test_qApp_warn(): + _run_helper(_qApp_warn_impl, timeout=_test_timeout) + + +def _test_number_of_draws_script(): + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + + # animated=True tells matplotlib to only draw the artist when we + # explicitly request it + ln, = ax.plot([0, 1], [1, 2], animated=True) + + # make sure the window is raised, but the script keeps going + plt.show(block=False) + plt.pause(0.3) + # Connect to draw_event to count the occurrences + fig.canvas.mpl_connect('draw_event', print) + + # get copy of entire figure (everything inside fig.bbox) + # sans animated artist + bg = fig.canvas.copy_from_bbox(fig.bbox) + # draw the animated artist, this uses a cached renderer + ax.draw_artist(ln) + # show the result to the screen + fig.canvas.blit(fig.bbox) + + for j in range(10): + # reset the background back in the canvas state, screen unchanged + fig.canvas.restore_region(bg) + # Create a **new** artist here, this is poor usage of blitting + # but good for testing to make sure that this doesn't create + # excessive draws + ln, = ax.plot([0, 1], [1, 2]) + # render the artist, updating the canvas state, but not the screen + ax.draw_artist(ln) + # copy the image to the GUI state, but screen might not changed yet + fig.canvas.blit(fig.bbox) + # flush any pending GUI events, re-painting the screen if needed + fig.canvas.flush_events() + + # Let the event loop process everything before leaving + plt.pause(0.1) + + +_blit_backends = _get_testable_interactive_backends() +for param in _blit_backends: + backend = param.values[0]["MPLBACKEND"] + if backend == "gtk3cairo": + # copy_from_bbox only works when rendering to an ImageSurface + param.marks.append( + pytest.mark.skip("gtk3cairo does not support blitting")) + elif backend == "gtk4cairo": + # copy_from_bbox only works when rendering to an ImageSurface + param.marks.append( + pytest.mark.skip("gtk4cairo does not support blitting")) + elif backend == "wx": + param.marks.append( + pytest.mark.skip("wx does not support blitting")) + + +@pytest.mark.parametrize("env", _blit_backends) +# subprocesses can struggle to get the display, so rerun a few times +@pytest.mark.flaky(reruns=4) +def test_blitting_events(env): + proc = _run_helper( + _test_number_of_draws_script, timeout=_test_timeout, extra_env=env) + # Count the number of draw_events we got. We could count some initial + # canvas draws (which vary in number by backend), but the critical + # check here is that it isn't 10 draws, which would be called if + # blitting is not properly implemented + ndraws = proc.stdout.count("DrawEvent") + assert 0 < ndraws < 5 # The source of this function gets extracted and run in another process, so it @@ -450,6 +593,8 @@ def _test_figure_leak(): # TODO: "0.1" memory threshold could be reduced 10x by fixing tkagg +@pytest.mark.skipif(sys.platform == "win32", + reason="appveyor tests fail; gh-22988 suggests reworking") @pytest.mark.parametrize("env", _get_testable_interactive_backends()) @pytest.mark.parametrize("time_mem", [(0.0, 2_000_000), (0.1, 30_000_000)]) def test_figure_leak_20490(env, time_mem): @@ -458,12 +603,17 @@ def test_figure_leak_20490(env, time_mem): # We haven't yet directly identified the leaks so test with a memory growth # threshold. pause_time, acceptable_memory_leakage = time_mem - if env["MPLBACKEND"] == "macosx": + if env["MPLBACKEND"] == "wx": + pytest.skip("wx backend is deprecated; tests failed on appveyor") + + if env["MPLBACKEND"] == "macosx" or ( + env["MPLBACKEND"] == "tkagg" and sys.platform == 'darwin' + ): acceptable_memory_leakage += 11_000_000 result = _run_helper( - _test_figure_leak, str(pause_time), timeout=_test_timeout, **env - ) + _test_figure_leak, str(pause_time), + timeout=_test_timeout, extra_env=env) growth = int(result.stdout) assert growth <= acceptable_memory_leakage diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index c018db5eaa01..91ff7fe20963 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -24,7 +24,7 @@ def test_bbox_inches_tight(): rows = len(data) ind = np.arange(len(col_labels)) + 0.3 # the x locations for the groups cell_text = [] - width = 0.4 # the width of the bars + width = 0.4 # the width of the bars yoff = np.zeros(len(col_labels)) # the bottom values for stacked bar chart fig, ax = plt.subplots(1, 1) @@ -43,7 +43,7 @@ def test_bbox_inches_tight(): @image_comparison(['bbox_inches_tight_suptile_legend'], - remove_text=False, savefig_kwarg={'bbox_inches': 'tight'}) + savefig_kwarg={'bbox_inches': 'tight'}) def test_bbox_inches_tight_suptile_legend(): plt.plot(np.arange(10), label='a straight line') plt.legend(bbox_to_anchor=(0.9, 1), loc='upper left') @@ -62,7 +62,7 @@ def y_formatter(y, pos): @image_comparison(['bbox_inches_tight_suptile_non_default.png'], - remove_text=False, savefig_kwarg={'bbox_inches': 'tight'}, + savefig_kwarg={'bbox_inches': 'tight'}, tol=0.1) # large tolerance because only testing clipping. def test_bbox_inches_tight_suptitle_non_default(): fig, ax = plt.subplots() @@ -146,3 +146,13 @@ def test_noop_tight_bbox(): assert (im[:, :, 3] == 255).all() assert not (im[:, :, :3] == 255).all() assert im.shape == (7, 10, 4) + + +@image_comparison(['bbox_inches_fixed_aspect'], extensions=['png'], + remove_text=True, savefig_kwarg={'bbox_inches': 'tight'}) +def test_bbox_inches_fixed_aspect(): + with plt.rc_context({'figure.constrained_layout.use': True}): + fig, ax = plt.subplots() + ax.plot([0, 1]) + ax.set_xlim(0, 1) + ax.set_aspect('equal') diff --git a/lib/matplotlib/tests/test_category.py b/lib/matplotlib/tests/test_category.py index 6eb590d6e82d..87dece6346f7 100644 --- a/lib/matplotlib/tests/test_category.py +++ b/lib/matplotlib/tests/test_category.py @@ -3,7 +3,6 @@ import numpy as np import matplotlib as mpl -from matplotlib._api import MatplotlibDeprecationWarning from matplotlib.axes import Axes import matplotlib.pyplot as plt import matplotlib.category as cat @@ -101,17 +100,6 @@ def test_convert(self, vals): def test_convert_one_string(self, value): assert self.cc.convert(value, self.unit, self.ax) == 0 - def test_convert_one_number(self): - with pytest.warns(MatplotlibDeprecationWarning): - actual = self.cc.convert(0.0, self.unit, self.ax) - np.testing.assert_allclose(actual, np.array([0.])) - - def test_convert_float_array(self): - data = np.array([1, 2, 3], dtype=float) - with pytest.warns(MatplotlibDeprecationWarning): - actual = self.cc.convert(data, self.unit, self.ax) - np.testing.assert_allclose(actual, np.array([1., 2., 3.])) - @pytest.mark.parametrize("fvals", fvalues, ids=fids) def test_convert_fail(self, fvals): with pytest.raises(TypeError): diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index ee608cfdf422..aa5c999b7079 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -4,15 +4,14 @@ from weakref import ref from unittest.mock import patch, Mock -from datetime import datetime +from datetime import datetime, date, timedelta import numpy as np from numpy.testing import (assert_array_equal, assert_approx_equal, assert_array_almost_equal) import pytest -from matplotlib import _api -import matplotlib.cbook as cbook +from matplotlib import _api, cbook import matplotlib.colors as mcolors from matplotlib.cbook import delete_masked_points @@ -52,7 +51,7 @@ def test_rgba(self): class Test_boxplot_stats: - def setup(self): + def setup_method(self): np.random.seed(937) self.nrows = 37 self.ncols = 4 @@ -143,7 +142,7 @@ def test_results_whiskers_percentiles(self): assert_array_almost_equal(res[key], value) def test_results_withlabels(self): - labels = ['Test1', 2, 'ardvark', 4] + labels = ['Test1', 2, 'Aardvark', 4] results = cbook.boxplot_stats(self.data, labels=labels) for lab, res in zip(labels, results): assert res['label'] == lab @@ -178,15 +177,15 @@ def test_boxplot_stats_autorange_false(self): class Test_callback_registry: - def setup(self): + def setup_method(self): self.signal = 'test' self.callbacks = cbook.CallbackRegistry() def connect(self, s, func, pickle): - cid = self.callbacks.connect(s, func) if pickle: - self.callbacks._pickled_cids.add(cid) - return cid + return self.callbacks.connect(s, func) + else: + return self.callbacks._connect_picklable(s, func) def disconnect(self, cid): return self.callbacks.disconnect(cid) @@ -363,6 +362,19 @@ def test_callbackregistry_custom_exception_handler(monkeypatch, cb, excp): cb.process('foo') +def test_callbackregistry_signals(): + cr = cbook.CallbackRegistry(signals=["foo"]) + results = [] + def cb(x): results.append(x) + cr.connect("foo", cb) + with pytest.raises(ValueError): + cr.connect("bar", cb) + cr.process("foo", 1) + with pytest.raises(ValueError): + cr.process("bar", 1) + assert results == [1] + + def test_callbackregistry_blocking(): # Needs an exception handler for interactive testing environments # that would only print this out instead of raising the exception @@ -396,6 +408,27 @@ def test_func2(): cb.process("test2") +@pytest.mark.parametrize('line, result', [ + ('a : no_comment', 'a : no_comment'), + ('a : "quoted str"', 'a : "quoted str"'), + ('a : "quoted str" # comment', 'a : "quoted str"'), + ('a : "#000000"', 'a : "#000000"'), + ('a : "#000000" # comment', 'a : "#000000"'), + ('a : ["#000000", "#FFFFFF"]', 'a : ["#000000", "#FFFFFF"]'), + ('a : ["#000000", "#FFFFFF"] # comment', 'a : ["#000000", "#FFFFFF"]'), + ('a : val # a comment "with quotes"', 'a : val'), + ('# only comment "with quotes" xx', ''), +]) +def test_strip_comment(line, result): + """Strip everything from the first unquoted #.""" + assert cbook._strip_comment(line) == result + + +def test_strip_comment_invalid(): + with pytest.raises(ValueError, match="Missing closing quote"): + cbook._strip_comment('grid.color: "aa') + + def test_sanitize_sequence(): d = {'a': 1, 'b': 2, 'c': 3} k = ['a', 'b', 'c'] @@ -569,7 +602,7 @@ def test_flatiter(): it = x.flat assert 0 == next(it) assert 1 == next(it) - ret = cbook.safe_first_element(it) + ret = cbook._safe_first_finite(it) assert ret == 0 assert 0 == next(it) @@ -725,7 +758,7 @@ def test_contiguous_regions(): def test_safe_first_element_pandas_series(pd): # deliberately create a pandas series with index not starting from 0 s = pd.Series(range(5), index=range(10, 15)) - actual = cbook.safe_first_element(s) + actual = cbook._safe_first_finite(s) assert actual == 0 @@ -855,3 +888,26 @@ def test_format_approx(): assert f(0.0012345600001, 5) == '0.00123' assert f(-0.0012345600001, 5) == '-0.00123' assert f(0.0012345600001, 8) == f(0.0012345600001, 10) == '0.00123456' + + +def test_safe_first_element_with_none(): + datetime_lst = [date.today() + timedelta(days=i) for i in range(10)] + datetime_lst[0] = None + actual = cbook._safe_first_finite(datetime_lst) + assert actual is not None and actual == datetime_lst[1] + + +@pytest.mark.parametrize('fmt, value, result', [ + ('%.2f m', 0.2, '0.20 m'), + ('{:.2f} m', 0.2, '0.20 m'), + ('{} m', 0.2, '0.2 m'), + ('const', 0.2, 'const'), + ('%d or {}', 0.2, '0 or {}'), + ('{{{:,.0f}}}', 2e5, '{200,000}'), + ('{:.2%}', 2/3, '66.67%'), + ('$%g', 2.54, '$2.54'), +]) +def test_auto_format_str(fmt, value, result): + """Apply *value* to the format string *fmt*.""" + assert cbook._auto_format_str(fmt, value) == result + assert cbook._auto_format_str(fmt, np.float64(value)) == result diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 75b01a2e4c86..ac1faa3c1cdc 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -1,4 +1,6 @@ +from datetime import datetime import io +import re from types import SimpleNamespace import numpy as np @@ -14,7 +16,6 @@ from matplotlib.collections import (Collection, LineCollection, EventCollection, PolyCollection) from matplotlib.testing.decorators import check_figures_equal, image_comparison -from matplotlib._api.deprecation import MatplotlibDeprecationWarning def generate_EventCollection_plot(): @@ -308,10 +309,9 @@ def test_add_collection(): # GitHub issue #1490, pull #1497. plt.figure() ax = plt.axes() - coll = ax.scatter([0, 1], [0, 1]) - ax.add_collection(coll) + ax.scatter([0, 1], [0, 1]) bounds = ax.dataLim.bounds - coll = ax.scatter([], []) + ax.scatter([], []) assert ax.dataLim.bounds == bounds @@ -385,11 +385,9 @@ def test_EllipseCollection(): hh = Y / y[-1] aa = np.ones_like(ww) * 20 # first axis is 20 degrees CCW from x axis - ec = mcollections.EllipseCollection(ww, hh, aa, - units='x', - offsets=XY, - transOffset=ax.transData, - facecolors='none') + ec = mcollections.EllipseCollection( + ww, hh, aa, units='x', offsets=XY, offset_transform=ax.transData, + facecolors='none') ax.add_collection(ec) ax.autoscale_view() @@ -405,7 +403,7 @@ def test_polycollection_close(): [[3., 0.], [3., 1.], [4., 1.], [4., 0.]]] fig = plt.figure() - ax = fig.add_axes(Axes3D(fig, auto_add_to_figure=False)) + ax = fig.add_axes(Axes3D(fig)) colors = ['r', 'g', 'b', 'y', 'k'] zpos = list(range(5)) @@ -441,7 +439,7 @@ def test_regularpolycollection_rotate(): for xy, alpha in zip(xy_points, rotations): col = mcollections.RegularPolyCollection( 4, sizes=(100,), rotation=alpha, - offsets=[xy], transOffset=ax.transData) + offsets=[xy], offset_transform=ax.transData) ax.add_collection(col, autolim=True) ax.autoscale_view() @@ -469,8 +467,8 @@ def get_transform(self): xy = [(0, 0)] # Unit square has a half-diagonal of `1/sqrt(2)`, so `pi * r**2` equals... circle_areas = [np.pi / 2] - squares = SquareCollection(sizes=circle_areas, offsets=xy, - transOffset=ax.transData) + squares = SquareCollection( + sizes=circle_areas, offsets=xy, offset_transform=ax.transData) ax.add_collection(squares, autolim=True) ax.axis([-1, 1, -1, 1]) @@ -485,6 +483,81 @@ def test_picking(): assert_array_equal(indices['ind'], [0]) +def test_quadmesh_contains(): + x = np.arange(4) + X = x[:, None] * x[None, :] + + fig, ax = plt.subplots() + mesh = ax.pcolormesh(X) + fig.draw_without_rendering() + xdata, ydata = 0.5, 0.5 + x, y = mesh.get_transform().transform((xdata, ydata)) + mouse_event = SimpleNamespace(xdata=xdata, ydata=ydata, x=x, y=y) + found, indices = mesh.contains(mouse_event) + assert found + assert_array_equal(indices['ind'], [0]) + + xdata, ydata = 1.5, 1.5 + x, y = mesh.get_transform().transform((xdata, ydata)) + mouse_event = SimpleNamespace(xdata=xdata, ydata=ydata, x=x, y=y) + found, indices = mesh.contains(mouse_event) + assert found + assert_array_equal(indices['ind'], [5]) + + +def test_quadmesh_contains_concave(): + # Test a concave polygon, V-like shape + x = [[0, -1], [1, 0]] + y = [[0, 1], [1, -1]] + fig, ax = plt.subplots() + mesh = ax.pcolormesh(x, y, [[0]]) + fig.draw_without_rendering() + # xdata, ydata, expected + points = [(-0.5, 0.25, True), # left wing + (0, 0.25, False), # between the two wings + (0.5, 0.25, True), # right wing + (0, -0.25, True), # main body + ] + for point in points: + xdata, ydata, expected = point + x, y = mesh.get_transform().transform((xdata, ydata)) + mouse_event = SimpleNamespace(xdata=xdata, ydata=ydata, x=x, y=y) + found, indices = mesh.contains(mouse_event) + assert found is expected + + +def test_quadmesh_cursor_data(): + x = np.arange(4) + X = x[:, None] * x[None, :] + + fig, ax = plt.subplots() + mesh = ax.pcolormesh(X) + # Empty array data + mesh._A = None + fig.draw_without_rendering() + xdata, ydata = 0.5, 0.5 + x, y = mesh.get_transform().transform((xdata, ydata)) + mouse_event = SimpleNamespace(xdata=xdata, ydata=ydata, x=x, y=y) + # Empty collection should return None + assert mesh.get_cursor_data(mouse_event) is None + + # Now test adding the array data, to make sure we do get a value + mesh.set_array(np.ones((X.shape))) + assert_array_equal(mesh.get_cursor_data(mouse_event), [1]) + + +def test_quadmesh_cursor_data_multiple_points(): + x = [1, 2, 1, 2] + fig, ax = plt.subplots() + mesh = ax.pcolormesh(x, x, np.ones((3, 3))) + fig.draw_without_rendering() + xdata, ydata = 1.5, 1.5 + x, y = mesh.get_transform().transform((xdata, ydata)) + mouse_event = SimpleNamespace(xdata=xdata, ydata=ydata, x=x, y=y) + # All quads are covering the same square + assert_array_equal(mesh.get_cursor_data(mouse_event), np.ones(9)) + + def test_linestyle_single_dashes(): plt.scatter([0, 1, 2], [0, 1, 2], linestyle=(0., [2., 2.])) plt.draw() @@ -498,10 +571,8 @@ def test_size_in_xy(): widths = 10, 10 coords = [(10, 10), (15, 15)] e = mcollections.EllipseCollection( - widths, heights, angles, - units='xy', - offsets=coords, - transOffset=ax.transData) + widths, heights, angles, units='xy', + offsets=coords, offset_transform=ax.transData) ax.add_collection(e) @@ -540,6 +611,12 @@ def test_lslw_bcast(): assert (col.get_linewidths() == [1, 2, 3]).all() +def test_set_wrong_linestyle(): + c = Collection() + with pytest.raises(ValueError, match="Do not know how to convert 'fuzzy'"): + c.set_linestyle('fuzzy') + + @mpl.style.context('default') def test_capstyle(): col = mcollections.PathCollection([], capstyle='round') @@ -625,7 +702,7 @@ def test_pathcollection_legend_elements(): h, l = sc.legend_elements(fmt="{x:g}") assert len(h) == 5 - assert_array_equal(np.array(l).astype(float), np.arange(5)) + assert l == ["0", "1", "2", "3", "4"] colors = np.array([line.get_color() for line in h]) colors2 = sc.cmap(np.arange(5)/4) assert_array_equal(colors, colors2) @@ -636,16 +713,14 @@ def test_pathcollection_legend_elements(): l2 = ax.legend(h2, lab2, loc=2) h, l = sc.legend_elements(prop="sizes", alpha=0.5, color="red") - alpha = np.array([line.get_alpha() for line in h]) - assert_array_equal(alpha, 0.5) - color = np.array([line.get_markerfacecolor() for line in h]) - assert_array_equal(color, "red") + assert all(line.get_alpha() == 0.5 for line in h) + assert all(line.get_markerfacecolor() == "red" for line in h) l3 = ax.legend(h, l, loc=4) h, l = sc.legend_elements(prop="sizes", num=4, fmt="{x:.2f}", func=lambda x: 2*x) actsizes = [line.get_markersize() for line in h] - labeledsizes = np.sqrt(np.array(l).astype(float)/2) + labeledsizes = np.sqrt(np.array(l, float) / 2) assert_array_almost_equal(actsizes, labeledsizes) l4 = ax.legend(h, l, loc=3) @@ -656,7 +731,7 @@ def test_pathcollection_legend_elements(): levels = [-1, 0, 55.4, 260] h6, lab6 = sc.legend_elements(num=levels, prop="sizes", fmt="{x:g}") - assert_array_equal(np.array(lab6).astype(float), levels[2:]) + assert [float(l) for l in lab6] == levels[2:] for l in [l1, l2, l3, l4]: ax.add_artist(l) @@ -742,80 +817,6 @@ def test_autolim_with_zeros(transform, expected): np.testing.assert_allclose(ax.get_xlim(), expected) -@pytest.mark.parametrize('flat_ref, kwargs', [ - (True, {}), - (False, {}), - (True, dict(antialiased=False)), - (False, dict(transform='__initialization_delayed__')), -]) -@check_figures_equal(extensions=['png']) -def test_quadmesh_deprecated_signature( - fig_test, fig_ref, flat_ref, kwargs): - # test that the new and old quadmesh signature produce the same results - # remove when the old QuadMesh.__init__ signature expires (v3.5+2) - from matplotlib.collections import QuadMesh - - x = [0, 1, 2, 3.] - y = [1, 2, 3.] - X, Y = np.meshgrid(x, y) - X += 0.2 * Y - coords = np.stack([X, Y], axis=-1) - assert coords.shape == (3, 4, 2) - C = np.linspace(0, 2, 6).reshape(2, 3) - - ax = fig_test.add_subplot() - ax.set(xlim=(0, 5), ylim=(0, 4)) - if 'transform' in kwargs: - kwargs['transform'] = mtransforms.Affine2D().scale(1.2) + ax.transData - qmesh = QuadMesh(coords, **kwargs) - qmesh.set_array(C) - ax.add_collection(qmesh) - assert qmesh._shading == 'flat' - - ax = fig_ref.add_subplot() - ax.set(xlim=(0, 5), ylim=(0, 4)) - if 'transform' in kwargs: - kwargs['transform'] = mtransforms.Affine2D().scale(1.2) + ax.transData - with pytest.warns(MatplotlibDeprecationWarning): - qmesh = QuadMesh(4 - 1, 3 - 1, - coords.copy().reshape(-1, 2) if flat_ref else coords, - **kwargs) - qmesh.set_array(C.flatten() if flat_ref else C) - ax.add_collection(qmesh) - assert qmesh._shading == 'flat' - - -@check_figures_equal(extensions=['png']) -def test_quadmesh_deprecated_positional(fig_test, fig_ref): - # test that positional parameters are still accepted with the old signature - # and work correctly - # remove when the old QuadMesh.__init__ signature expires (v3.5+2) - from matplotlib.collections import QuadMesh - - x = [0, 1, 2, 3.] - y = [1, 2, 3.] - X, Y = np.meshgrid(x, y) - X += 0.2 * Y - coords = np.stack([X, Y], axis=-1) - assert coords.shape == (3, 4, 2) - coords_flat = coords.copy().reshape(-1, 2) - C = np.linspace(0, 2, 12).reshape(3, 4) - - ax = fig_test.add_subplot() - ax.set(xlim=(0, 5), ylim=(0, 4)) - qmesh = QuadMesh(coords, antialiased=False, shading='gouraud') - qmesh.set_array(C) - ax.add_collection(qmesh) - - ax = fig_ref.add_subplot() - ax.set(xlim=(0, 5), ylim=(0, 4)) - with pytest.warns(MatplotlibDeprecationWarning): - qmesh = QuadMesh(4 - 1, 3 - 1, coords.copy().reshape(-1, 2), - False, 'gouraud') - qmesh.set_array(C) - ax.add_collection(qmesh) - - def test_quadmesh_set_array_validation(): x = np.arange(11) y = np.arange(8) @@ -823,18 +824,40 @@ def test_quadmesh_set_array_validation(): fig, ax = plt.subplots() coll = ax.pcolormesh(x, y, z) - # Test deprecated warning when faulty shape is passed. - with pytest.warns(MatplotlibDeprecationWarning): + with pytest.raises(ValueError, match=re.escape( + "For X (11) and Y (8) with flat shading, A should have shape " + "(7, 10, 3) or (7, 10, 4) or (7, 10) or (70,), not (10, 7)")): coll.set_array(z.reshape(10, 7)) z = np.arange(54).reshape((6, 9)) - with pytest.raises(TypeError, match=r"Dimensions of A \(6, 9\) " - r"are incompatible with X \(11\) and/or Y \(8\)"): + with pytest.raises(ValueError, match=re.escape( + "For X (11) and Y (8) with flat shading, A should have shape " + "(7, 10, 3) or (7, 10, 4) or (7, 10) or (70,), not (6, 9)")): coll.set_array(z) - with pytest.raises(TypeError, match=r"Dimensions of A \(54,\) " - r"are incompatible with X \(11\) and/or Y \(8\)"): + with pytest.raises(ValueError, match=re.escape( + "For X (11) and Y (8) with flat shading, A should have shape " + "(7, 10, 3) or (7, 10, 4) or (7, 10) or (70,), not (54,)")): coll.set_array(z.ravel()) + # RGB(A) tests + z = np.ones((9, 6, 3)) # RGB with wrong X/Y dims + with pytest.raises(ValueError, match=re.escape( + "For X (11) and Y (8) with flat shading, A should have shape " + "(7, 10, 3) or (7, 10, 4) or (7, 10) or (70,), not (9, 6, 3)")): + coll.set_array(z) + + z = np.ones((9, 6, 4)) # RGBA with wrong X/Y dims + with pytest.raises(ValueError, match=re.escape( + "For X (11) and Y (8) with flat shading, A should have shape " + "(7, 10, 3) or (7, 10, 4) or (7, 10) or (70,), not (9, 6, 4)")): + coll.set_array(z) + + z = np.ones((7, 10, 2)) # Right X/Y dims, bad 3rd dim + with pytest.raises(ValueError, match=re.escape( + "For X (11) and Y (8) with flat shading, A should have shape " + "(7, 10, 3) or (7, 10, 4) or (7, 10) or (70,), not (7, 10, 2)")): + coll.set_array(z) + x = np.arange(10) y = np.arange(7) z = np.random.random((7, 10)) @@ -887,7 +910,7 @@ def test_quadmesh_set_array(): def test_quadmesh_vmin_vmax(): # test when vmin/vmax on the norm changes, the quadmesh gets updated fig, ax = plt.subplots() - cmap = mpl.cm.get_cmap('plasma') + cmap = mpl.colormaps['plasma'] norm = mpl.colors.Normalize(vmin=0, vmax=1) coll = ax.pcolormesh([[1]], cmap=cmap, norm=norm) fig.canvas.draw() @@ -970,7 +993,7 @@ def test_color_logic(pcfunc): # Define 2 reference "colors" here for multiple use. face_default = mcolors.to_rgba_array(pc._get_default_facecolor()) mapped = pc.get_cmap()(pc.norm((z.ravel()))) - # Github issue #1302: + # GitHub issue #1302: assert mcolors.same_color(pc.get_edgecolor(), 'red') # Check setting attributes after initialization: pc = pcfunc(z) @@ -1031,12 +1054,12 @@ def test_color_logic(pcfunc): def test_LineCollection_args(): - with pytest.warns(MatplotlibDeprecationWarning): - lc = LineCollection(None, 2.2, 'r', zorder=3, facecolors=[0, 1, 0, 1]) - assert lc.get_linewidth()[0] == 2.2 - assert mcolors.same_color(lc.get_edgecolor(), 'r') - assert lc.get_zorder() == 3 - assert mcolors.same_color(lc.get_facecolor(), [[0, 1, 0, 1]]) + lc = LineCollection(None, linewidth=2.2, edgecolor='r', + zorder=3, facecolors=[0, 1, 0, 1]) + assert lc.get_linewidth()[0] == 2.2 + assert mcolors.same_color(lc.get_edgecolor(), 'r') + assert lc.get_zorder() == 3 + assert mcolors.same_color(lc.get_facecolor(), [[0, 1, 0, 1]]) # To avoid breaking mplot3d, LineCollection internally sets the facecolor # kwarg if it has not been specified. Hence we need the following test # for LineCollection._set_default(). @@ -1053,6 +1076,9 @@ def test_array_wrong_dimensions(): pc = plt.pcolormesh(z) pc.set_array(z) # 2D is OK for Quadmesh pc.update_scalarmappable() + # 3D RGB is OK as well + z = np.arange(36).reshape(3, 4, 3) + pc.set_array(z) def test_get_segments(): @@ -1086,15 +1112,10 @@ def test_set_offsets_late(): def test_set_offset_transform(): - with pytest.warns(MatplotlibDeprecationWarning, - match='.transOffset. without .offsets. has no effect'): - mcollections.Collection([], - transOffset=mtransforms.IdentityTransform()) - skew = mtransforms.Affine2D().skew(2, 2) - init = mcollections.Collection([], offsets=[], transOffset=skew) + init = mcollections.Collection(offset_transform=skew) - late = mcollections.Collection([]) + late = mcollections.Collection() late.set_offset_transform(skew) assert skew == init.get_offset_transform() == late.get_offset_transform() @@ -1118,3 +1139,55 @@ def test_set_offset_units(): off0 = sc.get_offsets() sc.set_offsets(list(zip(y, d))) np.testing.assert_allclose(off0, sc.get_offsets()) + + +@image_comparison(baseline_images=["test_check_masked_offsets"], + extensions=["png"], remove_text=True, style="mpl20") +def test_check_masked_offsets(): + # Check if masked data is respected by scatter + # Ref: Issue #24545 + unmasked_x = [ + datetime(2022, 12, 15, 4, 49, 52), + datetime(2022, 12, 15, 4, 49, 53), + datetime(2022, 12, 15, 4, 49, 54), + datetime(2022, 12, 15, 4, 49, 55), + datetime(2022, 12, 15, 4, 49, 56), + ] + + masked_y = np.ma.array([1, 2, 3, 4, 5], mask=[0, 1, 1, 0, 0]) + + fig, ax = plt.subplots() + ax.scatter(unmasked_x, masked_y) + + +@check_figures_equal(extensions=["png"]) +def test_masked_set_offsets(fig_ref, fig_test): + x = np.ma.array([1, 2, 3, 4, 5], mask=[0, 0, 1, 1, 0]) + y = np.arange(1, 6) + + ax_test = fig_test.add_subplot() + scat = ax_test.scatter(x, y) + scat.set_offsets(np.ma.column_stack([x, y])) + ax_test.set_xticks([]) + ax_test.set_yticks([]) + + ax_ref = fig_ref.add_subplot() + ax_ref.scatter([1, 2, 5], [1, 2, 5]) + ax_ref.set_xticks([]) + ax_ref.set_yticks([]) + + +def test_check_offsets_dtype(): + # Check that setting offsets doesn't change dtype + x = np.ma.array([1, 2, 3, 4, 5], mask=[0, 0, 1, 1, 0]) + y = np.arange(1, 6) + + fig, ax = plt.subplots() + scat = ax.scatter(x, y) + masked_offsets = np.ma.column_stack([x, y]) + scat.set_offsets(masked_offsets) + assert isinstance(scat.get_offsets(), type(masked_offsets)) + + unmasked_offsets = np.column_stack([x, y]) + scat.set_offsets(unmasked_offsets) + assert isinstance(scat.get_offsets(), type(unmasked_offsets)) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 304056f6d190..e39d0073786b 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -1,8 +1,13 @@ +import platform + import numpy as np import pytest +from matplotlib import _api from matplotlib import cm import matplotlib.colors as mcolors +import matplotlib as mpl + from matplotlib import rc_context from matplotlib.testing.decorators import image_comparison @@ -11,7 +16,7 @@ BoundaryNorm, LogNorm, PowerNorm, Normalize, NoNorm ) from matplotlib.colorbar import Colorbar -from matplotlib.ticker import FixedLocator +from matplotlib.ticker import FixedLocator, LogFormatter from matplotlib.testing.decorators import check_figures_equal @@ -24,7 +29,7 @@ def _get_cmap_norms(): colorbar_extension_length. """ # Create a colormap and specify the levels it represents. - cmap = cm.get_cmap("RdBu", lut=5) + cmap = mpl.colormaps["RdBu"].resampled(5) clevs = [-5., -2.5, -.5, .5, 1.5, 3.5] # Define norms for the colormaps. norms = dict() @@ -132,8 +137,8 @@ def test_colorbar_extension_inverted_axis(orientation, extend, expected): """Test extension color with an inverted axis""" data = np.arange(12).reshape(3, 4) fig, ax = plt.subplots() - cmap = plt.get_cmap("viridis").with_extremes(under=(0, 0, 0, 1), - over=(1, 1, 1, 1)) + cmap = mpl.colormaps["viridis"].with_extremes(under=(0, 0, 0, 1), + over=(1, 1, 1, 1)) im = ax.imshow(data, cmap=cmap) cbar = fig.colorbar(im, orientation=orientation, extend=extend) if orientation == "horizontal": @@ -211,14 +216,28 @@ def test_colorbar_positioning(use_gridspec): def test_colorbar_single_ax_panchor_false(): - # Just smoketesting that this doesn't crash. Note that this differs from - # the tests above with panchor=False because there use_gridspec is actually - # ineffective: passing *ax* as lists always disable use_gridspec. + # Note that this differs from the tests above with panchor=False because + # there use_gridspec is actually ineffective: passing *ax* as lists always + # disables use_gridspec. + ax = plt.subplot(111, anchor='N') plt.imshow([[0, 1]]) plt.colorbar(panchor=False) + assert ax.get_anchor() == 'N' + + +@pytest.mark.parametrize('constrained', [False, True], + ids=['standard', 'constrained']) +def test_colorbar_single_ax_panchor_east(constrained): + fig = plt.figure(constrained_layout=constrained) + ax = fig.add_subplot(111, anchor='N') + plt.imshow([[0, 1]]) + plt.colorbar(panchor='E') + assert ax.get_anchor() == 'E' -@image_comparison(['contour_colorbar.png'], remove_text=True) +@image_comparison( + ['contour_colorbar.png'], remove_text=True, + tol=0.01 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_contour_colorbar(): fig, ax = plt.subplots(figsize=(4, 2)) data = np.arange(1200).reshape(30, 40) - 500 @@ -256,7 +275,7 @@ def test_colorbar_single_scatter(): plt.figure() x = y = [0] z = [50] - cmap = plt.get_cmap('jet', 16) + cmap = mpl.colormaps['jet'].resampled(16) cs = plt.scatter(x, y, z, c=z, cmap=cmap) plt.colorbar(cs) @@ -268,7 +287,7 @@ def test_remove_from_figure(use_gridspec): Test `remove` with the specified ``use_gridspec`` setting """ fig, ax = plt.subplots() - sc = ax.scatter([1, 2], [3, 4], cmap="spring") + sc = ax.scatter([1, 2], [3, 4]) sc.set_array(np.array([5, 6])) pre_position = ax.get_position() cb = fig.colorbar(sc, use_gridspec=use_gridspec) @@ -284,7 +303,7 @@ def test_remove_from_figure_cl(): Test `remove` with constrained_layout """ fig, ax = plt.subplots(constrained_layout=True) - sc = ax.scatter([1, 2], [3, 4], cmap="spring") + sc = ax.scatter([1, 2], [3, 4]) sc.set_array(np.array([5, 6])) fig.draw_without_rendering() pre_position = ax.get_position() @@ -302,6 +321,15 @@ def test_colorbarbase(): Colorbar(ax, cmap=plt.cm.bone) +def test_parentless_mappable(): + pc = mpl.collections.PatchCollection([], cmap=plt.get_cmap('viridis')) + pc.set_array([]) + + with pytest.warns(_api.MatplotlibDeprecationWarning, + match='Unable to determine Axes to steal'): + plt.colorbar(pc) + + @image_comparison(['colorbar_closed_patch.png'], remove_text=True) def test_colorbar_closed_patch(): # Remove this line when this test image is regenerated. @@ -314,7 +342,7 @@ def test_colorbar_closed_patch(): ax4 = fig.add_axes([0.05, 0.25, 0.9, 0.1]) ax5 = fig.add_axes([0.05, 0.05, 0.9, 0.1]) - cmap = cm.get_cmap("RdBu", lut=5) + cmap = mpl.colormaps["RdBu"].resampled(5) im = ax1.pcolormesh(np.linspace(0, 10, 16).reshape((4, 4)), cmap=cmap) @@ -587,14 +615,15 @@ def test_colorbar_renorm(): assert np.isclose(cbar.vmax, z.max() * 1000) -def test_colorbar_format(): +@pytest.mark.parametrize('fmt', ['%4.2e', '{x:.2e}']) +def test_colorbar_format(fmt): # make sure that format is passed properly x, y = np.ogrid[-4:4:31j, -4:4:31j] z = 120000*np.exp(-x**2 - y**2) fig, ax = plt.subplots() im = ax.imshow(z) - cbar = fig.colorbar(im, format='%4.2e') + cbar = fig.colorbar(im, format=fmt) fig.canvas.draw() assert cbar.ax.yaxis.get_ticklabels()[4].get_text() == '8.00e+04' @@ -608,7 +637,7 @@ def test_colorbar_format(): im.set_norm(LogNorm(vmin=0.1, vmax=10)) fig.canvas.draw() assert (cbar.ax.yaxis.get_ticklabels()[0].get_text() == - r'$\mathdefault{10^{-2}}$') + '$\\mathdefault{10^{-2}}$') def test_colorbar_scale_reset(): @@ -652,6 +681,7 @@ def test_colorbar_inverted_ticks(): cbar.minorticks_on() ticks = cbar.get_ticks() minorticks = cbar.get_ticks(minor=True) + assert isinstance(minorticks, np.ndarray) cbar.ax.invert_yaxis() np.testing.assert_allclose(ticks, cbar.get_ticks()) np.testing.assert_allclose(minorticks, cbar.get_ticks(minor=True)) @@ -660,7 +690,7 @@ def test_colorbar_inverted_ticks(): def test_mappable_no_alpha(): fig, ax = plt.subplots() sm = cm.ScalarMappable(norm=mcolors.Normalize(), cmap='viridis') - fig.colorbar(sm) + fig.colorbar(sm, ax=ax) sm.set_cmap('plasma') plt.draw() @@ -698,6 +728,17 @@ def test_colorbar_label(): assert cbar3.ax.get_xlabel() == 'horizontal cbar' +@image_comparison(['colorbar_keeping_xlabel.png'], style='mpl20') +def test_keeping_xlabel(): + # github issue #23398 - xlabels being ignored in colorbar axis + arr = np.arange(25).reshape((5, 5)) + fig, ax = plt.subplots() + im = ax.imshow(arr) + cbar = plt.colorbar(im) + cbar.ax.set_xlabel('Visible Xlabel') + cbar.set_label('YLabel') + + @pytest.mark.parametrize("clim", [(-20000, 20000), (-32768, 0)]) def test_colorbar_int(clim): # Check that we cast to float early enough to not @@ -917,9 +958,86 @@ def test_proportional_colorbars(): fig.colorbar(CS3, spacing=spacings[j], ax=axs[i, j]) +@image_comparison(['extend_drawedges.png'], remove_text=True, style='mpl20') +def test_colorbar_extend_drawedges(): + params = [ + ('both', 1, [[[1.1, 0], [1.1, 1]], + [[2, 0], [2, 1]], + [[2.9, 0], [2.9, 1]]]), + ('min', 0, [[[1.1, 0], [1.1, 1]], + [[2, 0], [2, 1]]]), + ('max', 0, [[[2, 0], [2, 1]], + [[2.9, 0], [2.9, 1]]]), + ('neither', -1, [[[2, 0], [2, 1]]]), + ] + + plt.rcParams['axes.linewidth'] = 2 + + fig = plt.figure(figsize=(10, 4)) + subfigs = fig.subfigures(1, 2) + + for orientation, subfig in zip(['horizontal', 'vertical'], subfigs): + if orientation == 'horizontal': + axs = subfig.subplots(4, 1) + else: + axs = subfig.subplots(1, 4) + fig.subplots_adjust(left=0.05, bottom=0.05, right=0.95, top=0.95) + + for ax, (extend, coloroffset, res) in zip(axs, params): + cmap = mpl.colormaps["viridis"] + bounds = np.arange(5) + nb_colors = len(bounds) + coloroffset + colors = cmap(np.linspace(100, 255, nb_colors).astype(int)) + cmap, norm = mcolors.from_levels_and_colors(bounds, colors, + extend=extend) + + cbar = Colorbar(ax, cmap=cmap, norm=norm, orientation=orientation, + drawedges=True) + # Set limits such that only two colours are visible, and the + # dividers would be outside the Axes, to ensure that a) they are + # not drawn outside, and b) a divider still appears between the + # main colour and the extension. + if orientation == 'horizontal': + ax.set_xlim(1.1, 2.9) + else: + ax.set_ylim(1.1, 2.9) + res = np.array(res)[:, :, [1, 0]] + np.testing.assert_array_equal(cbar.dividers.get_segments(), res) + + +@image_comparison(['contourf_extend_patches.png'], remove_text=True, + style='mpl20') +def test_colorbar_contourf_extend_patches(): + params = [ + ('both', 5, ['\\', '//']), + ('min', 7, ['+']), + ('max', 2, ['|', '-', '/', '\\', '//']), + ('neither', 10, ['//', '\\', '||']), + ] + + plt.rcParams['axes.linewidth'] = 2 + + fig = plt.figure(figsize=(10, 4)) + subfigs = fig.subfigures(1, 2) + fig.subplots_adjust(left=0.05, bottom=0.05, right=0.95, top=0.95) + + x = np.linspace(-2, 3, 50) + y = np.linspace(-2, 3, 30) + z = np.cos(x[np.newaxis, :]) + np.sin(y[:, np.newaxis]) + + cmap = mpl.colormaps["viridis"] + for orientation, subfig in zip(['horizontal', 'vertical'], subfigs): + axs = subfig.subplots(2, 2).ravel() + for ax, (extend, levels, hatches) in zip(axs, params): + cs = ax.contourf(x, y, z, levels, hatches=hatches, + cmap=cmap, extend=extend) + subfig.colorbar(cs, ax=ax, orientation=orientation, fraction=0.4, + extendfrac=0.2, aspect=5) + + def test_negative_boundarynorm(): fig, ax = plt.subplots(figsize=(1, 3)) - cmap = plt.get_cmap("viridis") + cmap = mpl.colormaps["viridis"] clevs = np.arange(-94, -85) norm = BoundaryNorm(clevs, cmap.N) @@ -946,8 +1064,18 @@ def test_negative_boundarynorm(): np.testing.assert_allclose(cb.ax.get_yticks(), clevs) -@image_comparison(['nonorm_colorbars.svg'], remove_text=False, - style='mpl20') +def test_centerednorm(): + # Test default centered norm gets expanded with non-singular limits + # when plot data is all equal (autoscale halfrange == 0) + fig, ax = plt.subplots(figsize=(1, 3)) + + norm = mcolors.CenteredNorm() + mappable = ax.pcolormesh(np.zeros((3, 3)), norm=norm) + fig.colorbar(mappable) + assert (norm.vmin, norm.vmax) == (-0.1, 0.1) + + +@image_comparison(['nonorm_colorbars.svg'], style='mpl20') def test_nonorm(): plt.rcParams['svg.fonttype'] = 'none' data = [1, 2, 3, 4, 5] @@ -956,7 +1084,7 @@ def test_nonorm(): fig.subplots_adjust(bottom=0.5) norm = NoNorm(vmin=min(data), vmax=max(data)) - cmap = cm.get_cmap("viridis", len(data)) + cmap = mpl.colormaps["viridis"].resampled(len(data)) mappable = cm.ScalarMappable(norm=norm, cmap=cmap) cbar = fig.colorbar(mappable, cax=ax, orientation="horizontal") @@ -970,6 +1098,55 @@ def test_boundaries(): cb = fig.colorbar(pc, ax=ax, boundaries=np.linspace(-3, 3, 7)) +def test_colorbar_no_warning_rcparams_grid_true(): + # github issue #21723 - If mpl style has 'axes.grid' = True, + # fig.colorbar raises a warning about Auto-removal of grids + # by pcolor() and pcolormesh(). This is fixed by PR #22216. + plt.rcParams['axes.grid'] = True + fig, ax = plt.subplots() + ax.grid(False) + im = ax.pcolormesh([0, 1], [0, 1], [[1]]) + # make sure that no warning is raised by fig.colorbar + fig.colorbar(im) + + +def test_colorbar_set_formatter_locator(): + # check that the locator properties echo what is on the axis: + fig, ax = plt.subplots() + pc = ax.pcolormesh(np.random.randn(10, 10)) + cb = fig.colorbar(pc) + cb.ax.yaxis.set_major_locator(FixedLocator(np.arange(10))) + cb.ax.yaxis.set_minor_locator(FixedLocator(np.arange(0, 10, 0.2))) + assert cb.locator is cb.ax.yaxis.get_major_locator() + assert cb.minorlocator is cb.ax.yaxis.get_minor_locator() + cb.ax.yaxis.set_major_formatter(LogFormatter()) + cb.ax.yaxis.set_minor_formatter(LogFormatter()) + assert cb.formatter is cb.ax.yaxis.get_major_formatter() + assert cb.minorformatter is cb.ax.yaxis.get_minor_formatter() + + # check that the setter works as expected: + loc = FixedLocator(np.arange(7)) + cb.locator = loc + assert cb.ax.yaxis.get_major_locator() is loc + loc = FixedLocator(np.arange(0, 7, 0.1)) + cb.minorlocator = loc + assert cb.ax.yaxis.get_minor_locator() is loc + fmt = LogFormatter() + cb.formatter = fmt + assert cb.ax.yaxis.get_major_formatter() is fmt + fmt = LogFormatter() + cb.minorformatter = fmt + assert cb.ax.yaxis.get_minor_formatter() is fmt + + +@image_comparison(['colorbar_extend_alpha.png'], remove_text=True, + savefig_kwarg={'dpi': 40}) +def test_colorbar_extend_alpha(): + fig, ax = plt.subplots() + im = ax.imshow([[0, 1], [2, 3]], alpha=0.3, interpolation="none") + fig.colorbar(im, extend='both', boundaries=[0.5, 1.5, 2.5]) + + def test_offset_text_loc(): plt.style.use('mpl20') fig, ax = plt.subplots() @@ -995,3 +1172,46 @@ def test_title_text_loc(): # colorbar axes, including its extend triangles.... assert (cb.ax.title.get_window_extent(fig.canvas.get_renderer()).ymax > cb.ax.spines['outline'].get_window_extent().ymax) + + +@check_figures_equal(extensions=["png"]) +def test_passing_location(fig_ref, fig_test): + ax_ref = fig_ref.add_subplot() + im = ax_ref.imshow([[0, 1], [2, 3]]) + ax_ref.figure.colorbar(im, cax=ax_ref.inset_axes([0, 1.05, 1, 0.05]), + orientation="horizontal", ticklocation="top") + ax_test = fig_test.add_subplot() + im = ax_test.imshow([[0, 1], [2, 3]]) + ax_test.figure.colorbar(im, cax=ax_test.inset_axes([0, 1.05, 1, 0.05]), + location="top") + + +@pytest.mark.parametrize("kwargs,error,message", [ + ({'location': 'top', 'orientation': 'vertical'}, TypeError, + "location and orientation are mutually exclusive"), + ({'location': 'top', 'orientation': 'vertical', 'cax': True}, TypeError, + "location and orientation are mutually exclusive"), # Different to above + ({'ticklocation': 'top', 'orientation': 'vertical', 'cax': True}, + ValueError, "'top' is not a valid value for position"), + ({'location': 'top', 'extendfrac': (0, None)}, ValueError, + "invalid value for extendfrac"), + ]) +def test_colorbar_errors(kwargs, error, message): + fig, ax = plt.subplots() + im = ax.imshow([[0, 1], [2, 3]]) + if kwargs.get('cax', None) is True: + kwargs['cax'] = ax.inset_axes([0, 1.05, 1, 0.05]) + with pytest.raises(error, match=message): + fig.colorbar(im, **kwargs) + + +def test_colorbar_axes_parmeters(): + fig, ax = plt.subplots(2) + im = ax[0].imshow([[0, 1], [2, 3]]) + # colorbar should accept any form of axes sequence: + fig.colorbar(im, ax=ax) + fig.colorbar(im, ax=ax[0]) + fig.colorbar(im, ax=[_ax for _ax in ax]) + fig.colorbar(im, ax=(ax[0], ax[1])) + fig.colorbar(im, ax={i: _ax for i, _ax in enumerate(ax)}.values()) + fig.draw_without_rendering() diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 4fa65918e7fa..e40796caa7cc 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -10,12 +10,11 @@ from numpy.testing import assert_array_equal, assert_array_almost_equal -from matplotlib import cycler +from matplotlib import cbook, cm, cycler import matplotlib +import matplotlib as mpl import matplotlib.colors as mcolors -import matplotlib.cm as cm import matplotlib.colorbar as mcolorbar -import matplotlib.cbook as cbook import matplotlib.pyplot as plt import matplotlib.scale as mscale from matplotlib.testing.decorators import image_comparison, check_figures_equal @@ -31,9 +30,16 @@ def test_create_lookup_table(N, result): assert_array_almost_equal(mcolors._create_lookup_table(N, data), result) -def test_resample(): +@pytest.mark.parametrize("dtype", [np.uint8, int, np.float16, float]) +def test_index_dtype(dtype): + # We use subtraction in the indexing, so need to verify that uint8 works + cm = mpl.colormaps["viridis"] + assert_array_equal(cm(dtype(0)), cm(0)) + + +def test_resampled(): """ - GitHub issue #6025 pointed to incorrect ListedColormap._resample; + GitHub issue #6025 pointed to incorrect ListedColormap.resampled; here we test the method for LinearSegmentedColormap as well. """ n = 101 @@ -49,8 +55,8 @@ def test_resample(): cmap.set_under('r') cmap.set_over('g') cmap.set_bad('b') - lsc3 = lsc._resample(3) - lc3 = lc._resample(3) + lsc3 = lsc.resampled(3) + lc3 = lc.resampled(3) expected = np.array([[0.0, 0.2, 1.0, 0.7], [0.5, 0.2, 0.5, 0.7], [1.0, 0.2, 0.0, 0.7]], float) @@ -66,79 +72,91 @@ def test_resample(): def test_register_cmap(): - new_cm = copy.copy(cm.get_cmap("viridis")) + new_cm = mpl.colormaps["viridis"] target = "viridis2" - cm.register_cmap(target, new_cm) - assert plt.get_cmap(target) == new_cm + with pytest.warns( + mpl.MatplotlibDeprecationWarning, + match=r"matplotlib\.colormaps\.register\(name\)" + ): + cm.register_cmap(target, new_cm) + assert mpl.colormaps[target] == new_cm with pytest.raises(ValueError, match="Arguments must include a name or a Colormap"): - cm.register_cmap() - - with pytest.warns(UserWarning): - cm.register_cmap(target, new_cm) - - cm.unregister_cmap(target) + with pytest.warns( + mpl.MatplotlibDeprecationWarning, + match=r"matplotlib\.colormaps\.register\(name\)" + ): + cm.register_cmap() + + with pytest.warns( + mpl.MatplotlibDeprecationWarning, + match=r"matplotlib\.colormaps\.unregister\(name\)" + ): + cm.unregister_cmap(target) with pytest.raises(ValueError, match=f'{target!r} is not a valid value for name;'): - cm.get_cmap(target) - # test that second time is error free - cm.unregister_cmap(target) - - with pytest.raises(ValueError, match="You must pass a Colormap instance."): - cm.register_cmap('nome', cmap='not a cmap') + with pytest.warns( + mpl.MatplotlibDeprecationWarning, + match=r"matplotlib\.colormaps\[name\]" + ): + cm.get_cmap(target) + with pytest.warns( + mpl.MatplotlibDeprecationWarning, + match=r"matplotlib\.colormaps\.unregister\(name\)" + ): + # test that second time is error free + cm.unregister_cmap(target) + + with pytest.raises(TypeError, match="'cmap' must be"): + with pytest.warns( + mpl.MatplotlibDeprecationWarning, + match=r"matplotlib\.colormaps\.register\(name\)" + ): + cm.register_cmap('nome', cmap='not a cmap') + + +def test_colormaps_get_cmap(): + cr = mpl.colormaps + + # check str, and Colormap pass + assert cr.get_cmap('plasma') == cr["plasma"] + assert cr.get_cmap(cr["magma"]) == cr["magma"] + + # check default + assert cr.get_cmap(None) == cr[mpl.rcParams['image.cmap']] + + # check ValueError on bad name + bad_cmap = 'AardvarksAreAwkward' + with pytest.raises(ValueError, match=bad_cmap): + cr.get_cmap(bad_cmap) + + # check TypeError on bad type + with pytest.raises(TypeError, match='object'): + cr.get_cmap(object()) def test_double_register_builtin_cmap(): name = "viridis" - match = f"Trying to re-register the builtin cmap {name!r}." + match = f"Re-registering the builtin cmap {name!r}." with pytest.raises(ValueError, match=match): - cm.register_cmap(name, cm.get_cmap(name)) + matplotlib.colormaps.register( + mpl.colormaps[name], name=name, force=True + ) + with pytest.raises(ValueError, match='A colormap named "viridis"'): + with pytest.warns(mpl.MatplotlibDeprecationWarning): + cm.register_cmap(name, mpl.colormaps[name]) with pytest.warns(UserWarning): - cm.register_cmap(name, cm.get_cmap(name), override_builtin=True) + # TODO is warning more than once! + cm.register_cmap(name, mpl.colormaps[name], override_builtin=True) def test_unregister_builtin_cmap(): name = "viridis" match = f'cannot unregister {name!r} which is a builtin colormap.' with pytest.raises(ValueError, match=match): - cm.unregister_cmap(name) - - -def test_colormap_global_set_warn(): - new_cm = plt.get_cmap('viridis') - # Store the old value so we don't override the state later on. - orig_cmap = copy.copy(new_cm) - with pytest.warns(cbook.MatplotlibDeprecationWarning, - match="You are modifying the state of a globally"): - # This should warn now because we've modified the global state - new_cm.set_under('k') - - # This shouldn't warn because it is a copy - copy.copy(new_cm).set_under('b') - - # Test that registering and then modifying warns - plt.register_cmap(name='test_cm', cmap=copy.copy(orig_cmap)) - new_cm = plt.get_cmap('test_cm') - with pytest.warns(cbook.MatplotlibDeprecationWarning, - match="You are modifying the state of a globally"): - # This should warn now because we've modified the global state - new_cm.set_under('k') - - # Re-register the original - with pytest.warns(UserWarning): - plt.register_cmap(cmap=orig_cmap, override_builtin=True) - - -def test_colormap_dict_deprecate(): - # Make sure we warn on get and set access into cmap_d - with pytest.warns(cbook.MatplotlibDeprecationWarning, - match="The global colormaps dictionary is no longer"): - cmap = plt.cm.cmap_d['viridis'] - - with pytest.warns(cbook.MatplotlibDeprecationWarning, - match="The global colormaps dictionary is no longer"): - plt.cm.cmap_d['test'] = cmap + with pytest.warns(mpl.MatplotlibDeprecationWarning): + cm.unregister_cmap(name) def test_colormap_copy(): @@ -164,7 +182,7 @@ def test_colormap_copy(): def test_colormap_equals(): - cmap = plt.get_cmap("plasma") + cmap = mpl.colormaps["plasma"] cm_copy = cmap.copy() # different object id's assert cm_copy is not cmap @@ -192,7 +210,7 @@ def test_colormap_endian(): mapping of 1.0 when input from a non-native-byteorder array. """ - cmap = cm.get_cmap("jet") + cmap = mpl.colormaps["jet"] # Test under, over, and invalid along with values 0 and 1. a = [-0.5, 0, 0.5, 1, 1.5, np.nan] for dt in ["f2", "f4", "f8"]: @@ -207,7 +225,7 @@ def test_colormap_invalid(): rather than bad. This tests to make sure all invalid values (-inf, nan, inf) are mapped respectively to (under, bad, over). """ - cmap = cm.get_cmap("plasma") + cmap = mpl.colormaps["plasma"] x = np.array([-np.inf, -1, 0, np.nan, .7, 2, np.inf]) expected = np.array([[0.050383, 0.029803, 0.527975, 1.], @@ -232,7 +250,7 @@ def test_colormap_invalid(): # Test scalar representations assert_array_equal(cmap(-np.inf), cmap(0)) assert_array_equal(cmap(np.inf), cmap(1.0)) - assert_array_equal(cmap(np.nan), np.array([0., 0., 0., 0.])) + assert_array_equal(cmap(np.nan), [0., 0., 0., 0.]) def test_colormap_return_types(): @@ -240,7 +258,7 @@ def test_colormap_return_types(): Make sure that tuples are returned for scalar input and that the proper shapes are returned for ndarrays. """ - cmap = cm.get_cmap("plasma") + cmap = mpl.colormaps["plasma"] # Test return types and shapes # scalar input needs to return a tuple of length 4 assert isinstance(cmap(0.5), tuple) @@ -355,7 +373,7 @@ def test_BoundaryNorm(): # Testing extend keyword, with interpolation (large cmap) bounds = [1, 2, 3] - cmap = cm.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] mynorm = mcolors.BoundaryNorm(bounds, cmap.N, extend='both') refnorm = mcolors.BoundaryNorm([0] + bounds + [4], cmap.N) x = np.random.randn(100) * 10 + 2 @@ -474,11 +492,25 @@ def test_CenteredNorm(): norm(np.linspace(-1.0, 0.0, 10)) assert norm.vmax == 1.0 assert norm.halfrange == 1.0 - # set vcenter to 1, which should double halfrange + # set vcenter to 1, which should move the center but leave the + # halfrange unchanged norm.vcenter = 1 - assert norm.vmin == -1.0 - assert norm.vmax == 3.0 - assert norm.halfrange == 2.0 + assert norm.vmin == 0 + assert norm.vmax == 2 + assert norm.halfrange == 1 + + # Check setting vmin directly updates the halfrange and vmax, but + # leaves vcenter alone + norm.vmin = -1 + assert norm.halfrange == 2 + assert norm.vmax == 3 + assert norm.vcenter == 1 + + # also check vmax updates + norm.vmax = 2 + assert norm.halfrange == 1 + assert norm.vmin == 0 + assert norm.vcenter == 1 @pytest.mark.parametrize("vmin,vmax", [[-1, 2], [3, 1]]) @@ -563,19 +595,20 @@ def test_Normalize(): # i.e. 127-(-128) here). vals = np.array([-128, 127], dtype=np.int8) norm = mcolors.Normalize(vals.min(), vals.max()) - assert_array_equal(np.asarray(norm(vals)), [0, 1]) + assert_array_equal(norm(vals), [0, 1]) # Don't lose precision on longdoubles (float128 on Linux): # for array inputs... vals = np.array([1.2345678901, 9.8765432109], dtype=np.longdouble) - norm = mcolors.Normalize(vals.min(), vals.max()) - assert_array_equal(np.asarray(norm(vals)), [0, 1]) + norm = mcolors.Normalize(vals[0], vals[1]) + assert norm(vals).dtype == np.longdouble + assert_array_equal(norm(vals), [0, 1]) # and for scalar ones. eps = np.finfo(np.longdouble).resolution norm = plt.Normalize(1, 1 + 100 * eps) # This returns exactly 0.5 when longdouble is extended precision (80-bit), # but only a value close to it when it is quadruple precision (128-bit). - assert 0 < norm(1 + 50 * eps) < 1 + assert_array_almost_equal(norm(1 + 50 * eps), 0.5, decimal=3) def test_FuncNorm(): @@ -745,6 +778,28 @@ def test_SymLogNorm_single_zero(): plt.close(fig) +class TestAsinhNorm: + """ + Tests for `~.colors.AsinhNorm` + """ + + def test_init(self): + norm0 = mcolors.AsinhNorm() + assert norm0.linear_width == 1 + + norm5 = mcolors.AsinhNorm(linear_width=5) + assert norm5.linear_width == 5 + + def test_norm(self): + norm = mcolors.AsinhNorm(2, vmin=-4, vmax=4) + vals = np.arange(-3.5, 3.5, 10) + normed_vals = norm(vals) + asinh2 = np.arcsinh(2) + + expected = (2 * np.arcsinh(vals / 2) + 2 * asinh2) / (4 * asinh2) + assert_array_almost_equal(normed_vals, expected) + + def _inverse_tester(norm_instance, vals): """ Checks if the inverse of the given normalization is working. @@ -803,7 +858,7 @@ def test_boundarynorm_and_colorbarbase(): # Set the colormap and bounds bounds = [-1, 2, 5, 7, 12, 15] - cmap = cm.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] # Default behavior norm = mcolors.BoundaryNorm(bounds, cmap.N) @@ -1148,13 +1203,13 @@ def test_pandas_iterable(pd): assert_array_equal(cm1.colors, cm2.colors) -@pytest.mark.parametrize('name', sorted(plt.colormaps())) +@pytest.mark.parametrize('name', sorted(mpl.colormaps())) def test_colormap_reversing(name): """ Check the generated _lut data of a colormap and corresponding reversed colormap if they are almost the same. """ - cmap = plt.get_cmap(name) + cmap = mpl.colormaps[name] cmap_r = cmap.reversed() if not cmap_r._isinit: cmap._init() @@ -1166,6 +1221,15 @@ def test_colormap_reversing(name): assert_array_almost_equal(cmap(np.nan), cmap_r(np.nan)) +def test_has_alpha_channel(): + assert mcolors._has_alpha_channel((0, 0, 0, 0)) + assert mcolors._has_alpha_channel([1, 1, 1, 1]) + assert not mcolors._has_alpha_channel('blue') # 4-char string! + assert not mcolors._has_alpha_channel('0.25') + assert not mcolors._has_alpha_channel('r') + assert not mcolors._has_alpha_channel((1, 0, 0)) + + def test_cn(): matplotlib.rcParams['axes.prop_cycle'] = cycler('color', ['blue', 'r']) @@ -1223,8 +1287,7 @@ def test_to_rgba_array_single_str(): # single char color sequence is invalid with pytest.raises(ValueError, - match="Using a string of single character colors as " - "a color sequence is not supported."): + match="'rgb' is not a valid color value."): array = mcolors.to_rgba_array("rgb") @@ -1313,7 +1376,7 @@ def test_hex_shorthand_notation(): def test_repr_png(): - cmap = plt.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] png = cmap._repr_png_() assert len(png) > 0 img = Image.open(BytesIO(png)) @@ -1326,7 +1389,7 @@ def test_repr_png(): def test_repr_html(): - cmap = plt.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] html = cmap._repr_html_() assert len(html) > 0 png = cmap._repr_png_() @@ -1337,7 +1400,7 @@ def test_repr_html(): def test_get_under_over_bad(): - cmap = plt.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] assert_array_equal(cmap.get_under(), cmap(-np.inf)) assert_array_equal(cmap.get_over(), cmap(np.inf)) assert_array_equal(cmap.get_bad(), cmap(np.nan)) @@ -1345,7 +1408,7 @@ def test_get_under_over_bad(): @pytest.mark.parametrize('kind', ('over', 'under', 'bad')) def test_non_mutable_get_values(kind): - cmap = copy.copy(plt.get_cmap('viridis')) + cmap = copy.copy(mpl.colormaps['viridis']) init_value = getattr(cmap, f'get_{kind}')() getattr(cmap, f'set_{kind}')('k') black_value = getattr(cmap, f'get_{kind}')() @@ -1354,7 +1417,7 @@ def test_non_mutable_get_values(kind): def test_colormap_alpha_array(): - cmap = plt.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] vals = [-1, 0.5, 2] # under, valid, over with pytest.raises(ValueError, match="alpha is array-like but"): cmap(vals, alpha=[1, 1, 1, 1]) @@ -1366,7 +1429,7 @@ def test_colormap_alpha_array(): def test_colormap_bad_data_with_alpha(): - cmap = plt.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] c = cmap(np.nan, alpha=0.5) assert c == (0, 0, 0, 0) c = cmap([0.5, np.nan], alpha=0.5) @@ -1430,6 +1493,11 @@ def test_norm_callback(): norm.vmax = 5 assert increment.call_count == 2 + # We only want autoscale() calls to send out one update signal + increment.call_count = 0 + norm.autoscale([0, 1, 2]) + assert increment.call_count == 1 + def test_scalarmappable_norm_update(): norm = mcolors.Normalize() @@ -1480,4 +1548,52 @@ def test_norm_update_figs(fig_test, fig_ref): def test_make_norm_from_scale_name(): logitnorm = mcolors.make_norm_from_scale( mscale.LogitScale, mcolors.Normalize) - assert logitnorm.__name__ == "LogitScaleNorm" + assert logitnorm.__name__ == logitnorm.__qualname__ == "LogitScaleNorm" + + +def test_color_sequences(): + # basic access + assert plt.color_sequences is matplotlib.color_sequences # same registry + assert list(plt.color_sequences) == [ + 'tab10', 'tab20', 'tab20b', 'tab20c', 'Pastel1', 'Pastel2', 'Paired', + 'Accent', 'Dark2', 'Set1', 'Set2', 'Set3'] + assert len(plt.color_sequences['tab10']) == 10 + assert len(plt.color_sequences['tab20']) == 20 + + tab_colors = [ + 'tab:blue', 'tab:orange', 'tab:green', 'tab:red', 'tab:purple', + 'tab:brown', 'tab:pink', 'tab:gray', 'tab:olive', 'tab:cyan'] + for seq_color, tab_color in zip(plt.color_sequences['tab10'], tab_colors): + assert mcolors.same_color(seq_color, tab_color) + + # registering + with pytest.raises(ValueError, match="reserved name"): + plt.color_sequences.register('tab10', ['r', 'g', 'b']) + with pytest.raises(ValueError, match="not a valid color specification"): + plt.color_sequences.register('invalid', ['not a color']) + + rgb_colors = ['r', 'g', 'b'] + plt.color_sequences.register('rgb', rgb_colors) + assert plt.color_sequences['rgb'] == ['r', 'g', 'b'] + # should not affect the registered sequence because input is copied + rgb_colors.append('c') + assert plt.color_sequences['rgb'] == ['r', 'g', 'b'] + # should not affect the registered sequence because returned list is a copy + plt.color_sequences['rgb'].append('c') + assert plt.color_sequences['rgb'] == ['r', 'g', 'b'] + + # unregister + plt.color_sequences.unregister('rgb') + with pytest.raises(KeyError): + plt.color_sequences['rgb'] # rgb is gone + plt.color_sequences.unregister('rgb') # multiple unregisters are ok + with pytest.raises(ValueError, match="Cannot unregister builtin"): + plt.color_sequences.unregister('tab10') + + +def test_cm_set_cmap_error(): + sm = cm.ScalarMappable() + # Pick a name we are pretty sure will never be a colormap name + bad_cmap = 'AardvarksAreAwkward' + with pytest.raises(ValueError, match=bad_cmap): + sm.set_cmap(bad_cmap) diff --git a/lib/matplotlib/tests/test_compare_images.py b/lib/matplotlib/tests/test_compare_images.py index 95173a8860a1..6023f3d05468 100644 --- a/lib/matplotlib/tests/test_compare_images.py +++ b/lib/matplotlib/tests/test_compare_images.py @@ -4,7 +4,7 @@ import pytest from pytest import approx -from matplotlib.testing.compare import compare_images, make_test_filename +from matplotlib.testing.compare import compare_images from matplotlib.testing.decorators import _image_directories @@ -42,7 +42,8 @@ # Now test the reverse comparison. ('all128.png', 'all127.png', 0, 1), ]) -def test_image_comparison_expect_rms(im1, im2, tol, expect_rms): +def test_image_comparison_expect_rms(im1, im2, tol, expect_rms, tmp_path, + monkeypatch): """ Compare two images, expecting a particular RMS error. @@ -54,16 +55,16 @@ def test_image_comparison_expect_rms(im1, im2, tol, expect_rms): succeed if compare_images succeeds. Otherwise, the test will succeed if compare_images fails and returns an RMS error almost equal to this value. """ + # Change the working directory using monkeypatch to use a temporary + # test specific directory + monkeypatch.chdir(tmp_path) baseline_dir, result_dir = map(Path, _image_directories(lambda: "dummy")) - # Copy both "baseline" and "test" image to result_dir, so that 1) - # compare_images writes the diff to result_dir, rather than to the source - # tree and 2) the baseline image doesn't appear missing to triage_tests.py. - result_im1 = make_test_filename(result_dir / im1, "expected") - shutil.copyfile(baseline_dir / im1, result_im1) + # Copy "test" image to result_dir, so that compare_images writes + # the diff to result_dir, rather than to the source tree result_im2 = result_dir / im1 shutil.copyfile(baseline_dir / im2, result_im2) results = compare_images( - result_im1, result_im2, tol=tol, in_decorator=True) + baseline_dir / im1, result_im2, tol=tol, in_decorator=True) if expect_rms is None: assert results is None diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index 8fd3cc5a35a7..b0833052ad6e 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -1,11 +1,11 @@ import numpy as np import pytest +import matplotlib as mpl from matplotlib.testing.decorators import image_comparison import matplotlib.pyplot as plt -import matplotlib.gridspec as gridspec import matplotlib.transforms as mtransforms -from matplotlib import ticker, rcParams +from matplotlib import gridspec, ticker def example_plot(ax, fontsize=12, nodec=False): @@ -36,7 +36,7 @@ def example_pcolor(ax, fontsize=12): @image_comparison(['constrained_layout1.png']) def test_constrained_layout1(): """Test constrained_layout for a single subplot""" - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout="constrained") ax = fig.add_subplot() example_plot(ax, fontsize=24) @@ -44,7 +44,7 @@ def test_constrained_layout1(): @image_comparison(['constrained_layout2.png']) def test_constrained_layout2(): """Test constrained_layout for 2x2 subplots""" - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: example_plot(ax, fontsize=24) @@ -53,7 +53,7 @@ def test_constrained_layout2(): def test_constrained_layout3(): """Test constrained_layout for colorbars with subplots""" - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for nn, ax in enumerate(axs.flat): pcm = example_pcolor(ax, fontsize=24) if nn == 3: @@ -67,7 +67,7 @@ def test_constrained_layout3(): def test_constrained_layout4(): """Test constrained_layout for a single colorbar with subplots""" - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: pcm = example_pcolor(ax, fontsize=24) fig.colorbar(pcm, ax=axs, pad=0.01, shrink=0.6) @@ -80,7 +80,7 @@ def test_constrained_layout5(): colorbar bottom """ - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: pcm = example_pcolor(ax, fontsize=24) fig.colorbar(pcm, ax=axs, @@ -94,7 +94,7 @@ def test_constrained_layout6(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout="constrained") gs = fig.add_gridspec(1, 2, figure=fig) gsl = gs[0].subgridspec(2, 2) gsr = gs[1].subgridspec(1, 2) @@ -131,7 +131,7 @@ def test_identical_subgridspec(): axb += [fig.add_subplot(GSB[i])] fig.draw_without_rendering() - # chech first row above second + # check first row above second assert axa[0].get_position().y0 > axb[0].get_position().y1 @@ -141,7 +141,7 @@ def test_constrained_layout7(): UserWarning, match=('There are no gridspecs with layoutgrids. ' 'Possibly did not call parent GridSpec with ' 'the "figure" keyword')): - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout="constrained") gs = gridspec.GridSpec(1, 2) gsl = gridspec.GridSpecFromSubplotSpec(2, 2, gs[0]) gsr = gridspec.GridSpecFromSubplotSpec(1, 2, gs[1]) @@ -155,7 +155,7 @@ def test_constrained_layout7(): def test_constrained_layout8(): """Test for gridspecs that are not completely full""" - fig = plt.figure(figsize=(10, 5), constrained_layout=True) + fig = plt.figure(figsize=(10, 5), layout="constrained") gs = gridspec.GridSpec(3, 5, figure=fig) axs = [] for j in [0, 1]: @@ -166,7 +166,7 @@ def test_constrained_layout8(): for i in ilist: ax = fig.add_subplot(gs[j, i]) axs += [ax] - pcm = example_pcolor(ax, fontsize=9) + example_pcolor(ax, fontsize=9) if i > 0: ax.set_ylabel('') if j < 1: @@ -183,7 +183,7 @@ def test_constrained_layout8(): def test_constrained_layout9(): """Test for handling suptitle and for sharex and sharey""" - fig, axs = plt.subplots(2, 2, constrained_layout=True, + fig, axs = plt.subplots(2, 2, layout="constrained", sharex=False, sharey=False) for ax in axs.flat: pcm = example_pcolor(ax, fontsize=24) @@ -197,7 +197,7 @@ def test_constrained_layout9(): @image_comparison(['constrained_layout10.png']) def test_constrained_layout10(): """Test for handling legend outside axis""" - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: ax.plot(np.arange(12), label='This is a label') ax.legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) @@ -207,7 +207,7 @@ def test_constrained_layout10(): def test_constrained_layout11(): """Test for multiple nested gridspecs""" - fig = plt.figure(constrained_layout=True, figsize=(13, 3)) + fig = plt.figure(layout="constrained", figsize=(13, 3)) gs0 = gridspec.GridSpec(1, 2, figure=fig) gsl = gridspec.GridSpecFromSubplotSpec(1, 2, gs0[0]) gsl0 = gridspec.GridSpecFromSubplotSpec(2, 2, gsl[1]) @@ -227,7 +227,7 @@ def test_constrained_layout11(): def test_constrained_layout11rat(): """Test for multiple nested gridspecs with width_ratios""" - fig = plt.figure(constrained_layout=True, figsize=(10, 3)) + fig = plt.figure(layout="constrained", figsize=(10, 3)) gs0 = gridspec.GridSpec(1, 2, figure=fig, width_ratios=[6, 1]) gsl = gridspec.GridSpecFromSubplotSpec(1, 2, gs0[0]) gsl0 = gridspec.GridSpecFromSubplotSpec(2, 2, gsl[1], height_ratios=[2, 1]) @@ -246,7 +246,7 @@ def test_constrained_layout11rat(): @image_comparison(['constrained_layout12.png']) def test_constrained_layout12(): """Test that very unbalanced labeling still works.""" - fig = plt.figure(constrained_layout=True, figsize=(6, 8)) + fig = plt.figure(layout="constrained", figsize=(6, 8)) gs0 = gridspec.GridSpec(6, 2, figure=fig) @@ -268,23 +268,23 @@ def test_constrained_layout12(): @image_comparison(['constrained_layout13.png'], tol=2.e-2) def test_constrained_layout13(): """Test that padding works.""" - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: pcm = example_pcolor(ax, fontsize=12) fig.colorbar(pcm, ax=ax, shrink=0.6, aspect=20., pad=0.02) - with pytest.raises(TypeError, match='unexpected keyword argument'): - fig.set_constrained_layout_pads(wpad=1, hpad=2) - fig.set_constrained_layout_pads(w_pad=24./72., h_pad=24./72.) + with pytest.raises(TypeError): + fig.get_layout_engine().set(wpad=1, hpad=2) + fig.get_layout_engine().set(w_pad=24./72., h_pad=24./72.) @image_comparison(['constrained_layout14.png']) def test_constrained_layout14(): """Test that padding works.""" - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: pcm = example_pcolor(ax, fontsize=12) fig.colorbar(pcm, ax=ax, shrink=0.6, aspect=20., pad=0.02) - fig.set_constrained_layout_pads( + fig.get_layout_engine().set( w_pad=3./72., h_pad=3./72., hspace=0.2, wspace=0.2) @@ -292,7 +292,7 @@ def test_constrained_layout14(): @image_comparison(['constrained_layout15.png']) def test_constrained_layout15(): """Test that rcparams work.""" - rcParams['figure.constrained_layout.use'] = True + mpl.rcParams['figure.constrained_layout.use'] = True fig, axs = plt.subplots(2, 2) for ax in axs.flat: example_plot(ax, fontsize=12) @@ -301,7 +301,7 @@ def test_constrained_layout15(): @image_comparison(['constrained_layout16.png']) def test_constrained_layout16(): """Test ax.set_position.""" - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") example_plot(ax, fontsize=12) ax2 = fig.add_axes([0.2, 0.2, 0.4, 0.4]) @@ -309,7 +309,7 @@ def test_constrained_layout16(): @image_comparison(['constrained_layout17.png']) def test_constrained_layout17(): """Test uneven gridspecs""" - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout="constrained") gs = gridspec.GridSpec(3, 3, figure=fig) ax1 = fig.add_subplot(gs[0, 0]) @@ -325,7 +325,7 @@ def test_constrained_layout17(): def test_constrained_layout18(): """Test twinx""" - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") ax2 = ax.twinx() example_plot(ax) example_plot(ax2, fontsize=24) @@ -335,7 +335,7 @@ def test_constrained_layout18(): def test_constrained_layout19(): """Test twiny""" - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") ax2 = ax.twiny() example_plot(ax) example_plot(ax2, fontsize=24) @@ -358,7 +358,7 @@ def test_constrained_layout20(): def test_constrained_layout21(): """#11035: repeated calls to suptitle should not alter the layout""" - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") fig.suptitle("Suptitle0") fig.draw_without_rendering() @@ -373,7 +373,7 @@ def test_constrained_layout21(): def test_constrained_layout22(): """#11035: suptitle should not be include in CL if manually positioned""" - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") fig.draw_without_rendering() extents0 = np.copy(ax.get_position().extents) @@ -392,7 +392,7 @@ def test_constrained_layout23(): """ for i in range(2): - fig = plt.figure(constrained_layout=True, clear=True, num="123") + fig = plt.figure(layout="constrained", clear=True, num="123") gs = fig.add_gridspec(1, 2) sub = gs[0].subgridspec(2, 2) fig.suptitle("Suptitle{}".format(i)) @@ -408,7 +408,7 @@ def test_colorbar_location(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False - fig, axs = plt.subplots(4, 5, constrained_layout=True) + fig, axs = plt.subplots(4, 5, layout="constrained") for ax in axs.flat: pcm = example_pcolor(ax) ax.set_xlabel('') @@ -422,10 +422,10 @@ def test_colorbar_location(): def test_hidden_axes(): - # test that if we make an axes not visible that constrained_layout + # test that if we make an Axes not visible that constrained_layout # still works. Note the axes still takes space in the layout # (as does a gridspec slot that is empty) - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") axs[0, 1].set_visible(False) fig.draw_without_rendering() extents1 = np.copy(axs[0, 0].get_position().extents) @@ -436,7 +436,7 @@ def test_hidden_axes(): def test_colorbar_align(): for location in ['right', 'left', 'top', 'bottom']: - fig, axs = plt.subplots(2, 2, constrained_layout=True) + fig, axs = plt.subplots(2, 2, layout="constrained") cbs = [] for nn, ax in enumerate(axs.flat): ax.tick_params(direction='in') @@ -450,8 +450,8 @@ def test_colorbar_align(): cb.ax.yaxis.set_ticks([]) ax.set_xticklabels([]) ax.set_yticklabels([]) - fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.1, - wspace=0.1) + fig.get_layout_engine().set(w_pad=4 / 72, h_pad=4 / 72, + hspace=0.1, wspace=0.1) fig.draw_without_rendering() if location in ['left', 'right']: @@ -466,10 +466,9 @@ def test_colorbar_align(): cbs[3].ax.get_position().y0) -@image_comparison(['test_colorbars_no_overlapV.png'], - remove_text=False, style='mpl20') +@image_comparison(['test_colorbars_no_overlapV.png'], style='mpl20') def test_colorbars_no_overlapV(): - fig = plt.figure(figsize=(2, 4), constrained_layout=True) + fig = plt.figure(figsize=(2, 4), layout="constrained") axs = fig.subplots(2, 1, sharex=True, sharey=True) for ax in axs: ax.yaxis.set_major_formatter(ticker.NullFormatter()) @@ -479,10 +478,9 @@ def test_colorbars_no_overlapV(): fig.suptitle("foo") -@image_comparison(['test_colorbars_no_overlapH.png'], - remove_text=False, style='mpl20') +@image_comparison(['test_colorbars_no_overlapH.png'], style='mpl20') def test_colorbars_no_overlapH(): - fig = plt.figure(figsize=(4, 2), constrained_layout=True) + fig = plt.figure(figsize=(4, 2), layout="constrained") fig.suptitle("foo") axs = fig.subplots(1, 2, sharex=True, sharey=True) for ax in axs: @@ -493,13 +491,13 @@ def test_colorbars_no_overlapH(): def test_manually_set_position(): - fig, axs = plt.subplots(1, 2, constrained_layout=True) + fig, axs = plt.subplots(1, 2, layout="constrained") axs[0].set_position([0.2, 0.2, 0.3, 0.3]) fig.draw_without_rendering() pp = axs[0].get_position() np.testing.assert_allclose(pp, [[0.2, 0.2], [0.5, 0.5]]) - fig, axs = plt.subplots(1, 2, constrained_layout=True) + fig, axs = plt.subplots(1, 2, layout="constrained") axs[0].set_position([0.2, 0.2, 0.3, 0.3]) pc = axs[0].pcolormesh(np.random.rand(20, 20)) fig.colorbar(pc, ax=axs[0]) @@ -512,7 +510,7 @@ def test_manually_set_position(): remove_text=True, style='mpl20', savefig_kwarg={'bbox_inches': 'tight'}) def test_bboxtight(): - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") ax.set_aspect(1.) @@ -521,7 +519,7 @@ def test_bboxtight(): savefig_kwarg={'bbox_inches': mtransforms.Bbox([[0.5, 0], [2.5, 2]])}) def test_bbox(): - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") ax.set_aspect(1.) @@ -532,7 +530,7 @@ def test_align_labels(): negative numbers, drives the non-negative subplots' y labels off the edge of the plot """ - fig, (ax3, ax1, ax2) = plt.subplots(3, 1, constrained_layout=True, + fig, (ax3, ax1, ax2) = plt.subplots(3, 1, layout="constrained", figsize=(6.4, 8), gridspec_kw={"height_ratios": (1, 1, 0.7)}) @@ -560,7 +558,7 @@ def test_align_labels(): def test_suplabels(): - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") fig.draw_without_rendering() pos0 = ax.get_tightbbox(fig.canvas.get_renderer()) fig.supxlabel('Boo') @@ -570,7 +568,7 @@ def test_suplabels(): assert pos.y0 > pos0.y0 + 10.0 assert pos.x0 > pos0.x0 + 10.0 - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") fig.draw_without_rendering() pos0 = ax.get_tightbbox(fig.canvas.get_renderer()) # check that specifying x (y) doesn't ruin the layout @@ -587,3 +585,96 @@ def test_gridspec_addressing(): gs = fig.add_gridspec(3, 3) sp = fig.add_subplot(gs[0:, 1:]) fig.draw_without_rendering() + + +def test_discouraged_api(): + fig, ax = plt.subplots(constrained_layout=True) + fig.draw_without_rendering() + + with pytest.warns(PendingDeprecationWarning, + match="will be deprecated"): + fig, ax = plt.subplots() + fig.set_constrained_layout(True) + fig.draw_without_rendering() + + with pytest.warns(PendingDeprecationWarning, + match="will be deprecated"): + fig, ax = plt.subplots() + fig.set_constrained_layout({'w_pad': 0.02, 'h_pad': 0.02}) + fig.draw_without_rendering() + + +def test_kwargs(): + fig, ax = plt.subplots(constrained_layout={'h_pad': 0.02}) + fig.draw_without_rendering() + + +def test_rect(): + fig, ax = plt.subplots(layout='constrained') + fig.get_layout_engine().set(rect=[0, 0, 0.5, 0.5]) + fig.draw_without_rendering() + ppos = ax.get_position() + assert ppos.x1 < 0.5 + assert ppos.y1 < 0.5 + + fig, ax = plt.subplots(layout='constrained') + fig.get_layout_engine().set(rect=[0.2, 0.2, 0.3, 0.3]) + fig.draw_without_rendering() + ppos = ax.get_position() + assert ppos.x1 < 0.5 + assert ppos.y1 < 0.5 + assert ppos.x0 > 0.2 + assert ppos.y0 > 0.2 + + +def test_compressed1(): + fig, axs = plt.subplots(3, 2, layout='compressed', + sharex=True, sharey=True) + for ax in axs.flat: + pc = ax.imshow(np.random.randn(20, 20)) + + fig.colorbar(pc, ax=axs) + fig.draw_without_rendering() + + pos = axs[0, 0].get_position() + np.testing.assert_allclose(pos.x0, 0.2344, atol=1e-3) + pos = axs[0, 1].get_position() + np.testing.assert_allclose(pos.x1, 0.7024, atol=1e-3) + + # wider than tall + fig, axs = plt.subplots(2, 3, layout='compressed', + sharex=True, sharey=True, figsize=(5, 4)) + for ax in axs.flat: + pc = ax.imshow(np.random.randn(20, 20)) + + fig.colorbar(pc, ax=axs) + fig.draw_without_rendering() + + pos = axs[0, 0].get_position() + np.testing.assert_allclose(pos.x0, 0.06195, atol=1e-3) + np.testing.assert_allclose(pos.y1, 0.8537, atol=1e-3) + pos = axs[1, 2].get_position() + np.testing.assert_allclose(pos.x1, 0.8618, atol=1e-3) + np.testing.assert_allclose(pos.y0, 0.1934, atol=1e-3) + + +@pytest.mark.parametrize('arg, state', [ + (True, True), + (False, False), + ({}, True), + ({'rect': None}, True) +]) +def test_set_constrained_layout(arg, state): + fig, ax = plt.subplots(constrained_layout=arg) + assert fig.get_constrained_layout() is state + + +def test_constrained_toggle(): + fig, ax = plt.subplots() + with pytest.warns(PendingDeprecationWarning): + fig.set_constrained_layout(True) + assert fig.get_constrained_layout() + fig.set_constrained_layout(False) + assert not fig.get_constrained_layout() + fig.set_constrained_layout(True) + assert fig.get_constrained_layout() diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index 10fb0111669b..41d4dc8501bd 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -2,12 +2,14 @@ import platform import re +import contourpy import numpy as np -from numpy.testing import assert_array_almost_equal +from numpy.testing import ( + assert_array_almost_equal, assert_array_almost_equal_nulp) import matplotlib as mpl from matplotlib.testing.decorators import image_comparison -from matplotlib import pyplot as plt, rc_context -from matplotlib.colors import LogNorm +from matplotlib import pyplot as plt, rc_context, ticker +from matplotlib.colors import LogNorm, same_color import pytest @@ -60,15 +62,16 @@ def test_contour_shape_error(args, message): ax.contour(*args) -def test_contour_empty_levels(): - - x = np.arange(9) - z = np.random.random((9, 9)) - +def test_contour_no_valid_levels(): fig, ax = plt.subplots() - with pytest.warns(UserWarning) as record: - ax.contour(x, x, z, levels=[]) - assert len(record) == 1 + # no warning for empty levels. + ax.contour(np.random.rand(9, 9), levels=[]) + # no warning if levels is given and is not within the range of z. + cs = ax.contour(np.arange(81).reshape((9, 9)), levels=[100]) + # ... and if fmt is given. + ax.clabel(cs, fmt={100: '%1.2f'}) + # no warning if z is uniform. + ax.contour(np.ones((9, 9))) def test_contour_Nlevels(): @@ -82,33 +85,6 @@ def test_contour_Nlevels(): assert (cs1.levels == cs2.levels).all() -def test_contour_badlevel_fmt(): - # Test edge case from https://github.com/matplotlib/matplotlib/issues/9742 - # User supplied fmt for each level as a dictionary, but Matplotlib changed - # the level to the minimum data value because no contours possible. - # This was fixed in https://github.com/matplotlib/matplotlib/pull/9743 - x = np.arange(9) - z = np.zeros((9, 9)) - - fig, ax = plt.subplots() - fmt = {1.: '%1.2f'} - with pytest.warns(UserWarning) as record: - cs = ax.contour(x, x, z, levels=[1.]) - ax.clabel(cs, fmt=fmt) - assert len(record) == 1 - - -def test_contour_uniform_z(): - - x = np.arange(9) - z = np.ones((9, 9)) - - fig, ax = plt.subplots() - with pytest.warns(UserWarning) as record: - ax.contour(x, x, z) - assert len(record) == 1 - - @image_comparison(['contour_manual_labels'], remove_text=True, style='mpl20') def test_contour_manual_labels(): x, y = np.meshgrid(np.arange(0, 10), np.arange(0, 10)) @@ -153,8 +129,29 @@ def test_given_colors_levels_and_extends(): plt.colorbar(c, ax=ax) -@image_comparison(['contour_datetime_axis.png'], - remove_text=False, style='mpl20') +@image_comparison(['contour_log_locator.svg'], style='mpl20', + remove_text=False) +def test_log_locator_levels(): + + fig, ax = plt.subplots() + + N = 100 + x = np.linspace(-3.0, 3.0, N) + y = np.linspace(-2.0, 2.0, N) + + X, Y = np.meshgrid(x, y) + + Z1 = np.exp(-X**2 - Y**2) + Z2 = np.exp(-(X * 10)**2 - (Y * 10)**2) + data = Z1 + 50 * Z2 + + c = ax.contourf(data, locator=ticker.LogLocator()) + assert_array_almost_equal(c.levels, np.power(10.0, np.arange(-6, 3))) + cb = fig.colorbar(c, ax=ax) + assert_array_almost_equal(cb.ax.get_yticks(), c.levels) + + +@image_comparison(['contour_datetime_axis.png'], style='mpl20') def test_contour_datetime_axis(): fig = plt.figure() fig.subplots_adjust(hspace=0.4, top=0.98, bottom=.15) @@ -244,33 +241,6 @@ def test_contourf_symmetric_locator(): assert_array_almost_equal(cs.levels, np.linspace(-12, 12, 5)) -@pytest.mark.parametrize("args, cls, message", [ - ((), TypeError, - 'function takes exactly 6 arguments (0 given)'), - ((1, 2, 3, 4, 5, 6), ValueError, - 'Expected 2-dimensional array, got 0'), - (([[0]], [[0]], [[]], None, True, 0), ValueError, - 'x, y and z must all be 2D arrays with the same dimensions'), - (([[0]], [[0]], [[0]], None, True, 0), ValueError, - 'x, y and z must all be at least 2x2 arrays'), - ((*[np.arange(4).reshape((2, 2))] * 3, [[0]], True, 0), ValueError, - 'If mask is set it must be a 2D array with the same dimensions as x.'), -]) -def test_internal_cpp_api(args, cls, message): # Github issue 8197. - from matplotlib import _contour # noqa: ensure lazy-loaded module *is* loaded. - with pytest.raises(cls, match=re.escape(message)): - mpl._contour.QuadContourGenerator(*args) - - -def test_internal_cpp_api_2(): - from matplotlib import _contour # noqa: ensure lazy-loaded module *is* loaded. - arr = [[0, 1], [2, 3]] - qcg = mpl._contour.QuadContourGenerator(arr, arr, arr, None, True, 0) - with pytest.raises( - ValueError, match=r'filled contour levels must be increasing'): - qcg.create_filled_contour(1, 0) - - def test_circular_contour_warning(): # Check that almost circular contours don't throw a warning x, y = np.meshgrid(np.linspace(-2, 2, 4), np.linspace(-2, 2, 4)) @@ -340,12 +310,14 @@ def test_contourf_log_extension(): cb = plt.colorbar(c1, ax=ax1) assert cb.ax.get_ylim() == (1e-8, 1e10) cb = plt.colorbar(c2, ax=ax2) - assert cb.ax.get_ylim() == (1e-4, 1e6) + assert_array_almost_equal_nulp(cb.ax.get_ylim(), np.array((1e-4, 1e6))) cb = plt.colorbar(c3, ax=ax3) -@image_comparison(['contour_addlines.png'], - remove_text=True, style='mpl20', tol=0.03) +@image_comparison( + ['contour_addlines.png'], remove_text=True, style='mpl20', + tol=0.15 if platform.machine() in ('aarch64', 'ppc64le', 's390x') + else 0.03) # tolerance is because image changed minutely when tick finding on # colorbars was cleaned up... def test_contour_addlines(): @@ -520,3 +492,219 @@ def test_contour_autolabel_beyond_powerlimits(): ax.clabel(cs) # Currently, the exponent is missing, but that may be fixed in the future. assert {text.get_text() for text in ax.texts} == {"0.25", "1.00", "4.00"} + + +def test_contourf_legend_elements(): + from matplotlib.patches import Rectangle + x = np.arange(1, 10) + y = x.reshape(-1, 1) + h = x * y + + cs = plt.contourf(h, levels=[10, 30, 50], + colors=['#FFFF00', '#FF00FF', '#00FFFF'], + extend='both') + cs.cmap.set_over('red') + cs.cmap.set_under('blue') + cs.changed() + artists, labels = cs.legend_elements() + assert labels == ['$x \\leq -1e+250s$', + '$10.0 < x \\leq 30.0$', + '$30.0 < x \\leq 50.0$', + '$x > 1e+250s$'] + expected_colors = ('blue', '#FFFF00', '#FF00FF', 'red') + assert all(isinstance(a, Rectangle) for a in artists) + assert all(same_color(a.get_facecolor(), c) + for a, c in zip(artists, expected_colors)) + + +def test_contour_legend_elements(): + from matplotlib.collections import LineCollection + x = np.arange(1, 10) + y = x.reshape(-1, 1) + h = x * y + + colors = ['blue', '#00FF00', 'red'] + cs = plt.contour(h, levels=[10, 30, 50], + colors=colors, + extend='both') + artists, labels = cs.legend_elements() + assert labels == ['$x = 10.0$', '$x = 30.0$', '$x = 50.0$'] + assert all(isinstance(a, LineCollection) for a in artists) + assert all(same_color(a.get_color(), c) + for a, c in zip(artists, colors)) + + +@pytest.mark.parametrize( + "algorithm, klass", + [('mpl2005', contourpy.Mpl2005ContourGenerator), + ('mpl2014', contourpy.Mpl2014ContourGenerator), + ('serial', contourpy.SerialContourGenerator), + ('threaded', contourpy.ThreadedContourGenerator), + ('invalid', None)]) +def test_algorithm_name(algorithm, klass): + z = np.array([[1.0, 2.0], [3.0, 4.0]]) + if klass is not None: + cs = plt.contourf(z, algorithm=algorithm) + assert isinstance(cs._contour_generator, klass) + else: + with pytest.raises(ValueError): + plt.contourf(z, algorithm=algorithm) + + +@pytest.mark.parametrize( + "algorithm", ['mpl2005', 'mpl2014', 'serial', 'threaded']) +def test_algorithm_supports_corner_mask(algorithm): + z = np.array([[1.0, 2.0], [3.0, 4.0]]) + + # All algorithms support corner_mask=False + plt.contourf(z, algorithm=algorithm, corner_mask=False) + + # Only some algorithms support corner_mask=True + if algorithm != 'mpl2005': + plt.contourf(z, algorithm=algorithm, corner_mask=True) + else: + with pytest.raises(ValueError): + plt.contourf(z, algorithm=algorithm, corner_mask=True) + + +@image_comparison(baseline_images=['contour_all_algorithms'], + extensions=['png'], remove_text=True) +def test_all_algorithms(): + algorithms = ['mpl2005', 'mpl2014', 'serial', 'threaded'] + + rng = np.random.default_rng(2981) + x, y = np.meshgrid(np.linspace(0.0, 1.0, 10), np.linspace(0.0, 1.0, 6)) + z = np.sin(15*x)*np.cos(10*y) + rng.normal(scale=0.5, size=(6, 10)) + mask = np.zeros_like(z, dtype=bool) + mask[3, 7] = True + z = np.ma.array(z, mask=mask) + + _, axs = plt.subplots(2, 2) + for ax, algorithm in zip(axs.ravel(), algorithms): + ax.contourf(x, y, z, algorithm=algorithm) + ax.contour(x, y, z, algorithm=algorithm, colors='k') + ax.set_title(algorithm) + + +def test_subfigure_clabel(): + # Smoke test for gh#23173 + delta = 0.025 + x = np.arange(-3.0, 3.0, delta) + y = np.arange(-2.0, 2.0, delta) + X, Y = np.meshgrid(x, y) + Z1 = np.exp(-(X**2) - Y**2) + Z2 = np.exp(-((X - 1) ** 2) - (Y - 1) ** 2) + Z = (Z1 - Z2) * 2 + + fig = plt.figure() + figs = fig.subfigures(nrows=1, ncols=2) + + for f in figs: + ax = f.subplots() + CS = ax.contour(X, Y, Z) + ax.clabel(CS, inline=True, fontsize=10) + ax.set_title("Simplest default with labels") + + +@pytest.mark.parametrize( + "style", ['solid', 'dashed', 'dashdot', 'dotted']) +def test_linestyles(style): + delta = 0.025 + x = np.arange(-3.0, 3.0, delta) + y = np.arange(-2.0, 2.0, delta) + X, Y = np.meshgrid(x, y) + Z1 = np.exp(-X**2 - Y**2) + Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) + Z = (Z1 - Z2) * 2 + + # Positive contour defaults to solid + fig1, ax1 = plt.subplots() + CS1 = ax1.contour(X, Y, Z, 6, colors='k') + ax1.clabel(CS1, fontsize=9, inline=True) + ax1.set_title('Single color - positive contours solid (default)') + assert CS1.linestyles is None # default + + # Change linestyles using linestyles kwarg + fig2, ax2 = plt.subplots() + CS2 = ax2.contour(X, Y, Z, 6, colors='k', linestyles=style) + ax2.clabel(CS2, fontsize=9, inline=True) + ax2.set_title(f'Single color - positive contours {style}') + assert CS2.linestyles == style + + # Ensure linestyles do not change when negative_linestyles is defined + fig3, ax3 = plt.subplots() + CS3 = ax3.contour(X, Y, Z, 6, colors='k', linestyles=style, + negative_linestyles='dashdot') + ax3.clabel(CS3, fontsize=9, inline=True) + ax3.set_title(f'Single color - positive contours {style}') + assert CS3.linestyles == style + + +@pytest.mark.parametrize( + "style", ['solid', 'dashed', 'dashdot', 'dotted']) +def test_negative_linestyles(style): + delta = 0.025 + x = np.arange(-3.0, 3.0, delta) + y = np.arange(-2.0, 2.0, delta) + X, Y = np.meshgrid(x, y) + Z1 = np.exp(-X**2 - Y**2) + Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) + Z = (Z1 - Z2) * 2 + + # Negative contour defaults to dashed + fig1, ax1 = plt.subplots() + CS1 = ax1.contour(X, Y, Z, 6, colors='k') + ax1.clabel(CS1, fontsize=9, inline=True) + ax1.set_title('Single color - negative contours dashed (default)') + assert CS1.negative_linestyles == 'dashed' # default + + # Change negative_linestyles using rcParams + plt.rcParams['contour.negative_linestyle'] = style + fig2, ax2 = plt.subplots() + CS2 = ax2.contour(X, Y, Z, 6, colors='k') + ax2.clabel(CS2, fontsize=9, inline=True) + ax2.set_title(f'Single color - negative contours {style}' + '(using rcParams)') + assert CS2.negative_linestyles == style + + # Change negative_linestyles using negative_linestyles kwarg + fig3, ax3 = plt.subplots() + CS3 = ax3.contour(X, Y, Z, 6, colors='k', negative_linestyles=style) + ax3.clabel(CS3, fontsize=9, inline=True) + ax3.set_title(f'Single color - negative contours {style}') + assert CS3.negative_linestyles == style + + # Ensure negative_linestyles do not change when linestyles is defined + fig4, ax4 = plt.subplots() + CS4 = ax4.contour(X, Y, Z, 6, colors='k', linestyles='dashdot', + negative_linestyles=style) + ax4.clabel(CS4, fontsize=9, inline=True) + ax4.set_title(f'Single color - negative contours {style}') + assert CS4.negative_linestyles == style + + +def test_contour_remove(): + ax = plt.figure().add_subplot() + orig_children = ax.get_children() + cs = ax.contour(np.arange(16).reshape((4, 4))) + cs.clabel() + assert ax.get_children() != orig_children + cs.remove() + assert ax.get_children() == orig_children + + +def test_bool_autolevel(): + x, y = np.random.rand(2, 9) + z = (np.arange(9) % 2).reshape((3, 3)).astype(bool) + m = [[False, False, False], [False, True, False], [False, False, False]] + assert plt.contour(z.tolist()).levels.tolist() == [.5] + assert plt.contour(z).levels.tolist() == [.5] + assert plt.contour(np.ma.array(z, mask=m)).levels.tolist() == [.5] + assert plt.contourf(z.tolist()).levels.tolist() == [0, .5, 1] + assert plt.contourf(z).levels.tolist() == [0, .5, 1] + assert plt.contourf(np.ma.array(z, mask=m)).levels.tolist() == [0, .5, 1] + z = z.ravel() + assert plt.tricontour(x, y, z.tolist()).levels.tolist() == [.5] + assert plt.tricontour(x, y, z).levels.tolist() == [.5] + assert plt.tricontourf(x, y, z.tolist()).levels.tolist() == [0, .5, 1] + assert plt.tricontourf(x, y, z).levels.tolist() == [0, .5, 1] diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 61a0aa34bb0b..7abebcde7e56 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -6,12 +6,11 @@ import numpy as np import pytest -from matplotlib import rc_context, style +from matplotlib import _api, rc_context, style import matplotlib.dates as mdates import matplotlib.pyplot as plt from matplotlib.testing.decorators import image_comparison import matplotlib.ticker as mticker -import matplotlib._api as _api def test_date_numpyx(): @@ -70,6 +69,26 @@ def test_date2num_NaT_scalar(units): assert np.isnan(tmpl) +def test_date2num_masked(): + # Without tzinfo + base = datetime.datetime(2022, 12, 15) + dates = np.ma.array([base + datetime.timedelta(days=(2 * i)) + for i in range(7)], mask=[0, 1, 1, 0, 0, 0, 1]) + npdates = mdates.date2num(dates) + np.testing.assert_array_equal(np.ma.getmask(npdates), + (False, True, True, False, False, False, + True)) + + # With tzinfo + base = datetime.datetime(2022, 12, 15, tzinfo=mdates.UTC) + dates = np.ma.array([base + datetime.timedelta(days=(2 * i)) + for i in range(7)], mask=[0, 1, 1, 0, 0, 0, 1]) + npdates = mdates.date2num(dates) + np.testing.assert_array_equal(np.ma.getmask(npdates), + (False, True, True, False, False, False, + True)) + + def test_date_empty(): # make sure we do the right thing when told to plot dates even # if no date data has been presented, cf @@ -78,8 +97,8 @@ def test_date_empty(): ax.xaxis_date() fig.draw_without_rendering() np.testing.assert_allclose(ax.get_xlim(), - [mdates.date2num(np.datetime64('2000-01-01')), - mdates.date2num(np.datetime64('2010-01-01'))]) + [mdates.date2num(np.datetime64('1970-01-01')), + mdates.date2num(np.datetime64('1970-01-02'))]) mdates._reset_epoch_test_example() mdates.set_epoch('0000-12-31') @@ -87,8 +106,8 @@ def test_date_empty(): ax.xaxis_date() fig.draw_without_rendering() np.testing.assert_allclose(ax.get_xlim(), - [mdates.date2num(np.datetime64('2000-01-01')), - mdates.date2num(np.datetime64('2010-01-01'))]) + [mdates.date2num(np.datetime64('1970-01-01')), + mdates.date2num(np.datetime64('1970-01-02'))]) mdates._reset_epoch_test_example() @@ -182,8 +201,8 @@ def test_too_many_date_ticks(caplog): with pytest.warns(UserWarning) as rec: ax.set_xlim((t0, tf), auto=True) assert len(rec) == 1 - assert \ - 'Attempting to set identical left == right' in str(rec[0].message) + assert ('Attempting to set identical low and high xlims' + in str(rec[0].message)) ax.plot([], []) ax.xaxis.set_major_locator(mdates.DayLocator()) v = ax.xaxis.get_major_locator()() @@ -357,9 +376,13 @@ def test_drange(): # dates from an half open interval [start, end) assert len(mdates.drange(start, end, delta)) == 24 + # Same if interval ends slightly earlier + end = end - datetime.timedelta(microseconds=1) + assert len(mdates.drange(start, end, delta)) == 24 + # if end is a little bit later, we expect the range to contain one element # more - end = end + datetime.timedelta(microseconds=1) + end = end + datetime.timedelta(microseconds=2) assert len(mdates.drange(start, end, delta)) == 25 # reset end @@ -443,6 +466,15 @@ def _create_auto_date_locator(date1, date2): locator = _create_auto_date_locator(d1, d2) assert list(map(str, mdates.num2date(locator()))) == expected + locator = mdates.AutoDateLocator(interval_multiples=False) + assert locator.maxticks == {0: 11, 1: 12, 3: 11, 4: 12, 5: 11, 6: 11, 7: 8} + + locator = mdates.AutoDateLocator(maxticks={dateutil.rrule.MONTHLY: 5}) + assert locator.maxticks == {0: 11, 1: 5, 3: 11, 4: 12, 5: 11, 6: 11, 7: 8} + + locator = mdates.AutoDateLocator(maxticks=5) + assert locator.maxticks == {0: 5, 1: 5, 3: 5, 4: 5, 5: 5, 6: 5, 7: 5} + @_new_epoch_decorator def test_auto_date_locator_intmult(): @@ -904,7 +936,7 @@ def _test_date2num_dst(date_range, tz_convert): # Interval is 0b0.0000011 days, to prevent float rounding issues dtstart = datetime.datetime(2014, 3, 30, 0, 0, tzinfo=UTC) interval = datetime.timedelta(minutes=33, seconds=45) - interval_days = 0.0234375 # 2025 / 86400 seconds + interval_days = interval.seconds / 86400 N = 8 dt_utc = date_range(start=dtstart, freq=interval, periods=N) @@ -974,7 +1006,7 @@ def date_range(start, freq, periods): return [dtstart + (i * freq) for i in range(periods)] - # Define a tz_convert function that converts a list to a new time zone. + # Define a tz_convert function that converts a list to a new timezone. def tz_convert(dt_list, tzinfo): return [d.astimezone(tzinfo) for d in dt_list] @@ -1012,6 +1044,20 @@ def attach_tz(dt, zi): _test_rrulewrapper(attach_tz, dateutil.tz.gettz) + SYD = dateutil.tz.gettz('Australia/Sydney') + dtstart = datetime.datetime(2017, 4, 1, 0) + dtend = datetime.datetime(2017, 4, 4, 0) + rule = mdates.rrulewrapper(freq=dateutil.rrule.DAILY, dtstart=dtstart, + tzinfo=SYD, until=dtend) + assert rule.after(dtstart) == datetime.datetime(2017, 4, 2, 0, 0, + tzinfo=SYD) + assert rule.before(dtend) == datetime.datetime(2017, 4, 3, 0, 0, + tzinfo=SYD) + + # Test parts of __getattr__ + assert rule._base_tzinfo == SYD + assert rule._interval == 1 + @pytest.mark.pytz def test_rrulewrapper_pytz(): @@ -1046,6 +1092,15 @@ def test_yearlocator_pytz(): '2014-01-01 00:00:00-05:00', '2015-01-01 00:00:00-05:00'] st = list(map(str, mdates.num2date(locator(), tz=tz))) assert st == expected + assert np.allclose(locator.tick_values(x[0], x[1]), np.array( + [14610.20833333, 14610.33333333, 14610.45833333, 14610.58333333, + 14610.70833333, 14610.83333333, 14610.95833333, 14611.08333333, + 14611.20833333])) + assert np.allclose(locator.get_locator(x[1], x[0]).tick_values(x[0], x[1]), + np.array( + [14610.20833333, 14610.33333333, 14610.45833333, 14610.58333333, + 14610.70833333, 14610.83333333, 14610.95833333, 14611.08333333, + 14611.20833333])) def test_YearLocator(): @@ -1101,7 +1156,7 @@ def test_DayLocator(): def test_tz_utc(): dt = datetime.datetime(1970, 1, 1, tzinfo=mdates.UTC) - dt.tzname() + assert dt.tzname() == 'UTC' @pytest.mark.parametrize("x, tdelta", @@ -1161,7 +1216,7 @@ def test_warn_notintervals(): locator.create_dummy_axis() locator.axis.set_view_interval(mdates.date2num(dates[0]), mdates.date2num(dates[-1])) - with pytest.warns(UserWarning, match="AutoDateLocator was unable") as rec: + with pytest.warns(UserWarning, match="AutoDateLocator was unable"): locs = locator() @@ -1205,28 +1260,142 @@ def test_change_interval_multiples(): assert ax.get_xticklabels()[1].get_text() == 'Feb 01 2020' -def test_epoch2num(): - with _api.suppress_matplotlib_deprecation_warning(): +def test_julian2num(): + with pytest.warns(_api.MatplotlibDeprecationWarning): mdates._reset_epoch_test_example() mdates.set_epoch('0000-12-31') - assert mdates.epoch2num(86400) == 719164.0 - assert mdates.num2epoch(719165.0) == 86400 * 2 + # 2440587.5 is julian date for 1970-01-01T00:00:00 + # https://en.wikipedia.org/wiki/Julian_day + assert mdates.julian2num(2440588.5) == 719164.0 + assert mdates.num2julian(719165.0) == 2440589.5 # set back to the default mdates._reset_epoch_test_example() mdates.set_epoch('1970-01-01T00:00:00') - assert mdates.epoch2num(86400) == 1.0 - assert mdates.num2epoch(2.0) == 86400 * 2 + assert mdates.julian2num(2440588.5) == 1.0 + assert mdates.num2julian(2.0) == 2440589.5 -def test_julian2num(): - mdates._reset_epoch_test_example() - mdates.set_epoch('0000-12-31') - # 2440587.5 is julian date for 1970-01-01T00:00:00 - # https://en.wikipedia.org/wiki/Julian_day - assert mdates.julian2num(2440588.5) == 719164.0 - assert mdates.num2julian(719165.0) == 2440589.5 - # set back to the default - mdates._reset_epoch_test_example() - mdates.set_epoch('1970-01-01T00:00:00') - assert mdates.julian2num(2440588.5) == 1.0 - assert mdates.num2julian(2.0) == 2440589.5 +def test_DateLocator(): + locator = mdates.DateLocator() + # Test nonsingular + assert locator.nonsingular(0, np.inf) == (0, 1) + assert locator.nonsingular(0, 1) == (0, 1) + assert locator.nonsingular(1, 0) == (0, 1) + assert locator.nonsingular(0, 0) == (-2, 2) + locator.create_dummy_axis() + # default values + assert locator.datalim_to_dt() == ( + datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(1970, 1, 2, 0, 0, tzinfo=datetime.timezone.utc)) + + # Check default is UTC + assert locator.tz == mdates.UTC + tz_str = 'Iceland' + iceland_tz = dateutil.tz.gettz(tz_str) + # Check not Iceland + assert locator.tz != iceland_tz + # Set it to to Iceland + locator.set_tzinfo('Iceland') + # Check now it is Iceland + assert locator.tz == iceland_tz + locator.create_dummy_axis() + locator.axis.set_data_interval(*mdates.date2num(["2022-01-10", + "2022-01-08"])) + assert locator.datalim_to_dt() == ( + datetime.datetime(2022, 1, 8, 0, 0, tzinfo=iceland_tz), + datetime.datetime(2022, 1, 10, 0, 0, tzinfo=iceland_tz)) + + # Set rcParam + plt.rcParams['timezone'] = tz_str + + # Create a new one in a similar way + locator = mdates.DateLocator() + # Check now it is Iceland + assert locator.tz == iceland_tz + + # Test invalid tz values + with pytest.raises(ValueError, match="Aiceland is not a valid timezone"): + mdates.DateLocator(tz="Aiceland") + with pytest.raises(TypeError, + match="tz must be string or tzinfo subclass."): + mdates.DateLocator(tz=1) + + +def test_datestr2num(): + assert mdates.datestr2num('2022-01-10') == 19002.0 + dt = datetime.date(year=2022, month=1, day=10) + assert mdates.datestr2num('2022-01', default=dt) == 19002.0 + assert np.all(mdates.datestr2num( + ['2022-01', '2022-02'], default=dt + ) == np.array([19002., 19033.])) + assert mdates.datestr2num([]).size == 0 + assert mdates.datestr2num([], datetime.date(year=2022, + month=1, day=10)).size == 0 + + +@pytest.mark.parametrize('kwarg', + ('formats', 'zero_formats', 'offset_formats')) +def test_concise_formatter_exceptions(kwarg): + locator = mdates.AutoDateLocator() + kwargs = {kwarg: ['', '%Y']} + match = f"{kwarg} argument must be a list" + with pytest.raises(ValueError, match=match): + mdates.ConciseDateFormatter(locator, **kwargs) + + +def test_concise_formatter_call(): + locator = mdates.AutoDateLocator() + formatter = mdates.ConciseDateFormatter(locator) + assert formatter(19002.0) == '2022' + assert formatter.format_data_short(19002.0) == '2022-01-10 00:00:00' + + +@pytest.mark.parametrize('span, expected_locator', + ((0.02, mdates.MinuteLocator), + (1, mdates.HourLocator), + (19, mdates.DayLocator), + (40, mdates.WeekdayLocator), + (200, mdates.MonthLocator), + (2000, mdates.YearLocator))) +def test_date_ticker_factory(span, expected_locator): + with pytest.warns(_api.MatplotlibDeprecationWarning): + locator, _ = mdates.date_ticker_factory(span) + assert isinstance(locator, expected_locator) + + +def test_datetime_masked(): + # make sure that all-masked data falls back to the viewlim + # set in convert.axisinfo.... + x = np.array([datetime.datetime(2017, 1, n) for n in range(1, 6)]) + y = np.array([1, 2, 3, 4, 5]) + m = np.ma.masked_greater(y, 0) + + fig, ax = plt.subplots() + ax.plot(x, m) + assert ax.get_xlim() == (0, 1) + + +@pytest.mark.parametrize('val', (-1000000, 10000000)) +def test_num2date_error(val): + with pytest.raises(ValueError, match=f"Date ordinal {val} converts"): + mdates.num2date(val) + + +def test_num2date_roundoff(): + assert mdates.num2date(100000.0000578702) == datetime.datetime( + 2243, 10, 17, 0, 0, 4, 999980, tzinfo=datetime.timezone.utc) + # Slightly larger, steps of 20 microseconds + assert mdates.num2date(100000.0000578703) == datetime.datetime( + 2243, 10, 17, 0, 0, 5, tzinfo=datetime.timezone.utc) + + +def test_DateFormatter_settz(): + time = mdates.date2num(datetime.datetime(2011, 1, 1, 0, 0, + tzinfo=mdates.UTC)) + formatter = mdates.DateFormatter('%Y-%b-%d %H:%M') + # Default UTC + assert formatter(time) == '2011-Jan-01 00:00' + + # Set tzinfo + formatter.set_tzinfo('Pacific/Kiritimati') + assert formatter(time) == '2011-Jan-01 14:00' diff --git a/lib/matplotlib/tests/test_determinism.py b/lib/matplotlib/tests/test_determinism.py index cce05f12dacd..fe0fb34e128a 100644 --- a/lib/matplotlib/tests/test_determinism.py +++ b/lib/matplotlib/tests/test_determinism.py @@ -11,14 +11,7 @@ import matplotlib as mpl import matplotlib.testing.compare from matplotlib import pyplot as plt - - -needs_ghostscript = pytest.mark.skipif( - "eps" not in mpl.testing.compare.converter, - reason="This test needs a ghostscript installation") -needs_usetex = pytest.mark.skipif( - not mpl.checkdep_usetex(True), - reason="This test needs a TeX installation") +from matplotlib.testing._markers import needs_ghostscript, needs_usetex def _save_figure(objects='mhi', fmt="pdf", usetex=False): diff --git a/lib/matplotlib/tests/test_doc.py b/lib/matplotlib/tests/test_doc.py new file mode 100644 index 000000000000..8a4df35179bc --- /dev/null +++ b/lib/matplotlib/tests/test_doc.py @@ -0,0 +1,34 @@ +import pytest + + +def test_sphinx_gallery_example_header(): + """ + We have copied EXAMPLE_HEADER and modified it to include meta keywords. + This test monitors that the version we have copied is still the same as + the EXAMPLE_HEADER in sphinx-gallery. If sphinx-gallery changes its + EXAMPLE_HEADER, this test will start to fail. In that case, please update + the monkey-patching of EXAMPLE_HEADER in conf.py. + """ + gen_rst = pytest.importorskip('sphinx_gallery.gen_rst') + + EXAMPLE_HEADER = """ +.. DO NOT EDIT. +.. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY. +.. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE: +.. "{0}" +.. LINE NUMBERS ARE GIVEN BELOW. + +.. only:: html + + .. note:: + :class: sphx-glr-download-link-note + + Click :ref:`here ` + to download the full example code{2} + +.. rst-class:: sphx-glr-example-title + +.. _sphx_glr_{1}: + +""" + assert gen_rst.EXAMPLE_HEADER == EXAMPLE_HEADER diff --git a/lib/matplotlib/tests/test_dviread.py b/lib/matplotlib/tests/test_dviread.py index a40151fd555f..7e10975f44d5 100644 --- a/lib/matplotlib/tests/test_dviread.py +++ b/lib/matplotlib/tests/test_dviread.py @@ -7,7 +7,7 @@ def test_PsfontsMap(monkeypatch): - monkeypatch.setattr(dr, 'find_tex_file', lambda x: x) + monkeypatch.setattr(dr, '_find_tex_file', lambda x: x) filename = str(Path(__file__).parent / 'baseline_images/dviread/test.map') fontmap = dr.PsfontsMap(filename) diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 1e076bd64fc5..f3ece07660e3 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -1,6 +1,8 @@ +import copy from datetime import datetime import io from pathlib import Path +import pickle import platform from threading import Timer from types import SimpleNamespace @@ -11,21 +13,22 @@ from PIL import Image import matplotlib as mpl -from matplotlib import cbook, rcParams -from matplotlib._api.deprecation import MatplotlibDeprecationWarning +from matplotlib import gridspec from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.axes import Axes from matplotlib.figure import Figure, FigureBase +from matplotlib.layout_engine import (ConstrainedLayoutEngine, + TightLayoutEngine, + PlaceHolderLayoutEngine) from matplotlib.ticker import AutoMinorLocator, FixedFormatter, ScalarFormatter import matplotlib.pyplot as plt import matplotlib.dates as mdates -import matplotlib.gridspec as gridspec @image_comparison(['figure_align_labels'], extensions=['png', 'svg'], tol=0 if platform.machine() == 'x86_64' else 0.01) def test_align_labels(): - fig = plt.figure(tight_layout=True) + fig = plt.figure(layout='tight') gs = gridspec.GridSpec(3, 3) ax = fig.add_subplot(gs[0, :2]) @@ -187,65 +190,30 @@ def test_figure_legend(): def test_gca(): fig = plt.figure() - with pytest.raises(TypeError): - assert fig.add_axes() is None - + # test that gca() picks up Axes created via add_axes() ax0 = fig.add_axes([0, 0, 1, 1]) - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - assert fig.gca(projection='rectilinear') is ax0 assert fig.gca() is ax0 - ax1 = fig.add_axes(rect=[0.1, 0.1, 0.8, 0.8]) - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - assert fig.gca(projection='rectilinear') is ax1 + # test that gca() picks up Axes created via add_subplot() + ax1 = fig.add_subplot(111) assert fig.gca() is ax1 - ax2 = fig.add_subplot(121, projection='polar') - assert fig.gca() is ax2 - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - assert fig.gca(polar=True) is ax2 - - ax3 = fig.add_subplot(122) - assert fig.gca() is ax3 - - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - assert fig.gca(polar=True) is ax3 - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - assert fig.gca(polar=True) is not ax2 - assert fig.gca().get_subplotspec().get_geometry() == (1, 2, 1, 1) - # add_axes on an existing Axes should not change stored order, but will # make it current. fig.add_axes(ax0) - assert fig.axes == [ax0, ax1, ax2, ax3] + assert fig.axes == [ax0, ax1] assert fig.gca() is ax0 + # sca() should not change stored order of Axes, which is order added. + fig.sca(ax0) + assert fig.axes == [ax0, ax1] + # add_subplot on an existing Axes should not change stored order, but will # make it current. - fig.add_subplot(ax2) - assert fig.axes == [ax0, ax1, ax2, ax3] - assert fig.gca() is ax2 - - fig.sca(ax1) - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - assert fig.gca(projection='rectilinear') is ax1 + fig.add_subplot(ax1) + assert fig.axes == [ax0, ax1] assert fig.gca() is ax1 - # sca() should not change stored order of Axes, which is order added. - assert fig.axes == [ax0, ax1, ax2, ax3] - def test_add_subplot_subclass(): fig = plt.figure() @@ -268,10 +236,15 @@ def test_add_subplot_invalid(): with pytest.raises(ValueError, match='Number of rows must be a positive integer'): fig.add_subplot(0, 2, 1) - with pytest.raises(ValueError, match='num must be 1 <= num <= 4'): + with pytest.raises(ValueError, match='num must be an integer with ' + '1 <= num <= 4'): fig.add_subplot(2, 2, 0) - with pytest.raises(ValueError, match='num must be 1 <= num <= 4'): + with pytest.raises(ValueError, match='num must be an integer with ' + '1 <= num <= 4'): fig.add_subplot(2, 2, 5) + with pytest.raises(ValueError, match='num must be an integer with ' + '1 <= num <= 4'): + fig.add_subplot(2, 2, 0.5) with pytest.raises(ValueError, match='must be a three-digit integer'): fig.add_subplot(42) @@ -294,7 +267,7 @@ def test_add_subplot_invalid(): fig.add_subplot(2, 2.0, 1) _, ax = plt.subplots() with pytest.raises(ValueError, - match='The Subplot must have been created in the ' + match='The Axes must have been created in the ' 'present figure'): fig.add_subplot(ax) @@ -303,7 +276,7 @@ def test_add_subplot_invalid(): def test_suptitle(): fig, _ = plt.subplots() fig.suptitle('hello', color='r') - fig.suptitle('title', color='g', rotation='30') + fig.suptitle('title', color='g', rotation=30) def test_suptitle_fontproperties(): @@ -331,7 +304,7 @@ def test_alpha(): def test_too_many_figures(): with pytest.warns(RuntimeWarning): - for i in range(rcParams['figure.max_open_warning'] + 1): + for i in range(mpl.rcParams['figure.max_open_warning'] + 1): plt.figure() @@ -348,7 +321,7 @@ def test_iterability_axes_argument(): class MyAxes(Axes): def __init__(self, *args, myclass=None, **kwargs): - return Axes.__init__(self, *args, **kwargs) + Axes.__init__(self, *args, **kwargs) class MyClass: @@ -474,6 +447,10 @@ def test_invalid_figure_size(width, height): def test_invalid_figure_add_axes(): fig = plt.figure() + with pytest.raises(TypeError, + match="missing 1 required positional argument: 'rect'"): + fig.add_axes() + with pytest.raises(ValueError): fig.add_axes((.1, .1, .5, np.nan)) @@ -517,9 +494,8 @@ def test_savefig(): def test_savefig_warns(): fig = plt.figure() - msg = r'savefig\(\) got unexpected keyword argument "non_existent_kwarg"' for format in ['png', 'pdf', 'svg', 'tif', 'jpg']: - with pytest.warns(cbook.MatplotlibDeprecationWarning, match=msg): + with pytest.raises(TypeError): fig.savefig(io.BytesIO(), format=format, non_existent_kwarg=True) @@ -556,6 +532,13 @@ def test_savefig_pixel_ratio(backend): assert ratio1 == ratio2 +def test_savefig_preserve_layout_engine(tmp_path): + fig = plt.figure(layout='compressed') + fig.savefig(tmp_path / 'foo.png', bbox_inches='tight') + + assert fig.get_layout_engine()._compress + + def test_figure_repr(): fig = plt.figure(figsize=(10, 20), dpi=10) assert repr(fig) == "
" @@ -576,34 +559,72 @@ def test_valid_layouts(): def test_invalid_layouts(): - fig, ax = plt.subplots(constrained_layout=True) + fig, ax = plt.subplots(layout="constrained") with pytest.warns(UserWarning): # this should warn, fig.subplots_adjust(top=0.8) - assert not(fig.get_constrained_layout()) + assert isinstance(fig.get_layout_engine(), ConstrainedLayoutEngine) # Using layout + (tight|constrained)_layout warns, but the former takes # precedence. - with pytest.warns(UserWarning, match="Figure parameters 'layout' and " - "'tight_layout' cannot"): + wst = "The Figure parameters 'layout' and 'tight_layout'" + with pytest.warns(UserWarning, match=wst): fig = Figure(layout='tight', tight_layout=False) - assert fig.get_tight_layout() - assert not fig.get_constrained_layout() - with pytest.warns(UserWarning, match="Figure parameters 'layout' and " - "'constrained_layout' cannot"): + assert isinstance(fig.get_layout_engine(), TightLayoutEngine) + wst = "The Figure parameters 'layout' and 'constrained_layout'" + with pytest.warns(UserWarning, match=wst): fig = Figure(layout='constrained', constrained_layout=False) - assert not fig.get_tight_layout() - assert fig.get_constrained_layout() + assert not isinstance(fig.get_layout_engine(), TightLayoutEngine) + assert isinstance(fig.get_layout_engine(), ConstrainedLayoutEngine) with pytest.raises(ValueError, - match="'foobar' is not a valid value for layout"): + match="Invalid value for 'layout'"): Figure(layout='foobar') + # test that layouts can be swapped if no colorbar: + fig, ax = plt.subplots(layout="constrained") + fig.set_layout_engine("tight") + assert isinstance(fig.get_layout_engine(), TightLayoutEngine) + fig.set_layout_engine("constrained") + assert isinstance(fig.get_layout_engine(), ConstrainedLayoutEngine) + + # test that layouts cannot be swapped if there is a colorbar: + fig, ax = plt.subplots(layout="constrained") + pc = ax.pcolormesh(np.random.randn(2, 2)) + fig.colorbar(pc) + with pytest.raises(RuntimeError, match='Colorbar layout of new layout'): + fig.set_layout_engine("tight") + fig.set_layout_engine("none") + with pytest.raises(RuntimeError, match='Colorbar layout of new layout'): + fig.set_layout_engine("tight") + + fig, ax = plt.subplots(layout="tight") + pc = ax.pcolormesh(np.random.randn(2, 2)) + fig.colorbar(pc) + with pytest.raises(RuntimeError, match='Colorbar layout of new layout'): + fig.set_layout_engine("constrained") + fig.set_layout_engine("none") + assert isinstance(fig.get_layout_engine(), PlaceHolderLayoutEngine) + + with pytest.raises(RuntimeError, match='Colorbar layout of new layout'): + fig.set_layout_engine("constrained") + + +@pytest.mark.parametrize('layout', ['constrained', 'compressed']) +def test_layout_change_warning(layout): + """ + Raise a warning when a previously assigned layout changes to tight using + plt.tight_layout(). + """ + fig, ax = plt.subplots(layout=layout) + with pytest.warns(UserWarning, match='The figure layout has changed to'): + plt.tight_layout() + @check_figures_equal(extensions=["png", "pdf"]) def test_add_artist(fig_test, fig_ref): - fig_test.set_dpi(100) - fig_ref.set_dpi(100) + fig_test.dpi = 100 + fig_ref.dpi = 100 fig_test.subplots() l1 = plt.Line2D([.2, .7], [.7, .7], gid='l1') @@ -771,7 +792,7 @@ def test_figure_clear(clear_meth): assert fig.axes == [] -def test_clf_not_refedined(): +def test_clf_not_redefined(): for klass in FigureBase.__subclasses__(): # check that subclasses do not get redefined in our Figure subclasses assert 'clf' not in klass.__dict__ @@ -780,7 +801,7 @@ def test_clf_not_refedined(): @mpl.style.context('mpl20') def test_picking_does_not_stale(): fig, ax = plt.subplots() - col = ax.scatter([0], [0], [1000], picker=True) + ax.scatter([0], [0], [1000], picker=True) fig.canvas.draw() assert not fig.stale @@ -834,7 +855,12 @@ def test_animated_with_canvas_change(fig_test, fig_ref): class TestSubplotMosaic: @check_figures_equal(extensions=["png"]) @pytest.mark.parametrize( - "x", [[["A", "A", "B"], ["C", "D", "B"]], [[1, 1, 2], [3, 4, 2]]] + "x", [ + [["A", "A", "B"], ["C", "D", "B"]], + [[1, 1, 2], [3, 4, 2]], + (("A", "A", "B"), ("C", "D", "B")), + ((1, 1, 2), (3, 4, 2)) + ] ) def test_basic(self, fig_test, fig_ref, x): grid_axes = fig_test.subplot_mosaic(x) @@ -864,8 +890,8 @@ def test_all_nested(self, fig_test, fig_ref): x = [["A", "B"], ["C", "D"]] y = [["E", "F"], ["G", "H"]] - fig_ref.set_constrained_layout(True) - fig_test.set_constrained_layout(True) + fig_ref.set_layout_engine("constrained") + fig_test.set_layout_engine("constrained") grid_axes = fig_test.subplot_mosaic([[x, y]]) for ax in grid_axes.values(): @@ -885,8 +911,8 @@ def test_all_nested(self, fig_test, fig_ref): @check_figures_equal(extensions=["png"]) def test_nested(self, fig_test, fig_ref): - fig_ref.set_constrained_layout(True) - fig_test.set_constrained_layout(True) + fig_ref.set_layout_engine("constrained") + fig_test.set_layout_engine("constrained") x = [["A", "B"], ["C", "D"]] @@ -924,6 +950,26 @@ def test_nested_tuple(self, fig_test, fig_ref): fig_ref.subplot_mosaic([["F"], [x]]) fig_test.subplot_mosaic([["F"], [xt]]) + def test_nested_width_ratios(self): + x = [["A", [["B"], + ["C"]]]] + width_ratios = [2, 1] + + fig, axd = plt.subplot_mosaic(x, width_ratios=width_ratios) + + assert axd["A"].get_gridspec().get_width_ratios() == width_ratios + assert axd["B"].get_gridspec().get_width_ratios() != width_ratios + + def test_nested_height_ratios(self): + x = [["A", [["B"], + ["C"]]], ["D", "D"]] + height_ratios = [1, 2] + + fig, axd = plt.subplot_mosaic(x, height_ratios=height_ratios) + + assert axd["D"].get_gridspec().get_height_ratios() == height_ratios + assert axd["B"].get_gridspec().get_height_ratios() != height_ratios + @check_figures_equal(extensions=["png"]) @pytest.mark.parametrize( "x, empty_sentinel", @@ -964,6 +1010,10 @@ def test_fail_list_of_str(self): plt.subplot_mosaic(['foo', 'bar']) with pytest.raises(ValueError, match='must be 2D'): plt.subplot_mosaic(['foo']) + with pytest.raises(ValueError, match='must be 2D'): + plt.subplot_mosaic([['foo', ('bar',)]]) + with pytest.raises(ValueError, match='must be 2D'): + plt.subplot_mosaic([['a', 'b'], [('a', 'b'), 'c']]) @check_figures_equal(extensions=["png"]) @pytest.mark.parametrize("subplot_kw", [{}, {"projection": "polar"}, None]) @@ -977,8 +1027,26 @@ def test_subplot_kw(self, fig_test, fig_ref, subplot_kw): axB = fig_ref.add_subplot(gs[0, 1], **subplot_kw) + @check_figures_equal(extensions=["png"]) + @pytest.mark.parametrize("multi_value", ['BC', tuple('BC')]) + def test_per_subplot_kw(self, fig_test, fig_ref, multi_value): + x = 'AB;CD' + grid_axes = fig_test.subplot_mosaic( + x, + subplot_kw={'facecolor': 'red'}, + per_subplot_kw={ + 'D': {'facecolor': 'blue'}, + multi_value: {'facecolor': 'green'}, + } + ) + + gs = fig_ref.add_gridspec(2, 2) + for color, spec in zip(['red', 'green', 'green', 'blue'], gs): + fig_ref.add_subplot(spec, facecolor=color) + def test_string_parser(self): normalize = Figure._normalize_grid_string + assert normalize('ABC') == [['A', 'B', 'C']] assert normalize('AB;CC') == [['A', 'B'], ['C', 'C']] assert normalize('AB;CC;DE') == [['A', 'B'], ['C', 'C'], ['D', 'E']] @@ -995,6 +1063,25 @@ def test_string_parser(self): DE """) == [['A', 'B'], ['C', 'C'], ['D', 'E']] + def test_per_subplot_kw_expander(self): + normalize = Figure._norm_per_subplot_kw + assert normalize({"A": {}, "B": {}}) == {"A": {}, "B": {}} + assert normalize({("A", "B"): {}}) == {"A": {}, "B": {}} + with pytest.raises( + ValueError, match=f'The key {"B"!r} appears multiple times' + ): + normalize({("A", "B"): {}, "B": {}}) + with pytest.raises( + ValueError, match=f'The key {"B"!r} appears multiple times' + ): + normalize({"B": {}, ("A", "B"): {}}) + + def test_extra_per_subplot_kw(self): + with pytest.raises( + ValueError, match=f'The keys {set("B")!r} are in' + ): + Figure().subplot_mosaic("A", per_subplot_kw={"B": {}}) + @check_figures_equal(extensions=["png"]) @pytest.mark.parametrize("str_pattern", ["AAA\nBBB", "\nAAA\nBBB\n", "ABC\nDEF"] @@ -1090,11 +1177,10 @@ def test_reused_gridspec(): @image_comparison(['test_subfigure.png'], style='mpl20', - savefig_kwarg={'facecolor': 'teal'}, - remove_text=False) + savefig_kwarg={'facecolor': 'teal'}) def test_subfigure(): np.random.seed(19680801) - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout='constrained') sub = fig.subfigures(1, 2) axs = sub[0].subplots(2, 2) @@ -1114,7 +1200,7 @@ def test_subfigure(): def test_subfigure_tightbbox(): # test that we can get the tightbbox with a subfigure... - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout='constrained') sub = fig.subfigures(1, 2) np.testing.assert_allclose( @@ -1122,13 +1208,22 @@ def test_subfigure_tightbbox(): 8.0) +def test_subfigure_dpi(): + fig = plt.figure(dpi=100) + sub_fig = fig.subfigures() + assert sub_fig.get_dpi() == fig.get_dpi() + + sub_fig.set_dpi(200) + assert sub_fig.get_dpi() == 200 + assert fig.get_dpi() == 200 + + @image_comparison(['test_subfigure_ss.png'], style='mpl20', - savefig_kwarg={'facecolor': 'teal'}, - remove_text=False) + savefig_kwarg={'facecolor': 'teal'}, tol=0.02) def test_subfigure_ss(): # test assigning the subfigure via subplotspec np.random.seed(19680801) - fig = plt.figure(constrained_layout=True) + fig = plt.figure(layout='constrained') gs = fig.add_gridspec(1, 2) sub = fig.add_subfigure(gs[0], facecolor='pink') @@ -1147,13 +1242,12 @@ def test_subfigure_ss(): @image_comparison(['test_subfigure_double.png'], style='mpl20', - savefig_kwarg={'facecolor': 'teal'}, - remove_text=False) + savefig_kwarg={'facecolor': 'teal'}) def test_subfigure_double(): # test assigning the subfigure via subplotspec np.random.seed(19680801) - fig = plt.figure(constrained_layout=True, figsize=(10, 8)) + fig = plt.figure(layout='constrained', figsize=(10, 8)) fig.suptitle('fig') @@ -1237,10 +1331,10 @@ def test_subfigure_ticks(): ax2.scatter(x=[-126.5357270050049, 94.68456736755368], y=[1500, 3600]) ax3 = subfig_bl.add_subplot(gs[0, 3:14], sharey=ax1) - fig.set_dpi(120) + fig.dpi = 120 fig.draw_without_rendering() ticks120 = ax2.get_xticks() - fig.set_dpi(300) + fig.dpi = 300 fig.draw_without_rendering() ticks300 = ax2.get_xticks() np.testing.assert_allclose(ticks120, ticks300) @@ -1263,6 +1357,16 @@ def test_subfigure_scatter_size(): ax.scatter([3, 4, 5], [1, 2, 3], s=[20, 30, 40], marker='s', color='g') +def test_subfigure_pdf(): + fig = plt.figure(layout='constrained') + sub_fig = fig.subfigures() + ax = sub_fig.add_subplot(111) + b = ax.bar(1, 1) + ax.bar_label(b) + buffer = io.BytesIO() + fig.savefig(buffer, format='pdf') + + def test_add_subplot_kwargs(): # fig.add_subplot() always creates new axes, even if axes kwargs differ. fig = plt.figure() @@ -1350,3 +1454,58 @@ def test_kwargs_pass(): assert fig.get_label() == 'whole Figure' assert sub_fig.get_label() == 'sub figure' + + +@check_figures_equal(extensions=["png"]) +def test_rcparams(fig_test, fig_ref): + fig_ref.supxlabel("xlabel", weight='bold', size=15) + fig_ref.supylabel("ylabel", weight='bold', size=15) + fig_ref.suptitle("Title", weight='light', size=20) + with mpl.rc_context({'figure.labelweight': 'bold', + 'figure.labelsize': 15, + 'figure.titleweight': 'light', + 'figure.titlesize': 20}): + fig_test.supxlabel("xlabel") + fig_test.supylabel("ylabel") + fig_test.suptitle("Title") + + +def test_deepcopy(): + fig1, ax = plt.subplots() + ax.plot([0, 1], [2, 3]) + ax.set_yscale('log') + + fig2 = copy.deepcopy(fig1) + + # Make sure it is a new object + assert fig2.axes[0] is not ax + # And that the axis scale got propagated + assert fig2.axes[0].get_yscale() == 'log' + # Update the deepcopy and check the original isn't modified + fig2.axes[0].set_yscale('linear') + assert ax.get_yscale() == 'log' + + # And test the limits of the axes don't get propagated + ax.set_xlim(1e-1, 1e2) + # Draw these to make sure limits are updated + fig1.draw_without_rendering() + fig2.draw_without_rendering() + + assert ax.get_xlim() == (1e-1, 1e2) + assert fig2.axes[0].get_xlim() == (0, 1) + + +def test_unpickle_with_device_pixel_ratio(): + fig = Figure(dpi=42) + fig.canvas._set_device_pixel_ratio(7) + assert fig.dpi == 42*7 + fig2 = pickle.loads(pickle.dumps(fig)) + assert fig2.dpi == 42 + + +def test_gridspec_no_mutate_input(): + gs = {'left': .1} + gs_orig = dict(gs) + plt.subplots(1, 2, width_ratios=[1, 2], gridspec_kw=gs) + assert gs == gs_orig + plt.subplot_mosaic('AB', width_ratios=[1, 2], gridspec_kw=gs) diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 4cad797b3757..3724db1e1b43 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -2,6 +2,7 @@ import multiprocessing import os from pathlib import Path +from PIL import Image import shutil import subprocess import sys @@ -11,9 +12,10 @@ import pytest from matplotlib.font_manager import ( - findfont, findSystemFonts, FontProperties, fontManager, json_dump, - json_load, get_font, is_opentype_cff_font, MSUserFontDirectories, - _get_fontconfig_fonts) + findfont, findSystemFonts, FontEntry, FontProperties, fontManager, + json_dump, json_load, get_font, is_opentype_cff_font, + MSUserFontDirectories, _get_fontconfig_fonts, ft2font, + ttfFontProperty, cbook) from matplotlib import pyplot as plt, rc_context has_fclist = shutil.which('fc-list') is not None @@ -71,7 +73,8 @@ def test_otf(): assert res == is_opentype_cff_font(f.fname) -@pytest.mark.skipif(not has_fclist, reason='no fontconfig installed') +@pytest.mark.skipif(sys.platform == "win32" or not has_fclist, + reason='no fontconfig installed') def test_get_fontconfig_fonts(): assert len(_get_fontconfig_fonts()) > 1 @@ -101,7 +104,7 @@ def test_utf16m_sfnt(): entry = next(entry for entry in fontManager.ttflist if Path(entry.fname).name == "seguisbi.ttf") except StopIteration: - pytest.skip("Couldn't find font to test against.") + pytest.skip("Couldn't find seguisbi.ttf font to test against.") else: # Check that we successfully read "semibold" from the font's sfnt table # and set its weight accordingly. @@ -111,14 +114,25 @@ def test_utf16m_sfnt(): def test_find_ttc(): fp = FontProperties(family=["WenQuanYi Zen Hei"]) if Path(findfont(fp)).name != "wqy-zenhei.ttc": - pytest.skip("Font may be missing") - + pytest.skip("Font wqy-zenhei.ttc may be missing") fig, ax = plt.subplots() ax.text(.5, .5, "\N{KANGXI RADICAL DRAGON}", fontproperties=fp) for fmt in ["raw", "svg", "pdf", "ps"]: fig.savefig(BytesIO(), format=fmt) +def test_find_noto(): + fp = FontProperties(family=["Noto Sans CJK SC", "Noto Sans CJK JP"]) + name = Path(findfont(fp)).name + if name not in ("NotoSansCJKsc-Regular.otf", "NotoSansCJK-Regular.ttc"): + pytest.skip(f"Noto Sans CJK SC font may be missing (found {name})") + + fig, ax = plt.subplots() + ax.text(0.5, 0.5, 'Hello, 你好', fontproperties=fp) + for fmt in ["raw", "svg", "pdf", "ps"]: + fig.savefig(BytesIO(), format=fmt) + + def test_find_invalid(tmpdir): tmp_path = Path(tmpdir) @@ -133,11 +147,12 @@ def test_find_invalid(tmpdir): # Not really public, but get_font doesn't expose non-filename constructor. from matplotlib.ft2font import FT2Font - with pytest.raises(TypeError, match='path or binary-mode file'): + with pytest.raises(TypeError, match='font file or a binary-mode file'): FT2Font(StringIO()) -@pytest.mark.skipif(sys.platform != 'linux', reason='Linux only') +@pytest.mark.skipif(sys.platform != 'linux' or not has_fclist, + reason='only Linux with fontconfig installed') def test_user_fonts_linux(tmpdir, monkeypatch): font_test_file = 'mpltest.ttf' @@ -163,13 +178,29 @@ def test_user_fonts_linux(tmpdir, monkeypatch): _get_fontconfig_fonts.cache_clear() +def test_addfont_as_path(): + """Smoke test that addfont() accepts pathlib.Path.""" + font_test_file = 'mpltest.ttf' + path = Path(__file__).parent / font_test_file + try: + fontManager.addfont(path) + added, = [font for font in fontManager.ttflist + if font.fname.endswith(font_test_file)] + fontManager.ttflist.remove(added) + finally: + to_remove = [font for font in fontManager.ttflist + if font.fname.endswith(font_test_file)] + for font in to_remove: + fontManager.ttflist.remove(font) + + @pytest.mark.skipif(sys.platform != 'win32', reason='Windows only') def test_user_fonts_win32(): if not (os.environ.get('APPVEYOR') or os.environ.get('TF_BUILD')): pytest.xfail("This test should only run on CI (appveyor or azure) " "as the developer's font directory should remain " "unchanged.") - + pytest.xfail("We need to update the registry for this test to work") font_test_file = 'mpltest.ttf' # Precondition: the test font should not be available @@ -255,3 +286,41 @@ def test_fontcache_thread_safe(): if proc.returncode: pytest.fail("The subprocess returned with non-zero exit status " f"{proc.returncode}.") + + +def test_fontentry_dataclass(): + fontent = FontEntry(name='font-name') + + png = fontent._repr_png_() + img = Image.open(BytesIO(png)) + assert img.width > 0 + assert img.height > 0 + + html = fontent._repr_html_() + assert html.startswith("
127: + assert Path(font.fname).name == found_file_name + else: + assert Path(font.fname).name == "DejaVuSans.ttf" diff --git a/lib/matplotlib/tests/test_getattr.py b/lib/matplotlib/tests/test_getattr.py index 71556b5b4c71..8fcb981746b2 100644 --- a/lib/matplotlib/tests/test_getattr.py +++ b/lib/matplotlib/tests/test_getattr.py @@ -4,10 +4,16 @@ import matplotlib import pytest -# Get the names of all matplotlib submodules, except for the unit tests. -module_names = [m.name for m in walk_packages(path=matplotlib.__path__, - prefix=f'{matplotlib.__name__}.') - if not m.name.startswith(__package__)] +# Get the names of all matplotlib submodules, +# except for the unit tests and private modules. +module_names = [ + m.name + for m in walk_packages( + path=matplotlib.__path__, prefix=f'{matplotlib.__name__}.' + ) + if not m.name.startswith(__package__) + and not any(x.startswith('_') for x in m.name.split('.')) +] @pytest.mark.parametrize('module_name', module_names) diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 719b19057875..76a622181ddf 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -13,7 +13,7 @@ import matplotlib as mpl from matplotlib import ( - _api, colors, image as mimage, patches, pyplot as plt, style, rcParams) + colors, image as mimage, patches, pyplot as plt, style, rcParams) from matplotlib.image import (AxesImage, BboxImage, FigureImage, NonUniformImage, PcolorImage) from matplotlib.testing.decorators import check_figures_equal, image_comparison @@ -249,6 +249,7 @@ def test_imsave_pil_kwargs_tiff(): buf = io.BytesIO() pil_kwargs = {"description": "test image"} plt.imsave(buf, [[0, 1], [2, 3]], format="tiff", pil_kwargs=pil_kwargs) + assert len(pil_kwargs) == 1 im = Image.open(buf) tags = {TAGS[k].name: v for k, v in im.tag_v2.items()} assert tags["ImageDescription"] == "test image" @@ -342,6 +343,7 @@ def test_cursor_data(): ([[.123, .987]], "[0.123]"), ([[np.nan, 1, 2]], "[]"), ([[1, 1+1e-15]], "[1.0000000000000000]"), + ([[-1, -1]], "[-1.0000000000000000]"), ]) def test_format_cursor_data(data, text): from matplotlib.backend_bases import MouseEvent @@ -588,6 +590,20 @@ def test_get_window_extent_for_AxisImage(): assert_array_equal(im_bbox.get_points(), [[400, 200], [700, 900]]) + fig, ax = plt.subplots(figsize=(10, 10), dpi=100) + ax.set_position([0, 0, 1, 1]) + ax.set_xlim(1, 2) + ax.set_ylim(0, 1) + im_obj = ax.imshow( + im, extent=[0.4, 0.7, 0.2, 0.9], interpolation='nearest', + transform=ax.transAxes) + + fig.canvas.draw() + renderer = fig.canvas.renderer + im_bbox = im_obj.get_window_extent(renderer) + + assert_array_equal(im_bbox.get_points(), [[400, 200], [700, 900]]) + @image_comparison(['zoom_and_clip_upper_origin.png'], remove_text=True, style='mpl20') @@ -720,7 +736,7 @@ def test_load_from_url(): url = ('file:' + ('///' if sys.platform == 'win32' else '') + path.resolve().as_posix()) - with _api.suppress_matplotlib_deprecation_warning(): + with pytest.raises(ValueError, match="Please open the URL"): plt.imread(url) with urllib.request.urlopen(url) as file: plt.imread(file) @@ -882,7 +898,7 @@ def test_imshow_endianess(): remove_text=True, style='mpl20') def test_imshow_masked_interpolation(): - cmap = plt.get_cmap('viridis').with_extremes(over='r', under='b', bad='k') + cmap = mpl.colormaps['viridis'].with_extremes(over='r', under='b', bad='k') N = 20 n = colors.Normalize(vmin=0, vmax=N*N-1) @@ -977,13 +993,13 @@ def test_imshow_bignumbers_real(): def test_empty_imshow(make_norm): fig, ax = plt.subplots() with pytest.warns(UserWarning, - match="Attempting to set identical left == right"): + match="Attempting to set identical low and high xlims"): im = ax.imshow([[]], norm=make_norm()) im.set_extent([-5, 5, -5, 5]) fig.canvas.draw() with pytest.raises(RuntimeError): - im.make_image(fig._cachedRenderer) + im.make_image(fig.canvas.get_renderer()) def test_imshow_float16(): @@ -1095,7 +1111,7 @@ def test_image_array_alpha(fig_test, fig_ref): zz = np.exp(- 3 * ((xx - 0.5) ** 2) + (yy - 0.7 ** 2)) alpha = zz / zz.max() - cmap = plt.get_cmap('viridis') + cmap = mpl.colormaps['viridis'] ax = fig_test.add_subplot() ax.imshow(zz, alpha=alpha, cmap=cmap, interpolation='nearest') @@ -1112,7 +1128,7 @@ def test_image_array_alpha_validation(): @mpl.style.context('mpl20') def test_exact_vmin(): - cmap = copy(plt.cm.get_cmap("autumn_r")) + cmap = copy(mpl.colormaps["autumn_r"]) cmap.set_under(color="lightgrey") # make the image exactly 190 pixels wide @@ -1139,13 +1155,6 @@ def test_exact_vmin(): assert np.all(from_image == direct_computation) -@pytest.mark.network -@pytest.mark.flaky -def test_https_imread_smoketest(): - with _api.suppress_matplotlib_deprecation_warning(): - v = mimage.imread('https://matplotlib.org/1.5.0/_static/logo2.png') - - # A basic ndarray subclass that implements a quantity # It does not implement an entire unit system or all quantity math. # There is just enough implemented to test handling of ndarray @@ -1169,7 +1178,7 @@ def __getitem__(self, item): def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): func = getattr(ufunc, method) if "out" in kwargs: - raise NotImplementedError + return NotImplemented if len(inputs) == 1: i0 = inputs[0] unit = getattr(i0, "units", "dimensionless") @@ -1189,11 +1198,16 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): unit = f"{u0}*{u1}" elif ufunc == np.divide: unit = f"{u0}/({u1})" + elif ufunc in (np.greater, np.greater_equal, + np.equal, np.not_equal, + np.less, np.less_equal): + # Comparisons produce unitless booleans for output + unit = None else: - raise NotImplementedError + return NotImplemented out_arr = func(i0.view(np.ndarray), i1.view(np.ndarray), **kwargs) else: - raise NotImplementedError + return NotImplemented if unit is None: out_arr = np.array(out_arr) else: @@ -1235,7 +1249,7 @@ def test_norm_change(fig_test, fig_ref): masked_data = np.ma.array(data, mask=False) masked_data.mask[0:2, 0:2] = True - cmap = plt.get_cmap('viridis').with_extremes(under='w') + cmap = mpl.colormaps['viridis'].with_extremes(under='w') ax = fig_test.subplots() im = ax.imshow(data, norm=colors.LogNorm(vmin=0.5, vmax=1), @@ -1269,7 +1283,7 @@ def test_huge_range_log(fig_test, fig_ref, x): data[0:2, :] = 1000 ax = fig_ref.subplots() - cmap = plt.get_cmap('viridis').with_extremes(under='w') + cmap = mpl.colormaps['viridis'].with_extremes(under='w') ax.imshow(data, norm=colors.Normalize(vmin=1, vmax=data.max()), interpolation='nearest', cmap=cmap) @@ -1323,8 +1337,9 @@ def test_nonuniform_and_pcolor(): ax.set(xlim=(0, 10)) -@image_comparison(["rgba_antialias.png"], style="mpl20", - remove_text=True) +@image_comparison( + ['rgba_antialias.png'], style='mpl20', remove_text=True, + tol=0.007 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_rgba_antialias(): fig, axs = plt.subplots(2, 2, figsize=(3.5, 3.5), sharex=False, sharey=False, constrained_layout=True) @@ -1376,3 +1391,65 @@ def test_rgba_antialias(): # alternating red and blue stripes become purple axs[3].imshow(aa, interpolation='antialiased', interpolation_stage='rgba', cmap=cmap, vmin=-1.2, vmax=1.2) + + +# We check for the warning with a draw() in the test, but we also need to +# filter the warning as it is emitted by the figure test decorator +@pytest.mark.filterwarnings(r'ignore:Data with more than .* ' + 'cannot be accurately displayed') +@pytest.mark.parametrize('origin', ['upper', 'lower']) +@pytest.mark.parametrize( + 'dim, size, msg', [['row', 2**23, r'2\*\*23 columns'], + ['col', 2**24, r'2\*\*24 rows']]) +@check_figures_equal(extensions=('png', )) +def test_large_image(fig_test, fig_ref, dim, size, msg, origin): + # Check that Matplotlib downsamples images that are too big for AGG + # See issue #19276. Currently the fix only works for png output but not + # pdf or svg output. + ax_test = fig_test.subplots() + ax_ref = fig_ref.subplots() + + array = np.zeros((1, size + 2)) + array[:, array.size // 2:] = 1 + if dim == 'col': + array = array.T + im = ax_test.imshow(array, vmin=0, vmax=1, + aspect='auto', extent=(0, 1, 0, 1), + interpolation='none', + origin=origin) + + with pytest.warns(UserWarning, + match=f'Data with more than {msg} cannot be ' + 'accurately displayed.'): + fig_test.canvas.draw() + + array = np.zeros((1, 2)) + array[:, 1] = 1 + if dim == 'col': + array = array.T + im = ax_ref.imshow(array, vmin=0, vmax=1, aspect='auto', + extent=(0, 1, 0, 1), + interpolation='none', + origin=origin) + + +@check_figures_equal(extensions=["png"]) +def test_str_norms(fig_test, fig_ref): + t = np.random.rand(10, 10) * .8 + .1 # between 0 and 1 + axts = fig_test.subplots(1, 5) + axts[0].imshow(t, norm="log") + axts[1].imshow(t, norm="log", vmin=.2) + axts[2].imshow(t, norm="symlog") + axts[3].imshow(t, norm="symlog", vmin=.3, vmax=.7) + axts[4].imshow(t, norm="logit", vmin=.3, vmax=.7) + axrs = fig_ref.subplots(1, 5) + axrs[0].imshow(t, norm=colors.LogNorm()) + axrs[1].imshow(t, norm=colors.LogNorm(vmin=.2)) + # same linthresh as SymmetricalLogScale's default. + axrs[2].imshow(t, norm=colors.SymLogNorm(linthresh=2)) + axrs[3].imshow(t, norm=colors.SymLogNorm(linthresh=2, vmin=.3, vmax=.7)) + axrs[4].imshow(t, norm="logit", clim=(.3, .7)) + + assert type(axts[0].images[0].norm) == colors.LogNorm # Exactly that class + with pytest.raises(ValueError): + axts[0].imshow(t, norm="foobar") diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index fe8a8dd5f61b..a8d7fd107d8b 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1,13 +1,17 @@ import collections import platform from unittest import mock +import warnings import numpy as np +from numpy.testing import assert_allclose import pytest -from matplotlib.testing.decorators import image_comparison +from matplotlib.testing.decorators import check_figures_equal, image_comparison +from matplotlib.testing._markers import needs_usetex import matplotlib.pyplot as plt import matplotlib as mpl +import matplotlib.patches as mpatches import matplotlib.transforms as mtransforms import matplotlib.collections as mcollections import matplotlib.lines as mlines @@ -67,6 +71,60 @@ def test_legend_auto3(): ax.legend(loc='best') +def test_legend_auto4(): + """ + Check that the legend location with automatic placement is the same, + whatever the histogram type is. Related to issue #9580. + """ + # NB: barstacked is pointless with a single dataset. + fig, axs = plt.subplots(ncols=3, figsize=(6.4, 2.4)) + leg_bboxes = [] + for ax, ht in zip(axs.flat, ('bar', 'step', 'stepfilled')): + ax.set_title(ht) + # A high bar on the left but an even higher one on the right. + ax.hist([0] + 5*[9], bins=range(10), label="Legend", histtype=ht) + leg = ax.legend(loc="best") + fig.canvas.draw() + leg_bboxes.append( + leg.get_window_extent().transformed(ax.transAxes.inverted())) + + # The histogram type "bar" is assumed to be the correct reference. + assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds) + assert_allclose(leg_bboxes[2].bounds, leg_bboxes[0].bounds) + + +def test_legend_auto5(): + """ + Check that the automatic placement handle a rather complex + case with non rectangular patch. Related to issue #9580. + """ + fig, axs = plt.subplots(ncols=2, figsize=(9.6, 4.8)) + + leg_bboxes = [] + for ax, loc in zip(axs.flat, ("center", "best")): + # An Ellipse patch at the top, a U-shaped Polygon patch at the + # bottom and a ring-like Wedge patch: the correct placement of + # the legend should be in the center. + for _patch in [ + mpatches.Ellipse( + xy=(0.5, 0.9), width=0.8, height=0.2, fc="C1"), + mpatches.Polygon(np.array([ + [0, 1], [0, 0], [1, 0], [1, 1], [0.9, 1.0], [0.9, 0.1], + [0.1, 0.1], [0.1, 1.0], [0.1, 1.0]]), fc="C1"), + mpatches.Wedge((0.5, 0.5), 0.5, 0, 360, width=0.05, fc="C0") + ]: + ax.add_patch(_patch) + + ax.plot([0.1, 0.9], [0.9, 0.9], label="A segment") # sthg to label + + leg = ax.legend(loc=loc) + fig.canvas.draw() + leg_bboxes.append( + leg.get_window_extent().transformed(ax.transAxes.inverted())) + + assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds) + + @image_comparison(['legend_various_labels'], remove_text=True) def test_various_labels(): # tests all sorts of label types @@ -78,6 +136,20 @@ def test_various_labels(): ax.legend(numpoints=1, loc='best') +def test_legend_label_with_leading_underscore(): + """ + Test that artists with labels starting with an underscore are not added to + the legend, and that a warning is issued if one tries to add them + explicitly. + """ + fig, ax = plt.subplots() + line, = ax.plot([0, 1], label='_foo') + with pytest.warns(UserWarning, + match=r"starts with '_'.*excluded from the legend."): + legend = ax.legend(handles=[line]) + assert len(legend.legend_handles) == 0 + + @image_comparison(['legend_labels_first.png'], remove_text=True) def test_labels_first(): # test labels to left of markers @@ -133,7 +205,7 @@ def test_fancy(): plt.errorbar(np.arange(10), np.arange(10), xerr=0.5, yerr=0.5, label='XX') plt.legend(loc="center left", bbox_to_anchor=[1.0, 0.5], - ncol=2, shadow=True, title="My legend", numpoints=1) + ncols=2, shadow=True, title="My legend", numpoints=1) @image_comparison(['framealpha'], remove_text=True, @@ -175,7 +247,7 @@ def test_legend_expand(): ax.plot(x, x - 50, 'o', label='y=-1') l2 = ax.legend(loc='right', mode=mode) ax.add_artist(l2) - ax.legend(loc='lower left', mode=mode, ncol=2) + ax.legend(loc='lower left', mode=mode, ncols=2) @image_comparison(['hatching'], remove_text=True, style='default') @@ -222,6 +294,38 @@ def test_legend_remove(): assert ax.get_legend() is None +def test_reverse_legend_handles_and_labels(): + """Check that the legend handles and labels are reversed.""" + fig, ax = plt.subplots() + x = 1 + y = 1 + labels = ["First label", "Second label", "Third label"] + markers = ['.', ',', 'o'] + + ax.plot(x, y, markers[0], label=labels[0]) + ax.plot(x, y, markers[1], label=labels[1]) + ax.plot(x, y, markers[2], label=labels[2]) + leg = ax.legend(reverse=True) + actual_labels = [t.get_text() for t in leg.get_texts()] + actual_markers = [h.get_marker() for h in leg.legend_handles] + assert actual_labels == list(reversed(labels)) + assert actual_markers == list(reversed(markers)) + + +@check_figures_equal(extensions=["png"]) +def test_reverse_legend_display(fig_test, fig_ref): + """Check that the rendered legend entries are reversed""" + ax = fig_test.subplots() + ax.plot([1], 'ro', label="first") + ax.plot([2], 'bx', label="second") + ax.legend(reverse=True) + + ax = fig_ref.subplots() + ax.plot([2], 'bx', label="second") + ax.plot([1], 'ro', label="first") + ax.legend() + + class TestLegendFunction: # Tests the legend function on the Axes and pyplot. def test_legend_no_args(self): @@ -382,6 +486,47 @@ def test_warn_args_kwargs(self): "be discarded.") +def test_figure_legend_outside(): + todos = ['upper ' + pos for pos in ['left', 'center', 'right']] + todos += ['lower ' + pos for pos in ['left', 'center', 'right']] + todos += ['left ' + pos for pos in ['lower', 'center', 'upper']] + todos += ['right ' + pos for pos in ['lower', 'center', 'upper']] + + upperext = [20.347556, 27.722556, 790.583, 545.499] + lowerext = [20.347556, 71.056556, 790.583, 588.833] + leftext = [151.681556, 27.722556, 790.583, 588.833] + rightext = [20.347556, 27.722556, 659.249, 588.833] + axbb = [upperext, upperext, upperext, + lowerext, lowerext, lowerext, + leftext, leftext, leftext, + rightext, rightext, rightext] + + legbb = [[10., 555., 133., 590.], # upper left + [338.5, 555., 461.5, 590.], # upper center + [667, 555., 790., 590.], # upper right + [10., 10., 133., 45.], # lower left + [338.5, 10., 461.5, 45.], # lower center + [667., 10., 790., 45.], # lower right + [10., 10., 133., 45.], # left lower + [10., 282.5, 133., 317.5], # left center + [10., 555., 133., 590.], # left upper + [667, 10., 790., 45.], # right lower + [667., 282.5, 790., 317.5], # right center + [667., 555., 790., 590.]] # right upper + + for nn, todo in enumerate(todos): + print(todo) + fig, axs = plt.subplots(constrained_layout=True, dpi=100) + axs.plot(range(10), label='Boo1') + leg = fig.legend(loc='outside ' + todo) + fig.draw_without_rendering() + + assert_allclose(axs.get_window_extent().extents, + axbb[nn]) + assert_allclose(leg.get_window_extent().extents, + legbb[nn]) + + @image_comparison(['legend_stackplot.png']) def test_legend_stackplot(): """Test legend for PolyCollection using stackplot.""" @@ -478,11 +623,10 @@ def test_linecollection_scaled_dashes(): ax.add_collection(lc3) leg = ax.legend([lc1, lc2, lc3], ["line1", "line2", 'line 3']) - h1, h2, h3 = leg.legendHandles + h1, h2, h3 = leg.legend_handles for oh, lh in zip((lc1, lc2, lc3), (h1, h2, h3)): - assert oh.get_linestyles()[0][1] == lh._dashSeq - assert oh.get_linestyles()[0][0] == lh._dashOffset + assert oh.get_linestyles()[0] == lh._dash_pattern def test_handler_numpoints(): @@ -493,6 +637,22 @@ def test_handler_numpoints(): ax.legend(numpoints=0.5) +def test_text_nohandler_warning(): + """Test that Text artists with labels raise a warning""" + fig, ax = plt.subplots() + ax.text(x=0, y=0, s="text", label="label") + with pytest.warns(UserWarning) as record: + ax.legend() + assert len(record) == 1 + + # this should _not_ warn: + f, ax = plt.subplots() + ax.pcolormesh(np.random.uniform(0, 1, (10, 10))) + with warnings.catch_warnings(): + warnings.simplefilter("error") + ax.get_legend_handles_labels() + + def test_empty_bar_chart_with_legend(): """Test legend when bar chart is empty with a label.""" # related to issue #13003. Calling plt.legend() should not @@ -584,6 +744,25 @@ def test_legend_title_fontprop_fontsize(): assert leg5.get_title().get_fontsize() == 20 +@pytest.mark.parametrize('alignment', ('center', 'left', 'right')) +def test_legend_alignment(alignment): + fig, ax = plt.subplots() + ax.plot(range(10), label='test') + leg = ax.legend(title="Aardvark", alignment=alignment) + assert leg.get_children()[0].align == alignment + assert leg.get_alignment() == alignment + + +@pytest.mark.parametrize('alignment', ('center', 'left', 'right')) +def test_legend_set_alignment(alignment): + fig, ax = plt.subplots() + ax.plot(range(10), label='test') + leg = ax.legend() + leg.set_alignment(alignment) + assert leg.get_children()[0].align == alignment + assert leg.get_alignment() == alignment + + @pytest.mark.parametrize('color', ('red', 'none', (.5, .5, .5))) def test_legend_labelcolor_single(color): # test labelcolor for a single color @@ -621,6 +800,41 @@ def test_legend_labelcolor_linecolor(): assert mpl.colors.same_color(text.get_color(), color) +def test_legend_pathcollection_labelcolor_linecolor(): + # test the labelcolor for labelcolor='linecolor' on PathCollection + fig, ax = plt.subplots() + ax.scatter(np.arange(10), np.arange(10)*1, label='#1', c='r') + ax.scatter(np.arange(10), np.arange(10)*2, label='#2', c='g') + ax.scatter(np.arange(10), np.arange(10)*3, label='#3', c='b') + + leg = ax.legend(labelcolor='linecolor') + for text, color in zip(leg.get_texts(), ['r', 'g', 'b']): + assert mpl.colors.same_color(text.get_color(), color) + + +def test_legend_pathcollection_labelcolor_linecolor_iterable(): + # test the labelcolor for labelcolor='linecolor' on PathCollection + # with iterable colors + fig, ax = plt.subplots() + colors = np.random.default_rng().choice(['r', 'g', 'b'], 10) + ax.scatter(np.arange(10), np.arange(10)*1, label='#1', c=colors) + + leg = ax.legend(labelcolor='linecolor') + text, = leg.get_texts() + assert mpl.colors.same_color(text.get_color(), 'black') + + +def test_legend_pathcollection_labelcolor_linecolor_cmap(): + # test the labelcolor for labelcolor='linecolor' on PathCollection + # with a colormap + fig, ax = plt.subplots() + ax.scatter(np.arange(10), np.arange(10), c=np.arange(10), label='#1') + + leg = ax.legend(labelcolor='linecolor') + text, = leg.get_texts() + assert mpl.colors.same_color(text.get_color(), 'black') + + def test_legend_labelcolor_markeredgecolor(): # test the labelcolor for labelcolor='markeredgecolor' fig, ax = plt.subplots() @@ -633,6 +847,49 @@ def test_legend_labelcolor_markeredgecolor(): assert mpl.colors.same_color(text.get_color(), color) +def test_legend_pathcollection_labelcolor_markeredgecolor(): + # test the labelcolor for labelcolor='markeredgecolor' on PathCollection + fig, ax = plt.subplots() + ax.scatter(np.arange(10), np.arange(10)*1, label='#1', edgecolor='r') + ax.scatter(np.arange(10), np.arange(10)*2, label='#2', edgecolor='g') + ax.scatter(np.arange(10), np.arange(10)*3, label='#3', edgecolor='b') + + leg = ax.legend(labelcolor='markeredgecolor') + for text, color in zip(leg.get_texts(), ['r', 'g', 'b']): + assert mpl.colors.same_color(text.get_color(), color) + + +def test_legend_pathcollection_labelcolor_markeredgecolor_iterable(): + # test the labelcolor for labelcolor='markeredgecolor' on PathCollection + # with iterable colors + fig, ax = plt.subplots() + colors = np.random.default_rng().choice(['r', 'g', 'b'], 10) + ax.scatter(np.arange(10), np.arange(10)*1, label='#1', edgecolor=colors) + + leg = ax.legend(labelcolor='markeredgecolor') + for text, color in zip(leg.get_texts(), ['k']): + assert mpl.colors.same_color(text.get_color(), color) + + +def test_legend_pathcollection_labelcolor_markeredgecolor_cmap(): + # test the labelcolor for labelcolor='markeredgecolor' on PathCollection + # with a colormap + fig, ax = plt.subplots() + edgecolors = mpl.cm.viridis(np.random.rand(10)) + ax.scatter( + np.arange(10), + np.arange(10), + label='#1', + c=np.arange(10), + edgecolor=edgecolors, + cmap="Reds" + ) + + leg = ax.legend(labelcolor='markeredgecolor') + for text, color in zip(leg.get_texts(), ['k']): + assert mpl.colors.same_color(text.get_color(), color) + + def test_legend_labelcolor_markerfacecolor(): # test the labelcolor for labelcolor='markerfacecolor' fig, ax = plt.subplots() @@ -645,6 +902,48 @@ def test_legend_labelcolor_markerfacecolor(): assert mpl.colors.same_color(text.get_color(), color) +def test_legend_pathcollection_labelcolor_markerfacecolor(): + # test the labelcolor for labelcolor='markerfacecolor' on PathCollection + fig, ax = plt.subplots() + ax.scatter(np.arange(10), np.arange(10)*1, label='#1', facecolor='r') + ax.scatter(np.arange(10), np.arange(10)*2, label='#2', facecolor='g') + ax.scatter(np.arange(10), np.arange(10)*3, label='#3', facecolor='b') + + leg = ax.legend(labelcolor='markerfacecolor') + for text, color in zip(leg.get_texts(), ['r', 'g', 'b']): + assert mpl.colors.same_color(text.get_color(), color) + + +def test_legend_pathcollection_labelcolor_markerfacecolor_iterable(): + # test the labelcolor for labelcolor='markerfacecolor' on PathCollection + # with iterable colors + fig, ax = plt.subplots() + colors = np.random.default_rng().choice(['r', 'g', 'b'], 10) + ax.scatter(np.arange(10), np.arange(10)*1, label='#1', facecolor=colors) + + leg = ax.legend(labelcolor='markerfacecolor') + for text, color in zip(leg.get_texts(), ['k']): + assert mpl.colors.same_color(text.get_color(), color) + + +def test_legend_pathcollection_labelcolor_markfacecolor_cmap(): + # test the labelcolor for labelcolor='markerfacecolor' on PathCollection + # with colormaps + fig, ax = plt.subplots() + facecolors = mpl.cm.viridis(np.random.rand(10)) + ax.scatter( + np.arange(10), + np.arange(10), + label='#1', + c=np.arange(10), + facecolor=facecolors + ) + + leg = ax.legend(labelcolor='markerfacecolor') + for text, color in zip(leg.get_texts(), ['k']): + assert mpl.colors.same_color(text.get_color(), color) + + @pytest.mark.parametrize('color', ('red', 'none', (.5, .5, .5))) def test_legend_labelcolor_rcparam_single(color): # test the rcParams legend.labelcolor for a single color @@ -733,18 +1032,24 @@ def test_get_set_draggable(): assert not legend.get_draggable() +@pytest.mark.parametrize('draggable', (True, False)) +def test_legend_draggable(draggable): + fig, ax = plt.subplots() + ax.plot(range(10), label='shabnams') + leg = ax.legend(draggable=draggable) + assert leg.get_draggable() is draggable + + def test_alpha_handles(): x, n, hh = plt.hist([1, 2, 3], alpha=0.25, label='data', color='red') legend = plt.legend() - for lh in legend.legendHandles: + for lh in legend.legend_handles: lh.set_alpha(1.0) assert lh.get_facecolor()[:-1] == hh[1].get_facecolor()[:-1] assert lh.get_edgecolor()[:-1] == hh[1].get_edgecolor()[:-1] -@pytest.mark.skipif( - not mpl.checkdep_usetex(True), - reason="This test needs a TeX installation") +@needs_usetex def test_usetex_no_warn(caplog): mpl.rcParams['font.family'] = 'serif' mpl.rcParams['font.serif'] = 'Computer Modern' @@ -835,7 +1140,7 @@ def test_plot_single_input_multiple_label(label_array): def test_plot_multiple_label_incorrect_length_exception(): - # check that excepton is raised if multiple labels + # check that exception is raised if multiple labels # are given, but number of on labels != number of lines with pytest.raises(ValueError): x = [1, 2, 3] @@ -870,7 +1175,7 @@ def test_handlerline2d(): ax.scatter([0, 1], [0, 1], marker="v") handles = [mlines.Line2D([0], [0], marker="v")] leg = ax.legend(handles, ["Aardvark"], numpoints=1) - assert handles[0].get_marker() == leg.legendHandles[0].get_marker() + assert handles[0].get_marker() == leg.legend_handles[0].get_marker() def test_subfigure_legend(): @@ -888,3 +1193,29 @@ def test_setting_alpha_keeps_polycollection_color(): patch.set_alpha(0.5) assert patch.get_facecolor()[:3] == tuple(pc.get_facecolor()[0][:3]) assert patch.get_edgecolor()[:3] == tuple(pc.get_edgecolor()[0][:3]) + + +def test_legend_markers_from_line2d(): + # Test that markers can be copied for legend lines (#17960) + _markers = ['.', '*', 'v'] + fig, ax = plt.subplots() + lines = [mlines.Line2D([0], [0], ls='None', marker=mark) + for mark in _markers] + labels = ["foo", "bar", "xyzzy"] + markers = [line.get_marker() for line in lines] + legend = ax.legend(lines, labels) + + new_markers = [line.get_marker() for line in legend.get_lines()] + new_labels = [text.get_text() for text in legend.get_texts()] + + assert markers == new_markers == _markers + assert labels == new_labels + + +@check_figures_equal() +def test_ncol_ncols(fig_test, fig_ref): + # Test that both ncol and ncols work + strings = ["a", "b", "c", "d", "e", "f"] + ncols = 3 + fig_test.legend(strings, ncol=ncols) + fig_ref.legend(strings, ncols=ncols) diff --git a/lib/matplotlib/tests/test_lines.py b/lib/matplotlib/tests/test_lines.py index f6917a134bb4..7eecf5675a4b 100644 --- a/lib/matplotlib/tests/test_lines.py +++ b/lib/matplotlib/tests/test_lines.py @@ -3,6 +3,7 @@ """ import itertools +import platform import timeit from types import SimpleNamespace @@ -12,12 +13,14 @@ import pytest import matplotlib +import matplotlib as mpl import matplotlib.lines as mlines from matplotlib.markers import MarkerStyle from matplotlib.path import Path import matplotlib.pyplot as plt import matplotlib.transforms as mtransforms from matplotlib.testing.decorators import image_comparison, check_figures_equal +from matplotlib._api.deprecation import MatplotlibDeprecationWarning def test_segment_hits(): @@ -82,6 +85,22 @@ def test_set_line_coll_dash(): ax.contour(np.random.randn(20, 30), linestyles=[(0, (3, 3))]) +def test_invalid_line_data(): + with pytest.raises(RuntimeError, match='xdata must be'): + mlines.Line2D(0, []) + with pytest.raises(RuntimeError, match='ydata must be'): + mlines.Line2D([], 1) + + line = mlines.Line2D([], []) + # when deprecation cycle is completed + # with pytest.raises(RuntimeError, match='x must be'): + with pytest.warns(MatplotlibDeprecationWarning): + line.set_xdata(0) + # with pytest.raises(RuntimeError, match='y must be'): + with pytest.warns(MatplotlibDeprecationWarning): + line.set_ydata(0) + + @image_comparison(['line_dashes'], remove_text=True) def test_line_dashes(): fig, ax = plt.subplots() @@ -108,7 +127,9 @@ def test_valid_colors(): def test_linestyle_variants(): fig, ax = plt.subplots() for ls in ["-", "solid", "--", "dashed", - "-.", "dashdot", ":", "dotted"]: + "-.", "dashdot", ":", "dotted", + (0, None), (0, ()), (0, []), # gh-22930 + ]: ax.plot(range(10), linestyle=ls) fig.canvas.draw() @@ -162,7 +183,9 @@ def test_set_drawstyle(): assert len(line.get_path().vertices) == len(x) -@image_comparison(['line_collection_dashes'], remove_text=True, style='mpl20') +@image_comparison( + ['line_collection_dashes'], remove_text=True, style='mpl20', + tol=0.62 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_set_line_coll_dash_image(): fig, ax = plt.subplots() np.random.seed(0) @@ -179,7 +202,11 @@ def test_marker_fill_styles(): x = np.array([0, 9]) fig, ax = plt.subplots() - for j, marker in enumerate(mlines.Line2D.filled_markers): + # This hard-coded list of markers correspond to an earlier iteration of + # MarkerStyle.filled_markers; the value of that attribute has changed but + # we kept the old value here to not regenerate the baseline image. + # Replace with mlines.Line2D.filled_markers when the image is regenerated. + for j, marker in enumerate("ov^<>8sp*hHDdPX"): for i, fs in enumerate(mlines.Line2D.fillStyles): color = next(colors) ax.plot(j * 10 + x, y + i + .5 * (j % 2), @@ -294,7 +321,7 @@ def test_marker_as_markerstyle(): line.set_marker(MarkerStyle("o")) fig.canvas.draw() # test Path roundtrip - triangle1 = Path([[-1., -1.], [1., -1.], [0., 2.], [0., 0.]], closed=True) + triangle1 = Path._create_closed([[-1, -1], [1, -1], [0, 2]]) line2, = ax.plot([1, 3, 2], marker=MarkerStyle(triangle1), ms=22) line3, = ax.plot([0, 2, 1], marker=triangle1, ms=22) @@ -302,6 +329,17 @@ def test_marker_as_markerstyle(): assert_array_equal(line3.get_marker().vertices, triangle1.vertices) +@image_comparison(['striped_line.png'], remove_text=True, style='mpl20') +def test_striped_lines(): + rng = np.random.default_rng(19680801) + _, ax = plt.subplots() + ax.plot(rng.uniform(size=12), color='orange', gapcolor='blue', + linestyle='--', lw=5, label=' ') + ax.plot(rng.uniform(size=12), color='red', gapcolor='black', + linestyle=(0, (2, 5, 4, 2)), lw=5, label=' ', alpha=0.5) + ax.legend(handlelength=5) + + @check_figures_equal() def test_odd_dashes(fig_test, fig_ref): fig_test.add_subplot().plot([1, 2], dashes=[1, 2, 3]) @@ -332,3 +370,39 @@ def test_picking(): found, indices = l2.contains(mouse_event) assert found assert_array_equal(indices['ind'], [0]) + + +@check_figures_equal() +def test_input_copy(fig_test, fig_ref): + + t = np.arange(0, 6, 2) + l, = fig_test.add_subplot().plot(t, t, ".-") + t[:] = range(3) + # Trigger cache invalidation + l.set_drawstyle("steps") + fig_ref.add_subplot().plot([0, 2, 4], [0, 2, 4], ".-", drawstyle="steps") + + +@check_figures_equal(extensions=["png"]) +def test_markevery_prop_cycle(fig_test, fig_ref): + """Test that we can set markevery prop_cycle.""" + cases = [None, 8, (30, 8), [16, 24, 30], [0, -1], + slice(100, 200, 3), 0.1, 0.3, 1.5, + (0.0, 0.1), (0.45, 0.1)] + + cmap = mpl.colormaps['jet'] + colors = cmap(np.linspace(0.2, 0.8, len(cases))) + + x = np.linspace(-1, 1) + y = 5 * x**2 + + axs = fig_ref.add_subplot() + for i, markevery in enumerate(cases): + axs.plot(y - i, 'o-', markevery=markevery, color=colors[i]) + + matplotlib.rcParams['axes.prop_cycle'] = cycler(markevery=cases, + color=colors) + + ax = fig_test.add_subplot() + for i, _ in enumerate(cases): + ax.plot(y - i, 'o-') diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index f85d4ff467ef..f50f45bbd4ee 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -1,8 +1,10 @@ import numpy as np import matplotlib.pyplot as plt from matplotlib import markers +from matplotlib._api.deprecation import MatplotlibDeprecationWarning from matplotlib.path import Path from matplotlib.testing.decorators import check_figures_equal +from matplotlib.transforms import Affine2D import pytest @@ -18,7 +20,6 @@ def test_marker_fillstyle(): 'x', '', 'None', - None, r'$\frac{1}{2}$', "$\u266B$", 1, @@ -32,7 +33,6 @@ def test_marker_fillstyle(): (5, 0, 10), # a pentagon, rotated by 10 degrees (7, 1, 10), # a 7-pointed star, rotated by 10 degrees (5, 2, 10), # asterisk, rotated by 10 degrees - markers.MarkerStyle(), markers.MarkerStyle('o'), ]) def test_markers_valid(marker): @@ -40,6 +40,15 @@ def test_markers_valid(marker): markers.MarkerStyle(marker) +def test_deprecated_marker(): + with pytest.warns(MatplotlibDeprecationWarning): + ms = markers.MarkerStyle() + markers.MarkerStyle(ms) # No warning on copy. + with pytest.warns(MatplotlibDeprecationWarning): + ms = markers.MarkerStyle(None) + markers.MarkerStyle(ms) # No warning on copy. + + @pytest.mark.parametrize('marker', [ 'square', # arbitrary string np.array([[-0.5, 0, 1, 2, 3]]), # 1D array @@ -196,3 +205,99 @@ def test_marker_clipping(fig_ref, fig_test): ax_test.set(xlim=(-0.5, ncol), ylim=(-0.5, 2 * nrow)) ax_ref.axis('off') ax_test.axis('off') + + +def test_marker_init_transforms(): + """Test that initializing marker with transform is a simple addition.""" + marker = markers.MarkerStyle("o") + t = Affine2D().translate(1, 1) + t_marker = markers.MarkerStyle("o", transform=t) + assert marker.get_transform() + t == t_marker.get_transform() + + +def test_marker_init_joinstyle(): + marker = markers.MarkerStyle("*") + jstl = markers.JoinStyle.round + styled_marker = markers.MarkerStyle("*", joinstyle=jstl) + assert styled_marker.get_joinstyle() == jstl + assert marker.get_joinstyle() != jstl + + +def test_marker_init_captyle(): + marker = markers.MarkerStyle("*") + capstl = markers.CapStyle.round + styled_marker = markers.MarkerStyle("*", capstyle=capstl) + assert styled_marker.get_capstyle() == capstl + assert marker.get_capstyle() != capstl + + +@pytest.mark.parametrize("marker,transform,expected", [ + (markers.MarkerStyle("o"), Affine2D().translate(1, 1), + Affine2D().translate(1, 1)), + (markers.MarkerStyle("o", transform=Affine2D().translate(1, 1)), + Affine2D().translate(1, 1), Affine2D().translate(2, 2)), + (markers.MarkerStyle("$|||$", transform=Affine2D().translate(1, 1)), + Affine2D().translate(1, 1), Affine2D().translate(2, 2)), + (markers.MarkerStyle( + markers.TICKLEFT, transform=Affine2D().translate(1, 1)), + Affine2D().translate(1, 1), Affine2D().translate(2, 2)), +]) +def test_marker_transformed(marker, transform, expected): + new_marker = marker.transformed(transform) + assert new_marker is not marker + assert new_marker.get_user_transform() == expected + assert marker._user_transform is not new_marker._user_transform + + +def test_marker_rotated_invalid(): + marker = markers.MarkerStyle("o") + with pytest.raises(ValueError): + new_marker = marker.rotated() + with pytest.raises(ValueError): + new_marker = marker.rotated(deg=10, rad=10) + + +@pytest.mark.parametrize("marker,deg,rad,expected", [ + (markers.MarkerStyle("o"), 10, None, Affine2D().rotate_deg(10)), + (markers.MarkerStyle("o"), None, 0.01, Affine2D().rotate(0.01)), + (markers.MarkerStyle("o", transform=Affine2D().translate(1, 1)), + 10, None, Affine2D().translate(1, 1).rotate_deg(10)), + (markers.MarkerStyle("o", transform=Affine2D().translate(1, 1)), + None, 0.01, Affine2D().translate(1, 1).rotate(0.01)), + (markers.MarkerStyle("$|||$", transform=Affine2D().translate(1, 1)), + 10, None, Affine2D().translate(1, 1).rotate_deg(10)), + (markers.MarkerStyle( + markers.TICKLEFT, transform=Affine2D().translate(1, 1)), + 10, None, Affine2D().translate(1, 1).rotate_deg(10)), +]) +def test_marker_rotated(marker, deg, rad, expected): + new_marker = marker.rotated(deg=deg, rad=rad) + assert new_marker is not marker + assert new_marker.get_user_transform() == expected + assert marker._user_transform is not new_marker._user_transform + + +def test_marker_scaled(): + marker = markers.MarkerStyle("1") + new_marker = marker.scaled(2) + assert new_marker is not marker + assert new_marker.get_user_transform() == Affine2D().scale(2) + assert marker._user_transform is not new_marker._user_transform + + new_marker = marker.scaled(2, 3) + assert new_marker is not marker + assert new_marker.get_user_transform() == Affine2D().scale(2, 3) + assert marker._user_transform is not new_marker._user_transform + + marker = markers.MarkerStyle("1", transform=Affine2D().translate(1, 1)) + new_marker = marker.scaled(2) + assert new_marker is not marker + expected = Affine2D().translate(1, 1).scale(2) + assert new_marker.get_user_transform() == expected + assert marker._user_transform is not new_marker._user_transform + + +def test_alt_transform(): + m1 = markers.MarkerStyle("o", "left") + m2 = markers.MarkerStyle("o", "left", Affine2D().rotate_deg(90)) + assert m1.get_alt_transform().rotate_deg(90) == m2.get_alt_transform() diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index 80d989922c22..2ee3e914d5f6 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -1,5 +1,6 @@ import io from pathlib import Path +import platform import re import shlex from xml.etree import ElementTree as ET @@ -10,13 +11,13 @@ import matplotlib as mpl from matplotlib.testing.decorators import check_figures_equal, image_comparison import matplotlib.pyplot as plt -from matplotlib import _api, mathtext +from matplotlib import mathtext, _mathtext # If test is removed, use None as placeholder math_tests = [ r'$a+b+\dot s+\dot{s}+\ldots$', - r'$x \doteq y$', + r'$x\hspace{-0.2}\doteq\hspace{-0.2}y$', r'\$100.00 $\alpha \_$', r'$\frac{\$100.00}{y}$', r'$x y$', @@ -47,7 +48,7 @@ r"$\arccos((x^i))$", r"$\gamma = \frac{x=\frac{6}{8}}{y} \delta$", r'$\limsup_{x\to\infty}$', - r'$\oint^\infty_0$', + None, r"$f'\quad f'''(x)\quad ''/\mathrm{yr}$", r'$\frac{x_2888}{y}$', r"$\sqrt[3]{\frac{X_2}{Y}}=5$", @@ -60,8 +61,8 @@ '$\\Gamma \\Delta \\Theta \\Lambda \\Xi \\Pi \\Sigma \\Upsilon \\Phi \\Psi \\Omega$', '$\\alpha \\beta \\gamma \\delta \\epsilon \\zeta \\eta \\theta \\iota \\lambda \\mu \\nu \\xi \\pi \\kappa \\rho \\sigma \\tau \\upsilon \\phi \\chi \\psi$', - # The examples prefixed by 'mmltt' are from the MathML torture test here: - # https://developer.mozilla.org/en-US/docs/Mozilla/MathML_Project/MathML_Torture_Test + # The following examples are from the MathML torture test here: + # https://www-archive.mozilla.org/projects/mathml/demo/texvsmml.xhtml r'${x}^{2}{y}^{2}$', r'${}_{2}F_{3}$', r'$\frac{x+{y}^{2}}{k+1}$', @@ -100,28 +101,35 @@ r"$? ! &$", # github issue #466 None, None, - r"$\left\Vert a \right\Vert \left\vert b \right\vert \left| a \right| \left\| b\right\| \Vert a \Vert \vert b \vert$", + r"$\left\Vert \frac{a}{b} \right\Vert \left\vert \frac{a}{b} \right\vert \left\| \frac{a}{b}\right\| \left| \frac{a}{b} \right| \Vert a \Vert \vert b \vert \| a \| | b |$", r'$\mathring{A} \AA$', r'$M \, M \thinspace M \/ M \> M \: M \; M \ M \enspace M \quad M \qquad M \! M$', r'$\Cap$ $\Cup$ $\leftharpoonup$ $\barwedge$ $\rightharpoonup$', - r'$\dotplus$ $\doteq$ $\doteqdot$ $\ddots$', + r'$\hspace{-0.2}\dotplus\hspace{-0.2}$ $\hspace{-0.2}\doteq\hspace{-0.2}$ $\hspace{-0.2}\doteqdot\hspace{-0.2}$ $\ddots$', r'$xyz^kx_kx^py^{p-2} d_i^jb_jc_kd x^j_i E^0 E^0_u$', # github issue #4873 r'${xyz}^k{x}_{k}{x}^{p}{y}^{p-2} {d}_{i}^{j}{b}_{j}{c}_{k}{d} {x}^{j}_{i}{E}^{0}{E}^0_u$', r'${\int}_x^x x\oint_x^x x\int_{X}^{X}x\int_x x \int^x x \int_{x} x\int^{x}{\int}_{x} x{\int}^{x}_{x}x$', r'testing$^{123}$', - ' '.join('$\\' + p + '$' for p in sorted(mathtext.Parser._accentprefixed)), + None, r'$6-2$; $-2$; $ -2$; ${-2}$; ${ -2}$; $20^{+3}_{-2}$', r'$\overline{\omega}^x \frac{1}{2}_0^x$', # github issue #5444 r'$,$ $.$ $1{,}234{, }567{ , }890$ and $1,234,567,890$', # github issue 5799 r'$\left(X\right)_{a}^{b}$', # github issue 7615 r'$\dfrac{\$100.00}{y}$', # github issue #1888 ] -# 'Lightweight' tests test only a single fontset (dejavusans, which is the +# 'svgastext' tests switch svg output to embed text as text (rather than as +# paths). +svgastext_math_tests = [ + r'$-$-', +] +# 'lightweight' tests test only a single fontset (dejavusans, which is the # default) and only png outputs, in order to minimize the size of baseline # images. lightweight_math_tests = [ r'$\sqrt[ab]{123}$', # github issue #8665 r'$x \overset{f}{\rightarrow} \overset{f}{x} \underset{xx}{ff} \overset{xx}{ff} \underset{f}{x} \underset{f}{\leftarrow} x$', # github issue #18241 + r'$\sum x\quad\sum^nx\quad\sum_nx\quad\sum_n^nx\quad\prod x\quad\prod^nx\quad\prod_nx\quad\prod_n^nx$', # GitHub issue 18085 + r'$1.$ $2.$ $19680801.$ $a.$ $b.$ $mpl.$', ] digits = "0123456789" @@ -191,7 +199,8 @@ def baseline_images(request, fontset, index, text): @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathtext'], indirect=True) -@image_comparison(baseline_images=None) +@image_comparison(baseline_images=None, + tol=0.011 if platform.machine() in ('ppc64le', 's390x') else 0) def test_mathtext_rendering(baseline_images, fontset, index, text): mpl.rcParams['mathtext.fontset'] = fontset fig = plt.figure(figsize=(5.25, 0.75)) @@ -199,6 +208,23 @@ def test_mathtext_rendering(baseline_images, fontset, index, text): horizontalalignment='center', verticalalignment='center') +@pytest.mark.parametrize('index, text', enumerate(svgastext_math_tests), + ids=range(len(svgastext_math_tests))) +@pytest.mark.parametrize('fontset', ['cm', 'dejavusans']) +@pytest.mark.parametrize('baseline_images', ['mathtext0'], indirect=True) +@image_comparison( + baseline_images=None, extensions=['svg'], + savefig_kwarg={'metadata': { # Minimize image size. + 'Creator': None, 'Date': None, 'Format': None, 'Type': None}}) +def test_mathtext_rendering_svgastext(baseline_images, fontset, index, text): + mpl.rcParams['mathtext.fontset'] = fontset + mpl.rcParams['svg.fonttype'] = 'none' # Minimize image size. + fig = plt.figure(figsize=(5.25, 0.75)) + fig.patch.set(visible=False) # Minimize image size. + fig.text(0.5, 0.5, text, + horizontalalignment='center', verticalalignment='center') + + @pytest.mark.parametrize('index, text', enumerate(lightweight_math_tests), ids=range(len(lightweight_math_tests))) @pytest.mark.parametrize('fontset', ['dejavusans']) @@ -215,7 +241,8 @@ def test_mathtext_rendering_lightweight(baseline_images, fontset, index, text): @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathfont'], indirect=True) -@image_comparison(baseline_images=None, extensions=['png']) +@image_comparison(baseline_images=None, extensions=['png'], + tol=0.011 if platform.machine() in ('ppc64le', 's390x') else 0) def test_mathfont_rendering(baseline_images, fontset, index, text): mpl.rcParams['mathtext.fontset'] = fontset fig = plt.figure(figsize=(5.25, 0.75)) @@ -223,6 +250,19 @@ def test_mathfont_rendering(baseline_images, fontset, index, text): horizontalalignment='center', verticalalignment='center') +@check_figures_equal(extensions=["png"]) +def test_short_long_accents(fig_test, fig_ref): + acc_map = _mathtext.Parser._accent_map + short_accs = [s for s in acc_map if len(s) == 1] + corresponding_long_accs = [] + for s in short_accs: + l, = [l for l in acc_map if len(l) > 1 and acc_map[l] == acc_map[s]] + corresponding_long_accs.append(l) + fig_test.text(0, .5, "$" + "".join(rf"\{s}a" for s in short_accs) + "$") + fig_ref.text( + 0, .5, "$" + "".join(fr"\{l} a" for l in corresponding_long_accs) + "$") + + def test_fontinfo(): fontpath = mpl.font_manager.findfont("DejaVu Sans") font = mpl.ft2font.FT2Font(fontpath) @@ -233,8 +273,10 @@ def test_fontinfo(): @pytest.mark.parametrize( 'math, msg', [ - (r'$\hspace{}$', r'Expected \hspace{n}'), - (r'$\hspace{foo}$', r'Expected \hspace{n}'), + (r'$\hspace{}$', r'Expected \hspace{space}'), + (r'$\hspace{foo}$', r'Expected \hspace{space}'), + (r'$\sinx$', r'Unknown symbol: \sinx'), + (r'$\dotx$', r'Unknown symbol: \dotx'), (r'$\frac$', r'Expected \frac{num}{den}'), (r'$\frac{}{}$', r'Expected \frac{num}{den}'), (r'$\binom$', r'Expected \binom{num}{den}'), @@ -245,8 +287,8 @@ def test_fontinfo(): r'Expected \genfrac{ldelim}{rdelim}{rulesize}{style}{num}{den}'), (r'$\sqrt$', r'Expected \sqrt{value}'), (r'$\sqrt f$', r'Expected \sqrt{value}'), - (r'$\overline$', r'Expected \overline{value}'), - (r'$\overline{}$', r'Expected \overline{value}'), + (r'$\overline$', r'Expected \overline{body}'), + (r'$\overline{}$', r'Expected \overline{body}'), (r'$\leftF$', r'Expected a delimiter'), (r'$\rightF$', r'Unknown symbol: \rightF'), (r'$\left(\right$', r'Expected a delimiter'), @@ -255,12 +297,18 @@ def test_fontinfo(): (r'$\left($', re.compile(r'Expected ("|\'\\)\\right["\']')), (r'$\dfrac$', r'Expected \dfrac{num}{den}'), (r'$\dfrac{}{}$', r'Expected \dfrac{num}{den}'), - (r'$\overset$', r'Expected \overset{body}{annotation}'), - (r'$\underset$', r'Expected \underset{body}{annotation}'), + (r'$\overset$', r'Expected \overset{annotation}{body}'), + (r'$\underset$', r'Expected \underset{annotation}{body}'), + (r'$\foo$', r'Unknown symbol: \foo'), + (r'$a^2^2$', r'Double superscript'), + (r'$a_2_2$', r'Double subscript'), + (r'$a^2_a^2$', r'Double superscript'), ], ids=[ 'hspace without value', 'hspace with invalid value', + 'function without space', + 'accent without space', 'frac without parameters', 'frac with empty parameters', 'binom without parameters', @@ -279,6 +327,10 @@ def test_fontinfo(): 'dfrac with empty parameters', 'overset without parameters', 'underset without parameters', + 'unknown symbol', + 'double superscript', + 'double subscript', + 'super on sub without braces' ] ) def test_mathtext_exceptions(math, msg): @@ -288,25 +340,23 @@ def test_mathtext_exceptions(math, msg): parser.parse(math) -def test_single_minus_sign(): - plt.figure(figsize=(0.3, 0.3)) - plt.text(0.5, 0.5, '$-$') - plt.gca().spines[:].set_visible(False) - plt.gca().set_xticks([]) - plt.gca().set_yticks([]) +def test_get_unicode_index_exception(): + with pytest.raises(ValueError): + _mathtext.get_unicode_index(r'\foo') - buff = io.BytesIO() - plt.savefig(buff, format="rgba", dpi=1000) - array = np.frombuffer(buff.getvalue(), dtype=np.uint8) - # If this fails, it would be all white - assert not np.all(array == 0xff) +def test_single_minus_sign(): + fig = plt.figure() + fig.text(0.5, 0.5, '$-$') + fig.canvas.draw() + t = np.asarray(fig.canvas.renderer.buffer_rgba()) + assert (t != 0xff).any() # assert that canvas is not all white. @check_figures_equal(extensions=["png"]) def test_spaces(fig_test, fig_ref): - fig_test.subplots().set_title(r"$1\,2\>3\ 4$") - fig_ref.subplots().set_title(r"$1\/2\:3~4$") + fig_test.text(.5, .5, r"$1\,2\>3\ 4$") + fig_ref.text(.5, .5, r"$1\/2\:3~4$") @check_figures_equal(extensions=["png"]) @@ -319,6 +369,7 @@ def test_operator_space(fig_test, fig_ref): fig_test.text(0.1, 0.6, r"$\operatorname{op}[6]$") fig_test.text(0.1, 0.7, r"$\cos^2$") fig_test.text(0.1, 0.8, r"$\log_2$") + fig_test.text(0.1, 0.9, r"$\sin^2 \cos$") # GitHub issue #17852 fig_ref.text(0.1, 0.1, r"$\mathrm{log\,}6$") fig_ref.text(0.1, 0.2, r"$\mathrm{log}(6)$") @@ -328,6 +379,23 @@ def test_operator_space(fig_test, fig_ref): fig_ref.text(0.1, 0.6, r"$\mathrm{op}[6]$") fig_ref.text(0.1, 0.7, r"$\mathrm{cos}^2$") fig_ref.text(0.1, 0.8, r"$\mathrm{log}_2$") + fig_ref.text(0.1, 0.9, r"$\mathrm{sin}^2 \mathrm{\,cos}$") + + +@check_figures_equal(extensions=["png"]) +def test_inverted_delimiters(fig_test, fig_ref): + fig_test.text(.5, .5, r"$\left)\right($", math_fontfamily="dejavusans") + fig_ref.text(.5, .5, r"$)($", math_fontfamily="dejavusans") + + +@check_figures_equal(extensions=["png"]) +def test_genfrac_displaystyle(fig_test, fig_ref): + fig_test.text(0.1, 0.1, r"$\dfrac{2x}{3y}$") + + thickness = _mathtext.TruetypeFonts.get_underline_thickness( + None, None, fontsize=mpl.rcParams["font.size"], + dpi=mpl.rcParams["savefig.dpi"]) + fig_ref.text(0.1, 0.1, r"$\genfrac{}{}{%f}{0}{2x}{3y}$" % thickness) def test_mathtext_fallback_valid(): @@ -373,13 +441,7 @@ def test_mathtext_fallback(fallback, fontlist): def test_math_to_image(tmpdir): mathtext.math_to_image('$x^2$', str(tmpdir.join('example.png'))) mathtext.math_to_image('$x^2$', io.BytesIO()) - - -def test_mathtext_to_png(tmpdir): - with _api.suppress_matplotlib_deprecation_warning(): - mt = mathtext.MathTextParser('bitmap') - mt.to_png(str(tmpdir.join('example.png')), '$x^2$') - mt.to_png(io.BytesIO(), '$x^2$') + mathtext.math_to_image('$x^2$', io.BytesIO(), color='Maroon') @image_comparison(baseline_images=['math_fontfamily_image.png'], diff --git a/lib/matplotlib/tests/test_mlab.py b/lib/matplotlib/tests/test_mlab.py index b71ce44454ac..9cd1b44cc1e2 100644 --- a/lib/matplotlib/tests/test_mlab.py +++ b/lib/matplotlib/tests/test_mlab.py @@ -3,7 +3,7 @@ import numpy as np import pytest -import matplotlib.mlab as mlab +from matplotlib import mlab, _api class TestStride: @@ -13,6 +13,11 @@ def get_base(self, x): y = y.base return y + @pytest.fixture(autouse=True) + def stride_is_deprecated(self): + with _api.suppress_matplotlib_deprecation_warning(): + yield + def calc_window_target(self, x, NFFT, noverlap=0, axis=0): """ This is an adaptation of the original window extraction algorithm. @@ -92,7 +97,7 @@ def test_window(): class TestDetrend: - def setup(self): + def setup_method(self): np.random.seed(0) n = 1000 x = np.linspace(0., 100, n) @@ -610,7 +615,7 @@ def test_psd_window_hanning(self): noverlap=0, sides=self.sides, window=mlab.window_none) - spec_c *= len(ycontrol1)/(np.abs(windowVals)**2).sum() + spec_c *= len(ycontrol1)/(windowVals**2).sum() assert_array_equal(fsp_g, fsp_c) assert_array_equal(fsp_b, fsp_c) assert_allclose(spec_g, spec_c, atol=1e-08) @@ -657,7 +662,7 @@ def test_psd_window_hanning_detrend_linear(self): noverlap=0, sides=self.sides, window=mlab.window_none) - spec_c *= len(ycontrol1)/(np.abs(windowVals)**2).sum() + spec_c *= len(ycontrol1)/(windowVals**2).sum() assert_array_equal(fsp_g, fsp_c) assert_array_equal(fsp_b, fsp_c) assert_allclose(spec_g, spec_c, atol=1e-08) @@ -665,6 +670,33 @@ def test_psd_window_hanning_detrend_linear(self): with pytest.raises(AssertionError): assert_allclose(spec_b, spec_c, atol=1e-08) + def test_psd_window_flattop(self): + # flattop window + # adaption from https://github.com/scipy/scipy/blob\ + # /v1.10.0/scipy/signal/windows/_windows.py#L562-L622 + a = [0.21557895, 0.41663158, 0.277263158, 0.083578947, 0.006947368] + fac = np.linspace(-np.pi, np.pi, self.NFFT_density_real) + win = np.zeros(self.NFFT_density_real) + for k in range(len(a)): + win += a[k] * np.cos(k * fac) + + spec, fsp = mlab.psd(x=self.y, + NFFT=self.NFFT_density, + Fs=self.Fs, + noverlap=0, + sides=self.sides, + window=win, + scale_by_freq=False) + spec_a, fsp_a = mlab.psd(x=self.y, + NFFT=self.NFFT_density, + Fs=self.Fs, + noverlap=0, + sides=self.sides, + window=win) + assert_allclose(spec*win.sum()**2, + spec_a*self.Fs*(win**2).sum(), + atol=1e-08) + def test_psd_windowarray(self): freqs = self.freqs_density spec, fsp = mlab.psd(x=self.y, diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py index 1545bb881cf6..a0116d5dfcd9 100644 --- a/lib/matplotlib/tests/test_offsetbox.py +++ b/lib/matplotlib/tests/test_offsetbox.py @@ -9,11 +9,11 @@ import matplotlib.pyplot as plt import matplotlib.patches as mpatches import matplotlib.lines as mlines -from matplotlib.backend_bases import MouseButton +from matplotlib.backend_bases import MouseButton, MouseEvent from matplotlib.offsetbox import ( AnchoredOffsetbox, AnnotationBbox, AnchoredText, DrawingArea, OffsetBox, - OffsetImage, TextArea, _get_packed_offsets) + OffsetImage, PaddedBox, TextArea, _get_packed_offsets, HPacker, VPacker) @image_comparison(['offsetbox_clipping'], remove_text=True) @@ -117,76 +117,74 @@ def test_expand_with_tight_layout(): d2 = [2, 1] ax.plot(d1, label='series 1') ax.plot(d2, label='series 2') - ax.legend(ncol=2, mode='expand') + ax.legend(ncols=2, mode='expand') fig.tight_layout() # where the crash used to happen -@pytest.mark.parametrize('wd_list', - ([(150, 1)], [(150, 1)]*3, [(0.1, 1)], [(0.1, 1)]*2)) +@pytest.mark.parametrize('widths', + ([150], [150, 150, 150], [0.1], [0.1, 0.1])) @pytest.mark.parametrize('total', (250, 100, 0, -1, None)) @pytest.mark.parametrize('sep', (250, 1, 0, -1)) @pytest.mark.parametrize('mode', ("expand", "fixed", "equal")) -def test_get_packed_offsets(wd_list, total, sep, mode): +def test_get_packed_offsets(widths, total, sep, mode): # Check a (rather arbitrary) set of parameters due to successive similar # issue tickets (at least #10476 and #10784) related to corner cases # triggered inside this function when calling higher-level functions # (e.g. `Axes.legend`). # These are just some additional smoke tests. The output is untested. - _get_packed_offsets(wd_list, total, sep, mode=mode) + _get_packed_offsets(widths, total, sep, mode=mode) _Params = namedtuple('_params', 'wd_list, total, sep, expected') -@pytest.mark.parametrize('wd_list, total, sep, expected', [ +@pytest.mark.parametrize('widths, total, sep, expected', [ _Params( # total=None - [(3, 0), (1, 0), (2, 0)], total=None, sep=1, expected=(8, [0, 4, 6])), + [3, 1, 2], total=None, sep=1, expected=(8, [0, 4, 6])), _Params( # total larger than required - [(3, 0), (1, 0), (2, 0)], total=10, sep=1, expected=(10, [0, 4, 6])), + [3, 1, 2], total=10, sep=1, expected=(10, [0, 4, 6])), _Params( # total smaller than required - [(3, 0), (1, 0), (2, 0)], total=5, sep=1, expected=(5, [0, 4, 6])), + [3, 1, 2], total=5, sep=1, expected=(5, [0, 4, 6])), ]) -def test_get_packed_offsets_fixed(wd_list, total, sep, expected): - result = _get_packed_offsets(wd_list, total, sep, mode='fixed') +def test_get_packed_offsets_fixed(widths, total, sep, expected): + result = _get_packed_offsets(widths, total, sep, mode='fixed') assert result[0] == expected[0] assert_allclose(result[1], expected[1]) -@pytest.mark.parametrize('wd_list, total, sep, expected', [ +@pytest.mark.parametrize('widths, total, sep, expected', [ _Params( # total=None (implicit 1) - [(.1, 0)] * 3, total=None, sep=None, expected=(1, [0, .45, .9])), + [.1, .1, .1], total=None, sep=None, expected=(1, [0, .45, .9])), _Params( # total larger than sum of widths - [(3, 0), (1, 0), (2, 0)], total=10, sep=1, expected=(10, [0, 5, 8])), + [3, 1, 2], total=10, sep=1, expected=(10, [0, 5, 8])), _Params( # total smaller sum of widths: overlapping boxes - [(3, 0), (1, 0), (2, 0)], total=5, sep=1, expected=(5, [0, 2.5, 3])), + [3, 1, 2], total=5, sep=1, expected=(5, [0, 2.5, 3])), ]) -def test_get_packed_offsets_expand(wd_list, total, sep, expected): - result = _get_packed_offsets(wd_list, total, sep, mode='expand') +def test_get_packed_offsets_expand(widths, total, sep, expected): + result = _get_packed_offsets(widths, total, sep, mode='expand') assert result[0] == expected[0] assert_allclose(result[1], expected[1]) -@pytest.mark.parametrize('wd_list, total, sep, expected', [ +@pytest.mark.parametrize('widths, total, sep, expected', [ _Params( # total larger than required - [(3, 0), (2, 0), (1, 0)], total=6, sep=None, expected=(6, [0, 2, 4])), + [3, 2, 1], total=6, sep=None, expected=(6, [0, 2, 4])), _Params( # total smaller sum of widths: overlapping boxes - [(3, 0), (2, 0), (1, 0), (.5, 0)], total=2, sep=None, - expected=(2, [0, 0.5, 1, 1.5])), + [3, 2, 1, .5], total=2, sep=None, expected=(2, [0, 0.5, 1, 1.5])), _Params( # total larger than required - [(.5, 0), (1, 0), (.2, 0)], total=None, sep=1, - expected=(6, [0, 2, 4])), + [.5, 1, .2], total=None, sep=1, expected=(6, [0, 2, 4])), # the case total=None, sep=None is tested separately below ]) -def test_get_packed_offsets_equal(wd_list, total, sep, expected): - result = _get_packed_offsets(wd_list, total, sep, mode='equal') +def test_get_packed_offsets_equal(widths, total, sep, expected): + result = _get_packed_offsets(widths, total, sep, mode='equal') assert result[0] == expected[0] assert_allclose(result[1], expected[1]) def test_get_packed_offsets_equal_total_none_sep_none(): with pytest.raises(ValueError): - _get_packed_offsets([(1, 0)] * 3, total=None, sep=None, mode='equal') + _get_packed_offsets([1, 1, 1], total=None, sep=None, mode='equal') @pytest.mark.parametrize('child_type', ['draw', 'image', 'text']) @@ -228,7 +226,8 @@ def test_picking(child_type, boxcoords): x, y = ax.transAxes.transform_point((0.5, 0.5)) fig.canvas.draw() calls.clear() - fig.canvas.button_press_event(x, y, MouseButton.LEFT) + MouseEvent( + "button_press_event", fig.canvas, x, y, MouseButton.LEFT)._process() assert len(calls) == 1 and calls[0].artist == ab # Annotation should *not* be picked by an event at its original center @@ -237,7 +236,8 @@ def test_picking(child_type, boxcoords): ax.set_ylim(-1, 0) fig.canvas.draw() calls.clear() - fig.canvas.button_press_event(x, y, MouseButton.LEFT) + MouseEvent( + "button_press_event", fig.canvas, x, y, MouseButton.LEFT)._process() assert len(calls) == 0 @@ -325,3 +325,71 @@ def test_annotationbbox_extents(): def test_zorder(): assert OffsetBox(zorder=42).zorder == 42 + + +def test_arrowprops_copied(): + da = DrawingArea(20, 20, 0, 0, clip=True) + arrowprops = {"arrowstyle": "->", "relpos": (.3, .7)} + ab = AnnotationBbox(da, [.5, .5], xybox=(-0.2, 0.5), xycoords='data', + boxcoords="axes fraction", box_alignment=(0., .5), + arrowprops=arrowprops) + assert ab.arrowprops is not ab + assert arrowprops["relpos"] == (.3, .7) + + +@pytest.mark.parametrize("align", ["baseline", "bottom", "top", + "left", "right", "center"]) +def test_packers(align): + # set the DPI to match points to make the math easier below + fig = plt.figure(dpi=72) + renderer = fig.canvas.get_renderer() + + x1, y1 = 10, 30 + x2, y2 = 20, 60 + r1 = DrawingArea(x1, y1) + r2 = DrawingArea(x2, y2) + + # HPacker + hpacker = HPacker(children=[r1, r2], align=align) + hpacker.draw(renderer) + bbox = hpacker.get_bbox(renderer) + px, py = hpacker.get_offset(bbox, renderer) + # width, height, xdescent, ydescent + assert_allclose(bbox.bounds, (0, 0, x1 + x2, max(y1, y2))) + # internal element placement + if align in ("baseline", "left", "bottom"): + y_height = 0 + elif align in ("right", "top"): + y_height = y2 - y1 + elif align == "center": + y_height = (y2 - y1) / 2 + # x-offsets, y-offsets + assert_allclose([child.get_offset() for child in hpacker.get_children()], + [(px, py + y_height), (px + x1, py)]) + + # VPacker + vpacker = VPacker(children=[r1, r2], align=align) + vpacker.draw(renderer) + bbox = vpacker.get_bbox(renderer) + px, py = vpacker.get_offset(bbox, renderer) + # width, height, xdescent, ydescent + assert_allclose(bbox.bounds, (0, -max(y1, y2), max(x1, x2), y1 + y2)) + # internal element placement + if align in ("baseline", "left", "bottom"): + x_height = 0 + elif align in ("right", "top"): + x_height = x2 - x1 + elif align == "center": + x_height = (x2 - x1) / 2 + # x-offsets, y-offsets + assert_allclose([child.get_offset() for child in vpacker.get_children()], + [(px + x_height, py), (px, py - y2)]) + + +def test_paddedbox(): + # smoke test paddedbox for correct default value + fig, ax = plt.subplots() + at = AnchoredText("foo", 'upper left') + pb = PaddedBox(at, patch_attrs={'facecolor': 'r'}, draw_frame=True) + ax.add_artist(pb) + fig.draw_without_rendering() diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 81f1d8e018ec..45bd6b4b06fc 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -6,8 +6,8 @@ import pytest import matplotlib as mpl -from matplotlib.patches import (Annulus, Patch, Polygon, Rectangle, - FancyArrowPatch) +from matplotlib.patches import (Annulus, Ellipse, Patch, Polygon, Rectangle, + FancyArrowPatch, FancyArrow, BoxStyle, Arc) from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.transforms import Bbox import matplotlib.pyplot as plt @@ -31,6 +31,7 @@ def test_Polygon_close(): # start with open path and close it: p = Polygon(xy, closed=True) + assert p.get_closed() assert_array_equal(p.get_xy(), xyclosed) p.set_xy(xy) assert_array_equal(p.get_xy(), xyclosed) @@ -43,6 +44,7 @@ def test_Polygon_close(): # start with open path and leave it open: p = Polygon(xy, closed=False) + assert not p.get_closed() assert_array_equal(p.get_xy(), xy) p.set_xy(xy) assert_array_equal(p.get_xy(), xy) @@ -54,6 +56,54 @@ def test_Polygon_close(): assert_array_equal(p.get_xy(), xyclosed) +def test_corner_center(): + loc = [10, 20] + width = 1 + height = 2 + + # Rectangle + # No rotation + corners = ((10, 20), (11, 20), (11, 22), (10, 22)) + rect = Rectangle(loc, width, height) + assert_array_equal(rect.get_corners(), corners) + assert_array_equal(rect.get_center(), (10.5, 21)) + + # 90 deg rotation + corners_rot = ((10, 20), (10, 21), (8, 21), (8, 20)) + rect.set_angle(90) + assert_array_equal(rect.get_corners(), corners_rot) + assert_array_equal(rect.get_center(), (9, 20.5)) + + # Rotation not a multiple of 90 deg + theta = 33 + t = mtransforms.Affine2D().rotate_around(*loc, np.deg2rad(theta)) + corners_rot = t.transform(corners) + rect.set_angle(theta) + assert_almost_equal(rect.get_corners(), corners_rot) + + # Ellipse + loc = [loc[0] + width / 2, + loc[1] + height / 2] + ellipse = Ellipse(loc, width, height) + + # No rotation + assert_array_equal(ellipse.get_corners(), corners) + + # 90 deg rotation + corners_rot = ((11.5, 20.5), (11.5, 21.5), (9.5, 21.5), (9.5, 20.5)) + ellipse.set_angle(90) + assert_array_equal(ellipse.get_corners(), corners_rot) + # Rotation shouldn't change ellipse center + assert_array_equal(ellipse.get_center(), loc) + + # Rotation not a multiple of 90 deg + theta = 33 + t = mtransforms.Affine2D().rotate_around(*loc, np.deg2rad(theta)) + corners_rot = t.transform(corners) + ellipse.set_angle(theta) + assert_almost_equal(ellipse.get_corners(), corners_rot) + + def test_rotate_rect(): loc = np.asarray([1.0, 2.0]) width = 2 @@ -90,7 +140,7 @@ def test_rotate_rect_draw(fig_test, fig_ref): ax_ref.add_patch(rect_ref) assert rect_ref.get_angle() == angle - # Check that when the angle is updated after adding to an axes, that the + # Check that when the angle is updated after adding to an Axes, that the # patch is marked stale and redrawn in the correct location rect_test = Rectangle(loc, width, height) assert rect_test.get_angle() == 0 @@ -99,6 +149,40 @@ def test_rotate_rect_draw(fig_test, fig_ref): assert rect_test.get_angle() == angle +@check_figures_equal(extensions=['png']) +def test_dash_offset_patch_draw(fig_test, fig_ref): + ax_test = fig_test.add_subplot() + ax_ref = fig_ref.add_subplot() + + loc = (0.1, 0.1) + width, height = (0.8, 0.8) + rect_ref = Rectangle(loc, width, height, linewidth=3, edgecolor='b', + linestyle=(0, [6, 6])) + # fill the line gaps using a linestyle (0, [0, 6, 6, 0]), which is + # equivalent to (6, [6, 6]) but has 0 dash offset + rect_ref2 = Rectangle(loc, width, height, linewidth=3, edgecolor='r', + linestyle=(0, [0, 6, 6, 0])) + assert rect_ref.get_linestyle() == (0, [6, 6]) + assert rect_ref2.get_linestyle() == (0, [0, 6, 6, 0]) + + ax_ref.add_patch(rect_ref) + ax_ref.add_patch(rect_ref2) + + # Check that the dash offset of the rect is the same if we pass it in the + # init method and if we create two rects with appropriate onoff sequence + # of linestyle. + + rect_test = Rectangle(loc, width, height, linewidth=3, edgecolor='b', + linestyle=(0, [6, 6])) + rect_test2 = Rectangle(loc, width, height, linewidth=3, edgecolor='r', + linestyle=(6, [6, 6])) + assert rect_test.get_linestyle() == (0, [6, 6]) + assert rect_test2.get_linestyle() == (6, [6, 6]) + + ax_test.add_patch(rect_test) + ax_test.add_patch(rect_test2) + + def test_negative_rect(): # These two rectangles have the same vertices, but starting from a # different point. (We also drop the last vertex, which is a duplicate.) @@ -487,7 +571,7 @@ def test_datetime_datetime_fails(): from datetime import datetime start = datetime(2017, 1, 1, 0, 0, 0) - dt_delta = datetime(1970, 1, 5) # Will be 5 days if units are done wrong + dt_delta = datetime(1970, 1, 5) # Will be 5 days if units are done wrong. with pytest.raises(TypeError): mpatches.Rectangle((start, 0), dt_delta, 1) @@ -497,7 +581,7 @@ def test_datetime_datetime_fails(): def test_contains_point(): - ell = mpatches.Ellipse((0.5, 0.5), 0.5, 1.0, 0) + ell = mpatches.Ellipse((0.5, 0.5), 0.5, 1.0) points = [(0.0, 0.5), (0.2, 0.5), (0.25, 0.5), (0.5, 0.5)] path = ell.get_path() transform = ell.get_transform() @@ -510,7 +594,7 @@ def test_contains_point(): def test_contains_points(): - ell = mpatches.Ellipse((0.5, 0.5), 0.5, 1.0, 0) + ell = mpatches.Ellipse((0.5, 0.5), 0.5, 1.0) points = [(0.0, 0.5), (0.2, 0.5), (0.25, 0.5), (0.5, 0.5)] path = ell.get_path() transform = ell.get_transform() @@ -592,7 +676,7 @@ def test_large_arc(): y = -2115 diameter = 4261 for ax in [ax1, ax2]: - a = mpatches.Arc((x, y), diameter, diameter, lw=2, color='k') + a = Arc((x, y), diameter, diameter, lw=2, color='k') ax.add_patch(a) ax.set_axis_off() ax.set_aspect('equal') @@ -619,7 +703,7 @@ def test_rotated_arcs(): for prescale, centers in zip((1 - .0001, (1 - .0001) / np.sqrt(2)), (on_axis_centers, diag_centers)): for j, (x_sign, y_sign) in enumerate(centers, start=k): - a = mpatches.Arc( + a = Arc( (x_sign * scale * prescale, y_sign * scale * prescale), scale * sx, @@ -642,6 +726,20 @@ def test_rotated_arcs(): ax.set_aspect("equal") +def test_fancyarrow_shape_error(): + with pytest.raises(ValueError, match="Got unknown shape: 'foo'"): + FancyArrow(0, 0, 0.2, 0.2, shape='foo') + + +@pytest.mark.parametrize('fmt, match', ( + ("foo", "Unknown style: 'foo'"), + ("Round,foo", "Incorrect style argument: 'Round,foo'"), +)) +def test_boxstyle_errors(fmt, match): + with pytest.raises(ValueError, match=match): + BoxStyle(fmt) + + @image_comparison(baseline_images=['annulus'], extensions=['png']) def test_annulus(): @@ -675,6 +773,31 @@ def test_annulus_setters(): ell.angle = 45 +@image_comparison(baseline_images=['annulus'], extensions=['png']) +def test_annulus_setters2(): + + fig, ax = plt.subplots() + cir = Annulus((0., 0.), 0.2, 0.01, fc='g') # circular annulus + ell = Annulus((0., 0.), (1, 2), 0.1, 0, # elliptical + fc='m', ec='b', alpha=0.5, hatch='xxx') + ax.add_patch(cir) + ax.add_patch(ell) + ax.set_aspect('equal') + + cir.center = (0.5, 0.5) + cir.set_semimajor(0.2) + cir.set_semiminor(0.2) + assert cir.radii == (0.2, 0.2) + cir.width = 0.05 + + ell.center = (0.5, 0.5) + ell.set_semimajor(0.5) + ell.set_semiminor(0.3) + assert ell.radii == (0.5, 0.3) + ell.width = 0.1 + ell.angle = 45 + + def test_degenerate_polygon(): point = [0, 0] correct_extents = Bbox([point, point]).extents @@ -719,3 +842,41 @@ def test_default_capstyle(): def test_default_joinstyle(): patch = Patch() assert patch.get_joinstyle() == 'miter' + + +@image_comparison(["autoscale_arc"], extensions=['png', 'svg'], + style="mpl20", remove_text=True) +def test_autoscale_arc(): + fig, axs = plt.subplots(1, 3, figsize=(4, 1)) + arc_lists = ( + [Arc((0, 0), 1, 1, theta1=0, theta2=90)], + [Arc((0.5, 0.5), 1.5, 0.5, theta1=10, theta2=20)], + [Arc((0.5, 0.5), 1.5, 0.5, theta1=10, theta2=20), + Arc((0.5, 0.5), 2.5, 0.5, theta1=110, theta2=120), + Arc((0.5, 0.5), 3.5, 0.5, theta1=210, theta2=220), + Arc((0.5, 0.5), 4.5, 0.5, theta1=310, theta2=320)]) + + for ax, arcs in zip(axs, arc_lists): + for arc in arcs: + ax.add_patch(arc) + ax.autoscale() + + +@check_figures_equal(extensions=["png", 'svg', 'pdf', 'eps']) +def test_arc_in_collection(fig_test, fig_ref): + arc1 = Arc([.5, .5], .5, 1, theta1=0, theta2=60, angle=20) + arc2 = Arc([.5, .5], .5, 1, theta1=0, theta2=60, angle=20) + col = mcollections.PatchCollection(patches=[arc2], facecolors='none', + edgecolors='k') + fig_ref.subplots().add_patch(arc1) + fig_test.subplots().add_collection(col) + + +@check_figures_equal(extensions=["png", 'svg', 'pdf', 'eps']) +def test_modifying_arc(fig_test, fig_ref): + arc1 = Arc([.5, .5], .5, 1, theta1=0, theta2=60, angle=20) + arc2 = Arc([.5, .5], 1.5, 1, theta1=0, theta2=60, angle=10) + fig_ref.subplots().add_patch(arc1) + fig_test.subplots().add_patch(arc2) + arc2.set_width(.5) + arc2.set_angle(20) diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index be8e9d2b6ced..8cc4905287e9 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -53,9 +53,7 @@ def test_path_exceptions(): def test_point_in_path(): # Test #1787 - verts2 = [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] - - path = Path(verts2, closed=True) + path = Path._create_closed([(0, 0), (0, 1), (1, 1), (1, 0)]) points = [(0.5, 0.5), (1.5, 0.5)] ret = path.contains_points(points) assert ret.dtype == 'bool' diff --git a/lib/matplotlib/tests/test_pickle.py b/lib/matplotlib/tests/test_pickle.py index c8b9260bc291..ec6bdcc2fe14 100644 --- a/lib/matplotlib/tests/test_pickle.py +++ b/lib/matplotlib/tests/test_pickle.py @@ -1,12 +1,16 @@ from io import BytesIO +import ast import pickle import numpy as np import pytest +import matplotlib as mpl from matplotlib import cm -from matplotlib.testing.decorators import image_comparison +from matplotlib.testing import subprocess_run_helper +from matplotlib.testing.decorators import check_figures_equal from matplotlib.dates import rrulewrapper +from matplotlib.lines import VertexSelector import matplotlib.pyplot as plt import matplotlib.transforms as mtransforms import matplotlib.figure as mfigure @@ -40,13 +44,9 @@ def test_simple(): pickle.dump(fig, BytesIO(), pickle.HIGHEST_PROTOCOL) -@image_comparison( - ['multi_pickle.png'], remove_text=True, style='mpl20', tol=0.082) -def test_complete(): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - - fig = plt.figure('Figure with a label?', figsize=(10, 6)) +def _generate_complete_test_figure(fig_ref): + fig_ref.set_size_inches((10, 6)) + plt.figure(fig_ref) plt.suptitle('Can you fit any more in a figure?') @@ -83,31 +83,78 @@ def test_complete(): plt.quiver(x, y, u, v) plt.subplot(3, 3, 8) - plt.scatter(x, x**2, label='$x^2$') + plt.scatter(x, x ** 2, label='$x^2$') plt.legend(loc='upper left') plt.subplot(3, 3, 9) plt.errorbar(x, x * -0.5, xerr=0.2, yerr=0.4) - # + +@mpl.style.context("default") +@check_figures_equal(extensions=["png"]) +def test_complete(fig_test, fig_ref): + _generate_complete_test_figure(fig_ref) # plotting is done, now test its pickle-ability - # - result_fh = BytesIO() - pickle.dump(fig, result_fh, pickle.HIGHEST_PROTOCOL) + pkl = BytesIO() + pickle.dump(fig_ref, pkl, pickle.HIGHEST_PROTOCOL) + loaded = pickle.loads(pkl.getbuffer()) + loaded.canvas.draw() + + fig_test.set_size_inches(loaded.get_size_inches()) + fig_test.figimage(loaded.canvas.renderer.buffer_rgba()) + + plt.close(loaded) + + +def _pickle_load_subprocess(): + import os + import pickle + + path = os.environ['PICKLE_FILE_PATH'] + + with open(path, 'rb') as blob: + fig = pickle.load(blob) + + print(str(pickle.dumps(fig))) + + +@mpl.style.context("default") +@check_figures_equal(extensions=['png']) +def test_pickle_load_from_subprocess(fig_test, fig_ref, tmp_path): + _generate_complete_test_figure(fig_ref) - plt.close('all') + fp = tmp_path / 'sinus.pickle' + assert not fp.exists() - # make doubly sure that there are no figures left - assert plt._pylab_helpers.Gcf.figs == {} + with fp.open('wb') as file: + pickle.dump(fig_ref, file, pickle.HIGHEST_PROTOCOL) + assert fp.exists() - # wind back the fh and load in the figure - result_fh.seek(0) - fig = pickle.load(result_fh) + proc = subprocess_run_helper( + _pickle_load_subprocess, + timeout=60, + extra_env={'PICKLE_FILE_PATH': str(fp)} + ) - # make sure there is now a figure manager - assert plt._pylab_helpers.Gcf.figs != {} + loaded_fig = pickle.loads(ast.literal_eval(proc.stdout)) - assert fig.get_label() == 'Figure with a label?' + loaded_fig.canvas.draw() + + fig_test.set_size_inches(loaded_fig.get_size_inches()) + fig_test.figimage(loaded_fig.canvas.renderer.buffer_rgba()) + + plt.close(loaded_fig) + + +def test_gcf(): + fig = plt.figure("a label") + buf = BytesIO() + pickle.dump(fig, buf, pickle.HIGHEST_PROTOCOL) + plt.close("all") + assert plt._pylab_helpers.Gcf.figs == {} # No figures must be left. + fig = pickle.loads(buf.getbuffer()) + assert plt._pylab_helpers.Gcf.figs != {} # A manager is there again. + assert fig.get_label() == "a label" def test_no_pyplot(): @@ -200,7 +247,7 @@ def test_inset_and_secondary(): pickle.loads(pickle.dumps(fig)) -@pytest.mark.parametrize("cmap", cm._cmap_registry.values()) +@pytest.mark.parametrize("cmap", cm._colormaps.values()) def test_cmap(cmap): pickle.dumps(cmap) @@ -218,3 +265,20 @@ def test_unpickle_canvas(): def test_mpl_toolkits(): ax = parasite_axes.host_axes([0, 0, 1, 1]) assert type(pickle.loads(pickle.dumps(ax))) == parasite_axes.HostAxes + + +def test_standard_norm(): + assert type(pickle.loads(pickle.dumps(mpl.colors.LogNorm()))) \ + == mpl.colors.LogNorm + + +def test_dynamic_norm(): + logit_norm_instance = mpl.colors.make_norm_from_scale( + mpl.scale.LogitScale, mpl.colors.Normalize)() + assert type(pickle.loads(pickle.dumps(logit_norm_instance))) \ + == type(logit_norm_instance) + + +def test_vertexselector(): + line, = plt.plot([0, 1], picker=True) + pickle.loads(pickle.dumps(VertexSelector(line))) diff --git a/lib/matplotlib/tests/test_png.py b/lib/matplotlib/tests/test_png.py index 133d3954452b..646db60cd0ae 100644 --- a/lib/matplotlib/tests/test_png.py +++ b/lib/matplotlib/tests/test_png.py @@ -4,8 +4,7 @@ import pytest from matplotlib.testing.decorators import image_comparison -from matplotlib import pyplot as plt -import matplotlib.cm as cm +from matplotlib import cm, pyplot as plt @image_comparison(['pngsuite.png'], tol=0.03) @@ -27,18 +26,17 @@ def test_pngsuite(): plt.gca().set_xlim(0, len(files)) -def test_truncated_file(tmpdir): - d = tmpdir.mkdir('test') - fname = str(d.join('test.png')) - fname_t = str(d.join('test_truncated.png')) - plt.savefig(fname) - with open(fname, 'rb') as fin: +def test_truncated_file(tmp_path): + path = tmp_path / 'test.png' + path_t = tmp_path / 'test_truncated.png' + plt.savefig(path) + with open(path, 'rb') as fin: buf = fin.read() - with open(fname_t, 'wb') as fout: + with open(path_t, 'wb') as fout: fout.write(buf[:20]) with pytest.raises(Exception): - plt.imread(fname_t) + plt.imread(path_t) def test_truncated_buffer(): diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 85aece5fce1a..1f8e6a75baca 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -291,6 +291,13 @@ def test_polar_no_data(): assert ax.get_rmin() == 0 and ax.get_rmax() == 1 +def test_polar_default_log_lims(): + plt.subplot(projection='polar') + ax = plt.gca() + ax.set_rscale('log') + assert ax.get_rmin() > 0 + + def test_polar_not_datalim_adjustable(): ax = plt.figure().add_subplot(projection="polar") with pytest.raises(ValueError): @@ -375,7 +382,7 @@ def test_default_thetalocator(): def test_axvspan(): ax = plt.subplot(projection="polar") - span = plt.axvspan(0, np.pi/4) + span = ax.axvspan(0, np.pi/4) assert span.get_path()._interpolation_steps > 1 @@ -427,3 +434,15 @@ def test_cursor_precision(): assert ax.format_coord(2, 0) == "θ=0.6π (115°), r=0.000" assert ax.format_coord(2, .1) == "θ=0.64π (115°), r=0.100" assert ax.format_coord(2, 1) == "θ=0.637π (114.6°), r=1.000" + + +@image_comparison(['polar_log.png'], style='default') +def test_polar_log(): + fig = plt.figure() + ax = fig.add_subplot(polar=True) + + ax.set_rscale('log') + ax.set_rlim(1, 1000) + + n = 100 + ax.plot(np.linspace(0, 2 * np.pi, n), np.logspace(0, 2, n)) diff --git a/lib/matplotlib/tests/test_preprocess_data.py b/lib/matplotlib/tests/test_preprocess_data.py index c4013ec27a6e..a95a72e7f78d 100644 --- a/lib/matplotlib/tests/test_preprocess_data.py +++ b/lib/matplotlib/tests/test_preprocess_data.py @@ -250,7 +250,7 @@ def funcy(ax, x, y, z, t=None): def test_data_parameter_replacement(): """ - Test that that the docstring contains the correct *data* parameter stub + Test that the docstring contains the correct *data* parameter stub for all methods that we run _preprocess_data() on. """ program = ( diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index 0105c6f22d47..95e3174d8ae8 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -1,4 +1,5 @@ import difflib + import numpy as np import subprocess import sys @@ -8,7 +9,7 @@ import matplotlib as mpl from matplotlib import pyplot as plt -from matplotlib.cbook import MatplotlibDeprecationWarning +from matplotlib._api import MatplotlibDeprecationWarning def test_pyplot_up_to_date(tmpdir): @@ -206,8 +207,8 @@ def test_subplot_replace_projection(): ax = plt.subplot(1, 2, 1) ax1 = plt.subplot(1, 2, 1) ax2 = plt.subplot(1, 2, 2) - # This will delete ax / ax1 as they fully overlap - ax3 = plt.subplot(1, 2, 1, projection='polar') + with pytest.warns(MatplotlibDeprecationWarning): + ax3 = plt.subplot(1, 2, 1, projection='polar') ax4 = plt.subplot(1, 2, 1, projection='polar') assert ax is not None assert ax1 is ax @@ -228,12 +229,13 @@ def test_subplot_kwarg_collision(): ax1 = plt.subplot(projection='polar', theta_offset=0) ax2 = plt.subplot(projection='polar', theta_offset=0) assert ax1 is ax2 + ax1.remove() ax3 = plt.subplot(projection='polar', theta_offset=1) assert ax1 is not ax3 assert ax1 not in plt.gcf().axes -def test_gca_kwargs(): +def test_gca(): # plt.gca() returns an existing axes, unless there were no axes. plt.figure() ax = plt.gca() @@ -242,38 +244,16 @@ def test_gca_kwargs(): assert ax1 is ax plt.close() - # plt.gca() raises a DeprecationWarning if called with kwargs. - plt.figure() - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax = plt.gca(projection='polar') - ax1 = plt.gca() - assert ax is not None - assert ax1 is ax - assert ax1.name == 'polar' - plt.close() - - # plt.gca() ignores keyword arguments if an axes already exists. - plt.figure() - ax = plt.gca() - with pytest.warns( - MatplotlibDeprecationWarning, - match=r'Calling gca\(\) with keyword arguments was deprecated'): - ax1 = plt.gca(projection='polar') - assert ax is not None - assert ax1 is ax - assert ax1.name == 'rectilinear' - plt.close() - def test_subplot_projection_reuse(): - # create an axes + # create an Axes ax1 = plt.subplot(111) # check that it is current assert ax1 is plt.gca() # make sure we get it back if we ask again assert ax1 is plt.subplot(111) + # remove it + ax1.remove() # create a polar plot ax2 = plt.subplot(111, projection='polar') assert ax2 is plt.gca() @@ -281,6 +261,7 @@ def test_subplot_projection_reuse(): assert ax1 not in plt.gcf().axes # assert we get it back if no extra parameters passed assert ax2 is plt.subplot(111) + ax2.remove() # now check explicitly setting the projection to rectilinear # makes a new axes ax3 = plt.subplot(111, projection='rectilinear') @@ -302,15 +283,19 @@ def test_subplot_polar_normalization(): def test_subplot_change_projection(): + created_axes = set() ax = plt.subplot() + created_axes.add(ax) projections = ('aitoff', 'hammer', 'lambert', 'mollweide', 'polar', 'rectilinear', '3d') for proj in projections: - ax_next = plt.subplot(projection=proj) - assert ax_next is plt.subplot() - assert ax_next.name == proj - assert ax is not ax_next - ax = ax_next + ax.remove() + ax = plt.subplot(projection=proj) + assert ax is plt.subplot() + assert ax.name == proj + created_axes.add(ax) + # Check that each call created a new Axes. + assert len(created_axes) == 1 + len(projections) def test_polar_second_call(): @@ -335,3 +320,138 @@ def test_fallback_position(): axtest = plt.axes([0.2, 0.2, 0.5, 0.5], position=[0.1, 0.1, 0.8, 0.8]) np.testing.assert_allclose(axtest.bbox.get_points(), axref.bbox.get_points()) + + +def test_set_current_figure_via_subfigure(): + fig1 = plt.figure() + subfigs = fig1.subfigures(2) + + plt.figure() + assert plt.gcf() != fig1 + + current = plt.figure(subfigs[1]) + assert plt.gcf() == fig1 + assert current == fig1 + + +def test_set_current_axes_on_subfigure(): + fig = plt.figure() + subfigs = fig.subfigures(2) + + ax = subfigs[0].subplots(1, squeeze=True) + subfigs[1].subplots(1, squeeze=True) + + assert plt.gca() != ax + plt.sca(ax) + assert plt.gca() == ax + + +def test_pylab_integration(): + IPython = pytest.importorskip("IPython") + mpl.testing.subprocess_run_helper( + IPython.start_ipython, + "--pylab", + "-c", + ";".join(( + "import matplotlib.pyplot as plt", + "assert plt._REPL_DISPLAYHOOK == plt._ReplDisplayHook.IPYTHON", + )), + timeout=60, + ) + + +def test_doc_pyplot_summary(): + """Test that pyplot_summary lists all the plot functions.""" + pyplot_docs = Path(__file__).parent / '../../../doc/api/pyplot_summary.rst' + if not pyplot_docs.exists(): + pytest.skip("Documentation sources not available") + + def extract_documented_functions(lines): + """ + Return a list of all the functions that are mentioned in the + autosummary blocks contained in *lines*. + + An autosummary block looks like this:: + + .. autosummary:: + :toctree: _as_gen + :template: autosummary.rst + :nosignatures: + + plot + plot_date + + """ + functions = [] + in_autosummary = False + for line in lines: + if not in_autosummary: + if line.startswith(".. autosummary::"): + in_autosummary = True + else: + if not line or line.startswith(" :"): + # empty line or autosummary parameter + continue + if not line[0].isspace(): + # no more indentation: end of autosummary block + in_autosummary = False + continue + functions.append(line.strip()) + return functions + + lines = pyplot_docs.read_text().split("\n") + doc_functions = set(extract_documented_functions(lines)) + plot_commands = set(plt._get_pyplot_commands()) + missing = plot_commands.difference(doc_functions) + if missing: + raise AssertionError( + f"The following pyplot functions are not listed in the " + f"documentation. Please add them to doc/api/pyplot_summary.rst: " + f"{missing!r}") + extra = doc_functions.difference(plot_commands) + if extra: + raise AssertionError( + f"The following functions are listed in the pyplot documentation, " + f"but they do not exist in pyplot. " + f"Please remove them from doc/api/pyplot_summary.rst: {extra!r}") + + +def test_minor_ticks(): + plt.figure() + plt.plot(np.arange(1, 10)) + tick_pos, tick_labels = plt.xticks(minor=True) + assert np.all(tick_labels == np.array([], dtype=np.float64)) + assert tick_labels == [] + + plt.yticks(ticks=[3.5, 6.5], labels=["a", "b"], minor=True) + ax = plt.gca() + tick_pos = ax.get_yticks(minor=True) + tick_labels = ax.get_yticklabels(minor=True) + assert np.all(tick_pos == np.array([3.5, 6.5])) + assert [l.get_text() for l in tick_labels] == ['a', 'b'] + + +def test_switch_backend_no_close(): + plt.switch_backend('agg') + fig = plt.figure() + fig = plt.figure() + assert len(plt.get_fignums()) == 2 + plt.switch_backend('agg') + assert len(plt.get_fignums()) == 2 + plt.switch_backend('svg') + assert len(plt.get_fignums()) == 0 + + +def figure_hook_example(figure): + figure._test_was_here = True + + +def test_figure_hook(): + + test_rc = { + 'figure.hooks': ['matplotlib.tests.test_pyplot:figure_hook_example'] + } + with mpl.rc_context(test_rc): + fig = plt.figure() + + assert fig._test_was_here diff --git a/lib/matplotlib/tests/test_quiver.py b/lib/matplotlib/tests/test_quiver.py index ca0bf19de5e7..6e032a54422a 100644 --- a/lib/matplotlib/tests/test_quiver.py +++ b/lib/matplotlib/tests/test_quiver.py @@ -8,13 +8,13 @@ from matplotlib.testing.decorators import image_comparison -def draw_quiver(ax, **kw): +def draw_quiver(ax, **kwargs): X, Y = np.meshgrid(np.arange(0, 2 * np.pi, 1), np.arange(0, 2 * np.pi, 1)) U = np.cos(X) V = np.sin(Y) - Q = ax.quiver(U, V, **kw) + Q = ax.quiver(U, V, **kwargs) return Q @@ -51,11 +51,11 @@ def test_quiver_number_of_args(): X = [1, 2] with pytest.raises( TypeError, - match='takes 2-5 positional arguments but 1 were given'): + match='takes from 2 to 5 positional arguments but 1 were given'): plt.quiver(X) with pytest.raises( TypeError, - match='takes 2-5 positional arguments but 6 were given'): + match='takes from 2 to 5 positional arguments but 6 were given'): plt.quiver(X, X, X, X, X, X) @@ -91,7 +91,7 @@ def test_no_warnings(): def test_zero_headlength(): # Based on report by Doug McNeil: - # http://matplotlib.1069221.n5.nabble.com/quiver-warnings-td28107.html + # https://discourse.matplotlib.org/t/quiver-warnings/16722 fig, ax = plt.subplots() X, Y = np.meshgrid(np.arange(10), np.arange(10)) U, V = np.cos(X), np.sin(Y) diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index ba376b0779bb..c17e88aee1ac 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -1,6 +1,7 @@ import copy import os from pathlib import Path +import re import subprocess import sys from unittest import mock @@ -20,6 +21,7 @@ _validate_color_or_linecolor, validate_cycler, validate_float, + validate_fontstretch, validate_fontweight, validate_hatch, validate_hist_bins, @@ -38,7 +40,7 @@ def test_rcparams(tmpdir): linewidth = mpl.rcParams['lines.linewidth'] rcpath = Path(tmpdir) / 'test_rcparams.rc' - rcpath.write_text('lines.linewidth: 33') + rcpath.write_text('lines.linewidth: 33', encoding='utf-8') # test context given dictionary with mpl.rc_context(rc={'text.usetex': not usetex}): @@ -190,7 +192,7 @@ def test_axes_titlecolor_rcparams(): def test_Issue_1713(tmpdir): rcpath = Path(tmpdir) / 'test_rcparams.rc' - rcpath.write_text('timezone: UTC', encoding='UTF-32-BE') + rcpath.write_text('timezone: UTC', encoding='utf-8') with mock.patch('locale.getpreferredencoding', return_value='UTF-32-BE'): rc = mpl.rc_params_from_file(rcpath, True, False) assert rc.get('timezone') == 'UTC' @@ -228,8 +230,6 @@ def generate_validator_testcases(valid): ), 'fail': ((set(), ValueError), (1, ValueError), - ((1, 2), _api.MatplotlibDeprecationWarning), - (np.array([1, 2]), _api.MatplotlibDeprecationWarning), ) }, {'validator': _listify_validator(validate_int, n=2), @@ -469,12 +469,39 @@ def test_validate_fontweight(weight, parsed_weight): assert validate_fontweight(weight) == parsed_weight +@pytest.mark.parametrize('stretch, parsed_stretch', [ + ('expanded', 'expanded'), + ('EXPANDED', ValueError), # stretch is case-sensitive + (100, 100), + ('100', 100), + (np.array(100), 100), + # fractional fontweights are not defined. This should actually raise a + # ValueError, but historically did not. + (20.6, 20), + ('20.6', ValueError), + ([100], ValueError), +]) +def test_validate_fontstretch(stretch, parsed_stretch): + if parsed_stretch is ValueError: + with pytest.raises(ValueError): + validate_fontstretch(stretch) + else: + assert validate_fontstretch(stretch) == parsed_stretch + + def test_keymaps(): key_list = [k for k in mpl.rcParams if 'keymap' in k] for k in key_list: assert isinstance(mpl.rcParams[k], list) +def test_no_backend_reset_rccontext(): + assert mpl.rcParams['backend'] != 'module://aardvark' + with mpl.rc_context(): + mpl.rcParams['backend'] = 'module://aardvark' + assert mpl.rcParams['backend'] == 'module://aardvark' + + def test_rcparams_reset_after_fail(): # There was previously a bug that meant that if rc_context failed and # raised an exception due to issues in the supplied rc parameters, the @@ -515,10 +542,58 @@ def test_backend_fallback_headful(tmpdir): "sentinel = mpl.rcsetup._auto_backend_sentinel; " # Check that access on another instance does not resolve the sentinel. "assert mpl.RcParams({'backend': sentinel})['backend'] == sentinel; " - "assert dict.__getitem__(mpl.rcParams, 'backend') == sentinel; " + "assert mpl.rcParams._get('backend') == sentinel; " "import matplotlib.pyplot; " "print(matplotlib.get_backend())"], env=env, universal_newlines=True) # The actual backend will depend on what's installed, but at least tkagg is # present. assert backend.strip().lower() != "agg" + + +def test_deprecation(monkeypatch): + monkeypatch.setitem( + mpl._deprecated_map, "patch.linewidth", + ("0.0", "axes.linewidth", lambda old: 2 * old, lambda new: new / 2)) + with pytest.warns(_api.MatplotlibDeprecationWarning): + assert mpl.rcParams["patch.linewidth"] \ + == mpl.rcParams["axes.linewidth"] / 2 + with pytest.warns(_api.MatplotlibDeprecationWarning): + mpl.rcParams["patch.linewidth"] = 1 + assert mpl.rcParams["axes.linewidth"] == 2 + + monkeypatch.setitem( + mpl._deprecated_ignore_map, "patch.edgecolor", + ("0.0", "axes.edgecolor")) + with pytest.warns(_api.MatplotlibDeprecationWarning): + assert mpl.rcParams["patch.edgecolor"] \ + == mpl.rcParams["axes.edgecolor"] + with pytest.warns(_api.MatplotlibDeprecationWarning): + mpl.rcParams["patch.edgecolor"] = "#abcd" + assert mpl.rcParams["axes.edgecolor"] != "#abcd" + + monkeypatch.setitem( + mpl._deprecated_ignore_map, "patch.force_edgecolor", + ("0.0", None)) + with pytest.warns(_api.MatplotlibDeprecationWarning): + assert mpl.rcParams["patch.force_edgecolor"] is None + + monkeypatch.setitem( + mpl._deprecated_remain_as_none, "svg.hashsalt", + ("0.0",)) + with pytest.warns(_api.MatplotlibDeprecationWarning): + mpl.rcParams["svg.hashsalt"] = "foobar" + assert mpl.rcParams["svg.hashsalt"] == "foobar" # Doesn't warn. + mpl.rcParams["svg.hashsalt"] = None # Doesn't warn. + + mpl.rcParams.update(mpl.rcParams.copy()) # Doesn't warn. + # Note that the warning suppression actually arises from the + # iteration over the updater rcParams being protected by + # suppress_matplotlib_deprecation_warning, rather than any explicit check. + + +def test_rcparams_legend_loc(): + value = (0.9, .7) + match_str = f"{value} is not a valid value for legend.loc;" + with pytest.raises(ValueError, match=re.escape(match_str)): + mpl.RcParams({'legend.loc': value}) diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py index 1851525bd4e2..cbb7f516a65c 100644 --- a/lib/matplotlib/tests/test_sankey.py +++ b/lib/matplotlib/tests/test_sankey.py @@ -1,4 +1,8 @@ +import pytest +from numpy.testing import assert_allclose, assert_array_equal + from matplotlib.sankey import Sankey +from matplotlib.testing.decorators import check_figures_equal def test_sankey(): @@ -22,3 +26,80 @@ def show_three_decimal_places(value): format=show_three_decimal_places) assert s.diagrams[0].texts[0].get_text() == 'First\n0.250' + + +@pytest.mark.parametrize('kwargs, msg', ( + ({'gap': -1}, "'gap' is negative"), + ({'gap': 1, 'radius': 2}, "'radius' is greater than 'gap'"), + ({'head_angle': -1}, "'head_angle' is negative"), + ({'tolerance': -1}, "'tolerance' is negative"), + ({'flows': [1, -1], 'orientations': [-1, 0, 1]}, + r"The shapes of 'flows' \(2,\) and 'orientations'"), + ({'flows': [1, -1], 'labels': ['a', 'b', 'c']}, + r"The shapes of 'flows' \(2,\) and 'labels'"), + )) +def test_sankey_errors(kwargs, msg): + with pytest.raises(ValueError, match=msg): + Sankey(**kwargs) + + +@pytest.mark.parametrize('kwargs, msg', ( + ({'trunklength': -1}, "'trunklength' is negative"), + ({'flows': [0.2, 0.3], 'prior': 0}, "The scaled sum of the connected"), + ({'prior': -1}, "The index of the prior diagram is negative"), + ({'prior': 1}, "The index of the prior diagram is 1"), + ({'connect': (-1, 1), 'prior': 0}, "At least one of the connection"), + ({'connect': (2, 1), 'prior': 0}, "The connection index to the source"), + ({'connect': (1, 3), 'prior': 0}, "The connection index to this dia"), + ({'connect': (1, 1), 'prior': 0, 'flows': [-0.2, 0.2], + 'orientations': [2]}, "The value of orientations"), + ({'connect': (1, 1), 'prior': 0, 'flows': [-0.2, 0.2], + 'pathlengths': [2]}, "The lengths of 'flows'"), + )) +def test_sankey_add_errors(kwargs, msg): + sankey = Sankey() + with pytest.raises(ValueError, match=msg): + sankey.add(flows=[0.2, -0.2]) + sankey.add(**kwargs) + + +def test_sankey2(): + s = Sankey(flows=[0.25, -0.25, 0.5, -0.5], labels=['Foo'], + orientations=[-1], unit='Bar') + sf = s.finish() + assert_array_equal(sf[0].flows, [0.25, -0.25, 0.5, -0.5]) + assert sf[0].angles == [1, 3, 1, 3] + assert all([text.get_text()[0:3] == 'Foo' for text in sf[0].texts]) + assert all([text.get_text()[-3:] == 'Bar' for text in sf[0].texts]) + assert sf[0].text.get_text() == '' + assert_allclose(sf[0].tips, + [(-1.375, -0.52011255), + (1.375, -0.75506044), + (-0.75, -0.41522509), + (0.75, -0.8599479)]) + + s = Sankey(flows=[0.25, -0.25, 0, 0.5, -0.5], labels=['Foo'], + orientations=[-1], unit='Bar') + sf = s.finish() + assert_array_equal(sf[0].flows, [0.25, -0.25, 0, 0.5, -0.5]) + assert sf[0].angles == [1, 3, None, 1, 3] + assert_allclose(sf[0].tips, + [(-1.375, -0.52011255), + (1.375, -0.75506044), + (0, 0), + (-0.75, -0.41522509), + (0.75, -0.8599479)]) + + +@check_figures_equal(extensions=['png']) +def test_sankey3(fig_test, fig_ref): + ax_test = fig_test.gca() + s_test = Sankey(ax=ax_test, flows=[0.25, -0.25, -0.25, 0.25, 0.5, -0.5], + orientations=[1, -1, 1, -1, 0, 0]) + s_test.finish() + + ax_ref = fig_ref.gca() + s_ref = Sankey(ax=ax_ref) + s_ref.add(flows=[0.25, -0.25, -0.25, 0.25, 0.5, -0.5], + orientations=[1, -1, 1, -1, 0, 0]) + s_ref.finish() diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index 8fba86d2e82e..3b70d1e9d31d 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -2,9 +2,11 @@ import matplotlib.pyplot as plt from matplotlib.scale import ( + AsinhScale, AsinhTransform, LogTransform, InvertedLogTransform, SymmetricalLogTransform) import matplotlib.scale as mscale +from matplotlib.ticker import AsinhLocator, LogFormatterSciNotation from matplotlib.testing.decorators import check_figures_equal, image_comparison import numpy as np @@ -53,6 +55,20 @@ def test_symlog_mask_nan(): assert type(out) == type(x) +def test_symlog_linthresh(): + np.random.seed(19680801) + x = np.random.random(100) + y = np.random.random(100) + + fig, ax = plt.subplots() + plt.plot(x, y, 'o') + ax.set_xscale('symlog') + ax.set_yscale('symlog') + + with pytest.warns(UserWarning, match="All values .* of linthresh"): + fig.canvas.draw() + + @image_comparison(['logit_scales.png'], remove_text=True) def test_logit_scales(): fig, ax = plt.subplots() @@ -219,3 +235,75 @@ def test_scale_deepcopy(): sc2 = copy.deepcopy(sc) assert str(sc.get_transform()) == str(sc2.get_transform()) assert sc._transform is not sc2._transform + + +class TestAsinhScale: + def test_transforms(self): + a0 = 17.0 + a = np.linspace(-50, 50, 100) + + forward = AsinhTransform(a0) + inverse = forward.inverted() + invinv = inverse.inverted() + + a_forward = forward.transform_non_affine(a) + a_inverted = inverse.transform_non_affine(a_forward) + assert_allclose(a_inverted, a) + + a_invinv = invinv.transform_non_affine(a) + assert_allclose(a_invinv, a0 * np.arcsinh(a / a0)) + + def test_init(self): + fig, ax = plt.subplots() + + s = AsinhScale(axis=None, linear_width=23.0) + assert s.linear_width == 23 + assert s._base == 10 + assert s._subs == (2, 5) + + tx = s.get_transform() + assert isinstance(tx, AsinhTransform) + assert tx.linear_width == s.linear_width + + def test_base_init(self): + fig, ax = plt.subplots() + + s3 = AsinhScale(axis=None, base=3) + assert s3._base == 3 + assert s3._subs == (2,) + + s7 = AsinhScale(axis=None, base=7, subs=(2, 4)) + assert s7._base == 7 + assert s7._subs == (2, 4) + + def test_fmtloc(self): + class DummyAxis: + def __init__(self): + self.fields = {} + def set(self, **kwargs): + self.fields.update(**kwargs) + def set_major_formatter(self, f): + self.fields['major_formatter'] = f + + ax0 = DummyAxis() + s0 = AsinhScale(axis=ax0, base=0) + s0.set_default_locators_and_formatters(ax0) + assert isinstance(ax0.fields['major_locator'], AsinhLocator) + assert isinstance(ax0.fields['major_formatter'], str) + + ax5 = DummyAxis() + s7 = AsinhScale(axis=ax5, base=5) + s7.set_default_locators_and_formatters(ax5) + assert isinstance(ax5.fields['major_locator'], AsinhLocator) + assert isinstance(ax5.fields['major_formatter'], + LogFormatterSciNotation) + + def test_bad_scale(self): + fig, ax = plt.subplots() + + with pytest.raises(ValueError): + AsinhScale(axis=None, linear_width=0) + with pytest.raises(ValueError): + AsinhScale(axis=None, linear_width=-1) + s0 = AsinhScale(axis=None, ) + s1 = AsinhScale(axis=None, linear_width=3.0) diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index f71c6e32018a..41575d3a3ce1 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -14,11 +14,29 @@ minversion=None if sys.version_info < (3, 10) else '4.1.3') -def test_tinypages(tmpdir): - source_dir = Path(tmpdir) / 'src' - shutil.copytree(Path(__file__).parent / 'tinypages', source_dir) - html_dir = source_dir / '_build' / 'html' - doctree_dir = source_dir / 'doctrees' +def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None): + # Build the pages with warnings turned into errors + extra_args = [] if extra_args is None else extra_args + cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', + '-d', str(doctree_dir), str(source_dir), str(html_dir), *extra_args] + proc = Popen(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True, + env={**os.environ, "MPLBACKEND": ""}) + out, err = proc.communicate() + + assert proc.returncode == 0, \ + f"sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n" + if err: + pytest.fail(f"sphinx build emitted the following warnings:\n{err}") + + assert html_dir.is_dir() + + +def test_tinypages(tmp_path): + shutil.copytree(Path(__file__).parent / 'tinypages', tmp_path, + dirs_exist_ok=True) + html_dir = tmp_path / '_build' / 'html' + img_dir = html_dir / '_images' + doctree_dir = tmp_path / 'doctrees' # Build the pages with warnings turned into errors cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', '-d', str(doctree_dir), @@ -32,10 +50,10 @@ def test_tinypages(tmpdir): out, err = proc.communicate() # Build the pages with warnings turned into errors - build_sphinx_html(source_dir, doctree_dir, html_dir) + build_sphinx_html(tmp_path, doctree_dir, html_dir) def plot_file(num): - return html_dir / f'some_plots-{num}.png' + return img_dir / f'some_plots-{num}.png' def plot_directive_file(num): # This is always next to the doctree dir. @@ -58,8 +76,8 @@ def plot_directive_file(num): assert b'# Only a comment' in html_contents # check plot defined in external file. - assert filecmp.cmp(range_4, html_dir / 'range4.png') - assert filecmp.cmp(range_6, html_dir / 'range6.png') + assert filecmp.cmp(range_4, img_dir / 'range4.png') + assert filecmp.cmp(range_6, img_dir / 'range6_range6.png') # check if figure caption made it into html file assert b'This is the caption for plot 15.' in html_contents # check if figure caption using :caption: made it into html file @@ -73,15 +91,17 @@ def plot_directive_file(num): # Plot 21 is range(6) plot via an include directive. But because some of # the previous plots are repeated, the argument to plot_file() is only 17. assert filecmp.cmp(range_6, plot_file(17)) + # plot 22 is from the range6.py file again, but a different function + assert filecmp.cmp(range_10, img_dir / 'range6_range10.png') # Modify the included plot - contents = (source_dir / 'included_plot_21.rst').read_text() - contents = contents.replace('plt.plot(range(6))', 'plt.plot(range(4))') - (source_dir / 'included_plot_21.rst').write_text(contents) + contents = (tmp_path / 'included_plot_21.rst').read_bytes() + contents = contents.replace(b'plt.plot(range(6))', b'plt.plot(range(4))') + (tmp_path / 'included_plot_21.rst').write_bytes(contents) # Build the pages again and check that the modified file was updated modification_times = [plot_directive_file(i).stat().st_mtime for i in (1, 2, 3, 5)] - build_sphinx_html(source_dir, doctree_dir, html_dir) + build_sphinx_html(tmp_path, doctree_dir, html_dir) assert filecmp.cmp(range_4, plot_file(17)) # Check that the plots in the plot_directive folder weren't changed. # (plot_directive_file(1) won't be modified, but it will be copied to html/ @@ -98,42 +118,63 @@ def plot_directive_file(num): assert filecmp.cmp(range_6, plot_file(5)) -def test_plot_html_show_source_link(tmpdir): - source_dir = Path(tmpdir) / 'src' - source_dir.mkdir() +def test_plot_html_show_source_link(tmp_path): parent = Path(__file__).parent - shutil.copyfile(parent / 'tinypages/conf.py', source_dir / 'conf.py') - shutil.copytree(parent / 'tinypages/_static', source_dir / '_static') - doctree_dir = source_dir / 'doctrees' - (source_dir / 'index.rst').write_text(""" + shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py') + shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static') + doctree_dir = tmp_path / 'doctrees' + (tmp_path / 'index.rst').write_text(""" .. plot:: plt.plot(range(2)) """) # Make sure source scripts are created by default - html_dir1 = source_dir / '_build' / 'html1' - build_sphinx_html(source_dir, doctree_dir, html_dir1) - assert "index-1.py" in [p.name for p in html_dir1.iterdir()] + html_dir1 = tmp_path / '_build' / 'html1' + build_sphinx_html(tmp_path, doctree_dir, html_dir1) + assert len(list(html_dir1.glob("**/index-1.py"))) == 1 # Make sure source scripts are NOT created when # plot_html_show_source_link` is False - html_dir2 = source_dir / '_build' / 'html2' - build_sphinx_html(source_dir, doctree_dir, html_dir2, + html_dir2 = tmp_path / '_build' / 'html2' + build_sphinx_html(tmp_path, doctree_dir, html_dir2, extra_args=['-D', 'plot_html_show_source_link=0']) - assert "index-1.py" not in [p.name for p in html_dir2.iterdir()] + assert len(list(html_dir2.glob("**/index-1.py"))) == 0 -def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None): - # Build the pages with warnings turned into errors - extra_args = [] if extra_args is None else extra_args - cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', - '-d', str(doctree_dir), str(source_dir), str(html_dir), *extra_args] - proc = Popen(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True, - env={**os.environ, "MPLBACKEND": ""}) - out, err = proc.communicate() +@pytest.mark.parametrize('plot_html_show_source_link', [0, 1]) +def test_show_source_link_true(tmp_path, plot_html_show_source_link): + # Test that a source link is generated if :show-source-link: is true, + # whether or not plot_html_show_source_link is true. + parent = Path(__file__).parent + shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py') + shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static') + doctree_dir = tmp_path / 'doctrees' + (tmp_path / 'index.rst').write_text(""" +.. plot:: + :show-source-link: true - assert proc.returncode == 0, \ - f"sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n" - if err: - pytest.fail(f"sphinx build emitted the following warnings:\n{err}") + plt.plot(range(2)) +""") + html_dir = tmp_path / '_build' / 'html' + build_sphinx_html(tmp_path, doctree_dir, html_dir, extra_args=[ + '-D', f'plot_html_show_source_link={plot_html_show_source_link}']) + assert len(list(html_dir.glob("**/index-1.py"))) == 1 - assert html_dir.is_dir() + +@pytest.mark.parametrize('plot_html_show_source_link', [0, 1]) +def test_show_source_link_false(tmp_path, plot_html_show_source_link): + # Test that a source link is NOT generated if :show-source-link: is false, + # whether or not plot_html_show_source_link is true. + parent = Path(__file__).parent + shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py') + shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static') + doctree_dir = tmp_path / 'doctrees' + (tmp_path / 'index.rst').write_text(""" +.. plot:: + :show-source-link: false + + plt.plot(range(2)) +""") + html_dir = tmp_path / '_build' / 'html' + build_sphinx_html(tmp_path, doctree_dir, html_dir, extra_args=[ + '-D', f'plot_html_show_source_link={plot_html_show_source_link}']) + assert len(list(html_dir.glob("**/index-1.py"))) == 0 diff --git a/lib/matplotlib/tests/test_spines.py b/lib/matplotlib/tests/test_spines.py index b8891aec411e..89bc0c872de5 100644 --- a/lib/matplotlib/tests/test_spines.py +++ b/lib/matplotlib/tests/test_spines.py @@ -134,3 +134,17 @@ def test_label_without_ticks(): spine.get_path()).get_extents() assert ax.xaxis.label.get_position()[1] < spinebbox.ymin, \ "X-Axis label not below the spine" + + +@image_comparison(['black_axes']) +def test_spines_black_axes(): + # GitHub #18804 + plt.rcParams["savefig.pad_inches"] = 0 + plt.rcParams["savefig.bbox"] = 'tight' + fig = plt.figure(0, figsize=(4, 4)) + ax = fig.add_axes((0, 0, 1, 1)) + ax.set_xticklabels([]) + ax.set_yticklabels([]) + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_facecolor((0, 0, 0)) diff --git a/lib/matplotlib/tests/test_streamplot.py b/lib/matplotlib/tests/test_streamplot.py index c8824feb063c..10a64f1d6968 100644 --- a/lib/matplotlib/tests/test_streamplot.py +++ b/lib/matplotlib/tests/test_streamplot.py @@ -34,7 +34,8 @@ def test_startpoints(): plt.plot(start_x, start_y, 'ok') -@image_comparison(['streamplot_colormap'], remove_text=True, style='mpl20') +@image_comparison(['streamplot_colormap'], remove_text=True, style='mpl20', + tol=0.022) def test_colormap(): X, Y, U, V = velocity_field() plt.streamplot(X, Y, U, V, color=U, density=0.6, linewidth=2, @@ -77,6 +78,18 @@ def test_maxlength(): ax.set(xlim=(None, 3.2555988021882305), ylim=(None, 3.078326760195413)) +@image_comparison(['streamplot_maxlength_no_broken.png'], + remove_text=True, style='mpl20', tol=0.302) +def test_maxlength_no_broken(): + x, y, U, V = swirl_velocity_field() + ax = plt.figure().subplots() + ax.streamplot(x, y, U, V, maxlength=10., start_points=[[0., 1.5]], + linewidth=2, density=2, broken_streamlines=False) + assert ax.get_xlim()[-1] == ax.get_ylim()[-1] == 3 + # Compatibility for old test image + ax.set(xlim=(None, 3.2555988021882305), ylim=(None, 3.078326760195413)) + + @image_comparison(['streamplot_direction.png'], remove_text=True, style='mpl20', tol=0.073) def test_direction(): @@ -144,3 +157,13 @@ def test_streamplot_grid(): with pytest.raises(ValueError, match="'y' must be strictly increasing"): plt.streamplot(x, y, u, v) + + +def test_streamplot_inputs(): # test no exception occurs. + # fully-masked + plt.streamplot(np.arange(3), np.arange(3), + np.full((3, 3), np.nan), np.full((3, 3), np.nan), + color=np.random.rand(3, 3)) + # array-likes + plt.streamplot(range(3), range(3), + np.random.rand(3, 3), np.random.rand(3, 3)) diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py index 5ed115abb68f..7d1ed94ea236 100644 --- a/lib/matplotlib/tests/test_style.py +++ b/lib/matplotlib/tests/test_style.py @@ -26,7 +26,8 @@ def temp_style(style_name, settings=None): with TemporaryDirectory() as tmpdir: # Write style settings to file in the tmpdir. Path(tmpdir, temp_file).write_text( - "\n".join("{}: {}".format(k, v) for k, v in settings.items())) + "\n".join("{}: {}".format(k, v) for k, v in settings.items()), + encoding="utf-8") # Add tmpdir to style path and reload so we can access this style. USER_LIBRARY_PATHS.append(tmpdir) style.reload_library() @@ -59,7 +60,7 @@ def test_use(): def test_use_url(tmpdir): path = Path(tmpdir, 'file') - path.write_text('axes.facecolor: adeade') + path.write_text('axes.facecolor: adeade', encoding='utf-8') with temp_style('test', DUMMY_SETTINGS): url = ('file:' + ('///' if sys.platform == 'win32' else '') @@ -72,7 +73,7 @@ def test_single_path(tmpdir): mpl.rcParams[PARAM] = 'gray' temp_file = f'text.{STYLE_EXTENSION}' path = Path(tmpdir, temp_file) - path.write_text(f'{PARAM} : {VALUE}') + path.write_text(f'{PARAM} : {VALUE}', encoding='utf-8') with style.context(path): assert mpl.rcParams[PARAM] == VALUE assert mpl.rcParams[PARAM] == 'gray' @@ -174,3 +175,33 @@ def test_xkcd_cm(): with plt.xkcd(): assert mpl.rcParams["path.sketch"] == (1, 100, 2) assert mpl.rcParams["path.sketch"] is None + + +def test_deprecated_seaborn_styles(): + with mpl.style.context("seaborn-v0_8-bright"): + seaborn_bright = mpl.rcParams.copy() + assert mpl.rcParams != seaborn_bright + with pytest.warns(mpl._api.MatplotlibDeprecationWarning): + mpl.style.use("seaborn-bright") + assert mpl.rcParams == seaborn_bright + with pytest.warns(mpl._api.MatplotlibDeprecationWarning): + mpl.style.library["seaborn-bright"] + + +def test_up_to_date_blacklist(): + assert mpl.style.core.STYLE_BLACKLIST <= {*mpl.rcsetup._validators} + + +def test_style_from_module(tmp_path, monkeypatch): + monkeypatch.syspath_prepend(tmp_path) + monkeypatch.chdir(tmp_path) + pkg_path = tmp_path / "mpl_test_style_pkg" + pkg_path.mkdir() + (pkg_path / "test_style.mplstyle").write_text( + "lines.linewidth: 42", encoding="utf-8") + pkg_path.with_suffix(".mplstyle").write_text( + "lines.linewidth: 84", encoding="utf-8") + mpl.style.use("mpl_test_style_pkg.test_style") + assert mpl.rcParams["lines.linewidth"] == 42 + mpl.style.use("mpl_test_style_pkg.mplstyle") + assert mpl.rcParams["lines.linewidth"] == 84 diff --git a/lib/matplotlib/tests/test_subplots.py b/lib/matplotlib/tests/test_subplots.py index 47265d2c70a1..462dc55d8a8d 100644 --- a/lib/matplotlib/tests/test_subplots.py +++ b/lib/matplotlib/tests/test_subplots.py @@ -3,9 +3,9 @@ import numpy as np import pytest +from matplotlib.axes import Axes, SubplotBase import matplotlib.pyplot as plt -from matplotlib.testing.decorators import image_comparison -import matplotlib.axes as maxes +from matplotlib.testing.decorators import check_figures_equal, image_comparison def check_shared(axs, x_shared, y_shared): @@ -84,7 +84,7 @@ def test_shared(): plt.close(f) # test all option combinations - ops = [False, True, 'all', 'none', 'row', 'col'] + ops = [False, True, 'all', 'none', 'row', 'col', 0, 1] for xo in ops: for yo in ops: f, ((a1, a2), (a3, a4)) = plt.subplots(2, 2, sharex=xo, sharey=yo) @@ -122,6 +122,12 @@ def test_label_outer_span(): fig.axes, [False, True, False, True], [True, True, False, False]) +def test_label_outer_non_gridspec(): + ax = plt.axes([0, 0, 1, 1]) + ax.label_outer() # Does nothing. + check_visible([ax], [True], [True]) + + def test_shared_and_moved(): # test if sharey is on, but then tick_left is called that labels don't # re-appear. Seaborn does this just to be sure yaxis is on left... @@ -144,7 +150,7 @@ def test_exceptions(): plt.subplots(2, 2, sharey='blah') -@image_comparison(['subplots_offset_text'], remove_text=False) +@image_comparison(['subplots_offset_text']) def test_subplots_offsettext(): x = np.arange(0, 1e10, 1e9) y = np.arange(0, 100, 10)+1e4 @@ -209,6 +215,48 @@ def test_dont_mutate_kwargs(): assert gridspec_kw == {'width_ratios': [1, 2]} -def test_subplot_factory_reapplication(): - assert maxes.subplot_class_factory(maxes.Axes) is maxes.Subplot - assert maxes.subplot_class_factory(maxes.Subplot) is maxes.Subplot +@pytest.mark.parametrize("width_ratios", [None, [1, 3, 2]]) +@pytest.mark.parametrize("height_ratios", [None, [1, 2]]) +@check_figures_equal(extensions=['png']) +def test_width_and_height_ratios(fig_test, fig_ref, + height_ratios, width_ratios): + fig_test.subplots(2, 3, height_ratios=height_ratios, + width_ratios=width_ratios) + fig_ref.subplots(2, 3, gridspec_kw={ + 'height_ratios': height_ratios, + 'width_ratios': width_ratios}) + + +@pytest.mark.parametrize("width_ratios", [None, [1, 3, 2]]) +@pytest.mark.parametrize("height_ratios", [None, [1, 2]]) +@check_figures_equal(extensions=['png']) +def test_width_and_height_ratios_mosaic(fig_test, fig_ref, + height_ratios, width_ratios): + mosaic_spec = [['A', 'B', 'B'], ['A', 'C', 'D']] + fig_test.subplot_mosaic(mosaic_spec, height_ratios=height_ratios, + width_ratios=width_ratios) + fig_ref.subplot_mosaic(mosaic_spec, gridspec_kw={ + 'height_ratios': height_ratios, + 'width_ratios': width_ratios}) + + +@pytest.mark.parametrize('method,args', [ + ('subplots', (2, 3)), + ('subplot_mosaic', ('abc;def', )) + ] +) +def test_ratio_overlapping_kws(method, args): + with pytest.raises(ValueError, match='height_ratios'): + getattr(plt, method)(*args, height_ratios=[1, 2], + gridspec_kw={'height_ratios': [1, 2]}) + with pytest.raises(ValueError, match='width_ratios'): + getattr(plt, method)(*args, width_ratios=[1, 2, 3], + gridspec_kw={'width_ratios': [1, 2, 3]}) + + +def test_old_subplot_compat(): + fig = plt.figure() + assert isinstance(fig.add_subplot(), SubplotBase) + assert not isinstance(fig.add_axes(rect=[0, 0, 1, 1]), SubplotBase) + with pytest.raises(TypeError): + Axes(fig, [0, 0, 1, 1], rect=[0, 0, 1, 1]) diff --git a/lib/matplotlib/tests/test_table.py b/lib/matplotlib/tests/test_table.py index d4f68ea1805d..9b2cb96ea037 100644 --- a/lib/matplotlib/tests/test_table.py +++ b/lib/matplotlib/tests/test_table.py @@ -1,9 +1,10 @@ import matplotlib.pyplot as plt import numpy as np -from matplotlib.testing.decorators import image_comparison +from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.table import CustomCell, Table from matplotlib.path import Path +from matplotlib.transforms import Bbox def test_non_square(): @@ -194,3 +195,30 @@ def test_table_cells(): # properties and setp table.properties() plt.setp(table) + + +@check_figures_equal(extensions=["png"]) +def test_table_bbox(fig_test, fig_ref): + data = [[2, 3], + [4, 5]] + + col_labels = ('Foo', 'Bar') + row_labels = ('Ada', 'Bob') + + cell_text = [[f"{x}" for x in row] for row in data] + + ax_list = fig_test.subplots() + ax_list.table(cellText=cell_text, + rowLabels=row_labels, + colLabels=col_labels, + loc='center', + bbox=[0.1, 0.2, 0.8, 0.6] + ) + + ax_bbox = fig_ref.subplots() + ax_bbox.table(cellText=cell_text, + rowLabels=row_labels, + colLabels=col_labels, + loc='center', + bbox=Bbox.from_extents(0.1, 0.2, 0.9, 0.8) + ) diff --git a/lib/matplotlib/tests/test_texmanager.py b/lib/matplotlib/tests/test_texmanager.py index 5f98112043bb..fbff21144e60 100644 --- a/lib/matplotlib/tests/test_texmanager.py +++ b/lib/matplotlib/tests/test_texmanager.py @@ -1,23 +1,24 @@ +import os from pathlib import Path import re +import subprocess +import sys import matplotlib.pyplot as plt from matplotlib.texmanager import TexManager +from matplotlib.testing._markers import needs_usetex import pytest def test_fontconfig_preamble(): - """Test that the preamble is included in _fontconfig.""" + """Test that the preamble is included in the source.""" plt.rcParams['text.usetex'] = True - tm1 = TexManager() - font_config1 = tm1.get_font_config() - + src1 = TexManager()._get_tex_source("", fontsize=12) plt.rcParams['text.latex.preamble'] = '\\usepackage{txfonts}' - tm2 = TexManager() - font_config2 = tm2.get_font_config() + src2 = TexManager()._get_tex_source("", fontsize=12) - assert font_config1 != font_config2 + assert src1 != src2 @pytest.mark.parametrize( @@ -42,3 +43,32 @@ def test_font_selection(rc, preamble, family): src = Path(tm.make_tex("hello, world", fontsize=12)).read_text() assert preamble in src assert [*re.findall(r"\\\w+family", src)] == [family] + + +@needs_usetex +def test_unicode_characters(): + # Smoke test to see that Unicode characters does not cause issues + # See #23019 + plt.rcParams['text.usetex'] = True + fig, ax = plt.subplots() + ax.set_ylabel('\\textit{Velocity (\N{DEGREE SIGN}/sec)}') + ax.set_xlabel('\N{VULGAR FRACTION ONE QUARTER}Öøæ') + fig.canvas.draw() + + # But not all characters. + # Should raise RuntimeError, not UnicodeDecodeError + with pytest.raises(RuntimeError): + ax.set_title('\N{SNOWMAN}') + fig.canvas.draw() + + +@needs_usetex +def test_openin_any_paranoid(): + completed = subprocess.run( + [sys.executable, "-c", + 'import matplotlib.pyplot as plt;' + 'plt.rcParams.update({"text.usetex": True});' + 'plt.title("paranoid");' + 'plt.show(block=False);'], + env={**os.environ, 'openin_any': 'p'}, check=True, capture_output=True) + assert completed.stderr == b"" diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 9b7d1ce047bb..f775efa01de4 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -13,14 +13,10 @@ import matplotlib.pyplot as plt import matplotlib.transforms as mtransforms from matplotlib.testing.decorators import check_figures_equal, image_comparison +from matplotlib.testing._markers import needs_usetex from matplotlib.text import Text -needs_usetex = pytest.mark.skipif( - not mpl.checkdep_usetex(True), - reason="This test needs a TeX installation") - - @image_comparison(['font_styles']) def test_font_styles(): @@ -43,11 +39,16 @@ def find_matplotlib_font(**kw): style="normal", variant="normal", size=14) - ax.annotate( + a = ax.annotate( "Normal Font", (0.1, 0.1), xycoords='axes fraction', fontproperties=normal_font) + assert a.get_fontname() == 'DejaVu Sans' + assert a.get_fontstyle() == 'normal' + assert a.get_fontvariant() == 'normal' + assert a.get_weight() == 'normal' + assert a.get_stretch() == 'normal' bold_font = find_matplotlib_font( family="Foo", @@ -199,7 +200,7 @@ def test_antialiasing(): def test_afm_kerning(): fn = mpl.font_manager.findfont("Helvetica", fontext="afm") with open(fn, 'rb') as fh: - afm = mpl.afm.AFM(fh) + afm = mpl._afm.AFM(fh) assert afm.string_width_height('VAVAVAVAVAVA') == (7174.0, 718) @@ -240,12 +241,27 @@ def test_annotation_contains(): fig, ax = plt.subplots() ann = ax.annotate( "hello", xy=(.4, .4), xytext=(.6, .6), arrowprops={"arrowstyle": "->"}) - fig.canvas.draw() # Needed for the same reason as in test_contains. + fig.canvas.draw() # Needed for the same reason as in test_contains. event = MouseEvent( "button_press_event", fig.canvas, *ax.transData.transform((.5, .6))) assert ann.contains(event) == (False, {}) +@pytest.mark.parametrize('err, xycoords, match', ( + (RuntimeError, print, "Unknown return type"), + (RuntimeError, [0, 0], r"Unknown coordinate type: \[0, 0\]"), + (ValueError, "foo", "'foo' is not a recognized coordinate"), + (ValueError, "foo bar", "'foo bar' is not a recognized coordinate"), + (ValueError, "offset foo", "xycoords cannot be an offset coordinate"), + (ValueError, "axes foo", "'foo' is not a recognized unit"), +)) +def test_annotate_errors(err, xycoords, match): + fig, ax = plt.subplots() + with pytest.raises(err, match=match): + ax.annotate('xy', (0, 0), xytext=(0.5, 0.5), xycoords=xycoords) + fig.canvas.draw() + + @image_comparison(['titles']) def test_titles(): # left and right side titles @@ -323,6 +339,32 @@ def test_set_position(): assert a + shift_val == b +def test_char_index_at(): + fig = plt.figure() + text = fig.text(0.1, 0.9, "") + + text.set_text("i") + bbox = text.get_window_extent() + size_i = bbox.x1 - bbox.x0 + + text.set_text("m") + bbox = text.get_window_extent() + size_m = bbox.x1 - bbox.x0 + + text.set_text("iiiimmmm") + bbox = text.get_window_extent() + origin = bbox.x0 + + assert text._char_index_at(origin - size_i) == 0 # left of first char + assert text._char_index_at(origin) == 0 + assert text._char_index_at(origin + 0.499*size_i) == 0 + assert text._char_index_at(origin + 0.501*size_i) == 1 + assert text._char_index_at(origin + size_i*3) == 3 + assert text._char_index_at(origin + size_i*4 + size_m*3) == 7 + assert text._char_index_at(origin + size_i*4 + size_m*4) == 8 + assert text._char_index_at(origin + size_i*4 + size_m*10) == 8 + + @pytest.mark.parametrize('text', ['', 'O'], ids=['empty', 'non-empty']) def test_non_default_dpi(text): fig, ax = plt.subplots() @@ -340,33 +382,32 @@ def test_non_default_dpi(text): def test_get_rotation_string(): - assert mpl.text.get_rotation('horizontal') == 0. - assert mpl.text.get_rotation('vertical') == 90. - assert mpl.text.get_rotation('15.') == 15. + assert Text(rotation='horizontal').get_rotation() == 0. + assert Text(rotation='vertical').get_rotation() == 90. def test_get_rotation_float(): for i in [15., 16.70, 77.4]: - assert mpl.text.get_rotation(i) == i + assert Text(rotation=i).get_rotation() == i def test_get_rotation_int(): for i in [67, 16, 41]: - assert mpl.text.get_rotation(i) == float(i) + assert Text(rotation=i).get_rotation() == float(i) def test_get_rotation_raises(): with pytest.raises(ValueError): - mpl.text.get_rotation('hozirontal') + Text(rotation='hozirontal') def test_get_rotation_none(): - assert mpl.text.get_rotation(None) == 0.0 + assert Text(rotation=None).get_rotation() == 0.0 def test_get_rotation_mod360(): for i, j in zip([360., 377., 720+177.2], [0., 17., 177.2]): - assert_almost_equal(mpl.text.get_rotation(i), j) + assert_almost_equal(Text(rotation=i).get_rotation(), j) @pytest.mark.parametrize("ha", ["center", "right", "left"]) @@ -504,8 +545,8 @@ def test_two_2line_texts(spacing1, spacing2): fig = plt.figure() renderer = fig.canvas.get_renderer() - text1 = plt.text(0.25, 0.5, text_string, linespacing=spacing1) - text2 = plt.text(0.25, 0.5, text_string, linespacing=spacing2) + text1 = fig.text(0.25, 0.5, text_string, linespacing=spacing1) + text2 = fig.text(0.25, 0.5, text_string, linespacing=spacing2) fig.canvas.draw() box1 = text1.get_window_extent(renderer=renderer) @@ -519,6 +560,11 @@ def test_two_2line_texts(spacing1, spacing2): assert box1.height != box2.height +def test_validate_linespacing(): + with pytest.raises(TypeError): + plt.text(.25, .5, "foo", linespacing="abc") + + def test_nonfinite_pos(): fig, ax = plt.subplots() ax.text(0, np.nan, 'nan') @@ -711,6 +757,14 @@ def test_update_mutate_input(): assert inp['bbox'] == cache['bbox'] +@pytest.mark.parametrize('rotation', ['invalid string', [90]]) +def test_invalid_rotation_values(rotation): + with pytest.raises( + ValueError, + match=("rotation must be 'vertical', 'horizontal' or a number")): + Text(0, 0, 'foo', rotation=rotation) + + def test_invalid_color(): with pytest.raises(ValueError): plt.figtext(.5, .5, "foo", c="foobar") @@ -743,6 +797,20 @@ def test_parse_math(): fig.canvas.draw() +def test_parse_math_rcparams(): + # Default is True + fig, ax = plt.subplots() + ax.text(0, 0, r"$ \wrong{math} $") + with pytest.raises(ValueError, match='Unknown symbol'): + fig.canvas.draw() + + # Setting rcParams to False + with mpl.rc_context({'text.parse_math': False}): + fig, ax = plt.subplots() + ax.text(0, 0, r"$ \wrong{math} $") + fig.canvas.draw() + + @image_comparison(['text_pdf_font42_kerning.pdf'], style='mpl20') def test_pdf_font42_kerning(): plt.rcParams['pdf.fonttype'] = 42 @@ -756,3 +824,34 @@ def test_pdf_chars_beyond_bmp(): plt.rcParams['mathtext.fontset'] = 'stixsans' plt.figure() plt.figtext(0.1, 0.5, "Mass $m$ \U00010308", size=30) + + +@needs_usetex +def test_metrics_cache(): + mpl.text._get_text_metrics_with_cache_impl.cache_clear() + + fig = plt.figure() + fig.text(.3, .5, "foo\nbar") + fig.text(.3, .5, "foo\nbar", usetex=True) + fig.text(.5, .5, "foo\nbar", usetex=True) + fig.canvas.draw() + renderer = fig._get_renderer() + ys = {} # mapping of strings to where they were drawn in y with draw_tex. + + def call(*args, **kwargs): + renderer, x, y, s, *_ = args + ys.setdefault(s, set()).add(y) + + renderer.draw_tex = call + fig.canvas.draw() + assert [*ys] == ["foo", "bar"] + # Check that both TeX strings were drawn with the same y-position for both + # single-line substrings. Previously, there used to be an incorrect cache + # collision with the non-TeX string (drawn first here) whose metrics would + # get incorrectly reused by the first TeX string. + assert len(ys["foo"]) == len(ys["bar"]) == 1 + + info = mpl.text._get_text_metrics_with_cache_impl.cache_info() + # Every string gets a miss for the first layouting (extents), then a hit + # when drawing, but "foo\nbar" gets two hits as it's drawn twice. + assert info.hits > info.misses diff --git a/lib/matplotlib/tests/test_textpath.py b/lib/matplotlib/tests/test_textpath.py new file mode 100644 index 000000000000..e421d2623cad --- /dev/null +++ b/lib/matplotlib/tests/test_textpath.py @@ -0,0 +1,10 @@ +import copy + +from matplotlib.textpath import TextPath + + +def test_copy(): + tp = TextPath((0, 0), ".") + assert copy.deepcopy(tp).vertices is not tp.vertices + assert (copy.deepcopy(tp).vertices == tp.vertices).all() + assert copy.copy(tp).vertices is tp.vertices diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index a66e17b8d0f5..2bea8c999067 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -1,6 +1,7 @@ from contextlib import nullcontext import itertools import locale +import logging import re import numpy as np @@ -207,6 +208,15 @@ def test_basic(self): test_value = np.array([0.5, 1., 2., 4., 8., 16., 32., 64., 128., 256.]) assert_almost_equal(loc.tick_values(1, 100), test_value) + def test_polar_axes(self): + """ + Polar axes have a different ticking logic. + """ + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.set_yscale('log') + ax.set_ylim(1, 100) + assert_array_equal(ax.get_yticks(), [10, 100, 1000]) + def test_switch_to_autolocator(self): loc = mticker.LogLocator(subs="all") assert_array_equal(loc.tick_values(0.45, 0.55), @@ -443,6 +453,112 @@ def test_set_params(self): assert sym.numticks == 8 +class TestAsinhLocator: + def test_init(self): + lctr = mticker.AsinhLocator(linear_width=2.718, numticks=19) + assert lctr.linear_width == 2.718 + assert lctr.numticks == 19 + assert lctr.base == 10 + + def test_set_params(self): + lctr = mticker.AsinhLocator(linear_width=5, + numticks=17, symthresh=0.125, + base=4, subs=(2.5, 3.25)) + assert lctr.numticks == 17 + assert lctr.symthresh == 0.125 + assert lctr.base == 4 + assert lctr.subs == (2.5, 3.25) + + lctr.set_params(numticks=23) + assert lctr.numticks == 23 + lctr.set_params(None) + assert lctr.numticks == 23 + + lctr.set_params(symthresh=0.5) + assert lctr.symthresh == 0.5 + lctr.set_params(symthresh=None) + assert lctr.symthresh == 0.5 + + lctr.set_params(base=7) + assert lctr.base == 7 + lctr.set_params(base=None) + assert lctr.base == 7 + + lctr.set_params(subs=(2, 4.125)) + assert lctr.subs == (2, 4.125) + lctr.set_params(subs=None) + assert lctr.subs == (2, 4.125) + lctr.set_params(subs=[]) + assert lctr.subs is None + + def test_linear_values(self): + lctr = mticker.AsinhLocator(linear_width=100, numticks=11, base=0) + + assert_almost_equal(lctr.tick_values(-1, 1), + np.arange(-1, 1.01, 0.2)) + assert_almost_equal(lctr.tick_values(-0.1, 0.1), + np.arange(-0.1, 0.101, 0.02)) + assert_almost_equal(lctr.tick_values(-0.01, 0.01), + np.arange(-0.01, 0.0101, 0.002)) + + def test_wide_values(self): + lctr = mticker.AsinhLocator(linear_width=0.1, numticks=11, base=0) + + assert_almost_equal(lctr.tick_values(-100, 100), + [-100, -20, -5, -1, -0.2, + 0, 0.2, 1, 5, 20, 100]) + assert_almost_equal(lctr.tick_values(-1000, 1000), + [-1000, -100, -20, -3, -0.4, + 0, 0.4, 3, 20, 100, 1000]) + + def test_near_zero(self): + """Check that manually injected zero will supersede nearby tick""" + lctr = mticker.AsinhLocator(linear_width=100, numticks=3, base=0) + + assert_almost_equal(lctr.tick_values(-1.1, 0.9), [-1.0, 0.0, 0.9]) + + def test_fallback(self): + lctr = mticker.AsinhLocator(1.0, numticks=11) + + assert_almost_equal(lctr.tick_values(101, 102), + np.arange(101, 102.01, 0.1)) + + def test_symmetrizing(self): + class DummyAxis: + bounds = (-1, 1) + @classmethod + def get_view_interval(cls): return cls.bounds + + lctr = mticker.AsinhLocator(linear_width=1, numticks=3, + symthresh=0.25, base=0) + lctr.axis = DummyAxis + + DummyAxis.bounds = (-1, 2) + assert_almost_equal(lctr(), [-1, 0, 2]) + + DummyAxis.bounds = (-1, 0.9) + assert_almost_equal(lctr(), [-1, 0, 1]) + + DummyAxis.bounds = (-0.85, 1.05) + assert_almost_equal(lctr(), [-1, 0, 1]) + + DummyAxis.bounds = (1, 1.1) + assert_almost_equal(lctr(), [1, 1.05, 1.1]) + + def test_base_rounding(self): + lctr10 = mticker.AsinhLocator(linear_width=1, numticks=8, + base=10, subs=(1, 3, 5)) + assert_almost_equal(lctr10.tick_values(-110, 110), + [-500, -300, -100, -50, -30, -10, -5, -3, -1, + -0.5, -0.3, -0.1, 0, 0.1, 0.3, 0.5, + 1, 3, 5, 10, 30, 50, 100, 300, 500]) + + lctr5 = mticker.AsinhLocator(linear_width=1, numticks=20, base=5) + assert_almost_equal(lctr5.tick_values(-1050, 1050), + [-625, -125, -25, -5, -1, -0.2, 0, + 0.2, 1, 5, 25, 125, 625]) + + class TestScalarFormatter: offset_data = [ (123, 189, 0), @@ -479,6 +595,8 @@ class TestScalarFormatter: use_offset_data = [True, False] + useMathText_data = [True, False] + # (sci_type, scilimits, lim, orderOfMag, fewticks) scilimits_data = [ (False, (0, 0), (10.0, 20.0), 0, False), @@ -537,6 +655,19 @@ def test_use_offset(self, use_offset): with mpl.rc_context({'axes.formatter.useoffset': use_offset}): tmp_form = mticker.ScalarFormatter() assert use_offset == tmp_form.get_useOffset() + assert tmp_form.offset == 0 + + @pytest.mark.parametrize('use_math_text', useMathText_data) + def test_useMathText(self, use_math_text): + with mpl.rc_context({'axes.formatter.use_mathtext': use_math_text}): + tmp_form = mticker.ScalarFormatter() + assert use_math_text == tmp_form.get_useMathText() + + def test_set_use_offset_float(self): + tmp_form = mticker.ScalarFormatter() + tmp_form.set_useOffset(0.5) + assert not tmp_form.get_useOffset() + assert tmp_form.offset == 0.5 def test_use_locale(self): conv = locale.localeconv() @@ -589,6 +720,8 @@ def test_cursor_dummy_axis(self, data, expected): sf.axis.set_view_interval(0, 10) fmt = sf.format_data_short assert fmt(data) == expected + assert sf.axis.get_tick_space() == 9 + assert sf.axis.get_minpos() == 0 def test_mathtext_ticks(self): mpl.rcParams.update({ @@ -602,6 +735,29 @@ def test_mathtext_ticks(self): ax.set_xticks([-1, 0, 1]) fig.canvas.draw() + def test_cmr10_substitutions(self, caplog): + mpl.rcParams.update({ + 'font.family': 'cmr10', + 'mathtext.fontset': 'cm', + 'axes.formatter.use_mathtext': True, + }) + + # Test that it does not log a warning about missing glyphs. + with caplog.at_level(logging.WARNING, logger='matplotlib.mathtext'): + fig, ax = plt.subplots() + ax.plot([-0.03, 0.05], [40, 0.05]) + ax.set_yscale('log') + yticks = [0.02, 0.3, 4, 50] + formatter = mticker.LogFormatterSciNotation() + ax.set_yticks(yticks, map(formatter, yticks)) + fig.canvas.draw() + assert not caplog.text + + def test_empty_locs(self): + sf = mticker.ScalarFormatter() + sf.set_locs([]) + assert sf(0.5) == '' + class FakeAxis: """Allow Formatter to be called without having a "full" plot set up.""" @@ -637,6 +793,7 @@ def test_basic(self, labelOnlyBase, base, exponent, locs, positions, formatter.axis = FakeAxis(1, base**exponent) vals = base**locs labels = [formatter(x, pos) for (x, pos) in zip(vals, positions)] + expected = [label.replace('-', '\N{Minus Sign}') for label in expected] assert labels == expected def test_blank(self): @@ -1180,7 +1337,7 @@ def test_params(self, unicode_minus, input, expected): assert _formatter(input) == _exp_output # Test several non default separators: no separator, a narrow - # no-break space (unicode character) and an extravagant string. + # no-break space (Unicode character) and an extravagant string. for _sep in ("", "\N{NARROW NO-BREAK SPACE}", "@_@"): # Case 2: unit=UNIT and sep=_sep. # Replace the default space separator from the reference case @@ -1377,7 +1534,7 @@ def test_remove_overlap(remove_overlapping_locs, expected_num): def test_bad_locator_subs(sub): ll = mticker.LogLocator() with pytest.raises(ValueError): - ll.subs(sub) + ll.set_params(subs=sub) @pytest.mark.parametrize('numticks', [1, 2, 3, 9]) @@ -1388,3 +1545,19 @@ def test_small_range_loglocator(numticks): for top in [5, 7, 9, 11, 15, 50, 100, 1000]: ticks = ll.tick_values(.5, top) assert (np.diff(np.log10(ll.tick_values(6, 150))) == 1).all() + + +def test_NullFormatter(): + formatter = mticker.NullFormatter() + assert formatter(1.0) == '' + assert formatter.format_data(1.0) == '' + assert formatter.format_data_short(1.0) == '' + + +@pytest.mark.parametrize('formatter', ( + mticker.FuncFormatter(lambda a: f'val: {a}'), + mticker.FixedFormatter(('foo', 'bar')))) +def test_set_offset_string(formatter): + assert formatter.get_offset() == '' + formatter.set_offset_string('mpl') + assert formatter.get_offset() == 'mpl' diff --git a/lib/matplotlib/tests/test_tightlayout.py b/lib/matplotlib/tests/test_tightlayout.py index e9b01b160da1..968f0da7b514 100644 --- a/lib/matplotlib/tests/test_tightlayout.py +++ b/lib/matplotlib/tests/test_tightlayout.py @@ -134,9 +134,10 @@ def test_tight_layout7(): def test_tight_layout8(): """Test automatic use of tight_layout.""" fig = plt.figure() - fig.set_tight_layout({'pad': .1}) + fig.set_layout_engine(layout='tight', pad=0.1) ax = fig.add_subplot() example_plot(ax, fontsize=24) + fig.draw_without_rendering() @image_comparison(['tight_layout9']) @@ -342,3 +343,51 @@ def test_manual_colorbar(): fig.colorbar(pts, cax=cax) with pytest.warns(UserWarning, match="This figure includes Axes"): fig.tight_layout() + + +def test_clipped_to_axes(): + # Ensure that _fully_clipped_to_axes() returns True under default + # conditions for all projection types. Axes.get_tightbbox() + # uses this to skip artists in layout calculations. + arr = np.arange(100).reshape((10, 10)) + fig = plt.figure(figsize=(6, 2)) + ax1 = fig.add_subplot(131, projection='rectilinear') + ax2 = fig.add_subplot(132, projection='mollweide') + ax3 = fig.add_subplot(133, projection='polar') + for ax in (ax1, ax2, ax3): + # Default conditions (clipped by ax.bbox or ax.patch) + ax.grid(False) + h, = ax.plot(arr[:, 0]) + m = ax.pcolor(arr) + assert h._fully_clipped_to_axes() + assert m._fully_clipped_to_axes() + # Non-default conditions (not clipped by ax.patch) + rect = Rectangle((0, 0), 0.5, 0.5, transform=ax.transAxes) + h.set_clip_path(rect) + m.set_clip_path(rect.get_path(), rect.get_transform()) + assert not h._fully_clipped_to_axes() + assert not m._fully_clipped_to_axes() + + +def test_tight_pads(): + fig, ax = plt.subplots() + with pytest.warns(PendingDeprecationWarning, + match='will be deprecated'): + fig.set_tight_layout({'pad': 0.15}) + fig.draw_without_rendering() + + +def test_tight_kwargs(): + fig, ax = plt.subplots(tight_layout={'pad': 0.15}) + fig.draw_without_rendering() + + +def test_tight_toggle(): + fig, ax = plt.subplots() + with pytest.warns(PendingDeprecationWarning): + fig.set_tight_layout(True) + assert fig.get_tight_layout() + fig.set_tight_layout(False) + assert not fig.get_tight_layout() + fig.set_tight_layout(True) + assert fig.get_tight_layout() diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 716ef2117c02..064a7240c39d 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -10,7 +10,7 @@ import matplotlib.patches as mpatches import matplotlib.transforms as mtransforms from matplotlib.path import Path -from matplotlib.testing.decorators import image_comparison +from matplotlib.testing.decorators import image_comparison, check_figures_equal def test_non_affine_caching(): @@ -191,8 +191,7 @@ def test_affine_inverted_invalidated(): def test_clipping_of_log(): # issue 804 - path = Path([(0.2, -99), (0.4, -99), (0.4, 20), (0.2, 20), (0.2, -99)], - closed=True) + path = Path._create_closed([(0.2, -99), (0.4, -99), (0.4, 20), (0.2, 20)]) # something like this happens in plotting logarithmic histograms trans = mtransforms.BlendedGenericTransform( mtransforms.Affine2D(), scale.LogTransform(10, 'clip')) @@ -423,7 +422,7 @@ def test_pathc_extents_non_affine(self): ax = plt.axes() offset = mtransforms.Affine2D().translate(10, 10) na_offset = NonAffineForTest(mtransforms.Affine2D().translate(10, 10)) - pth = Path(np.array([[0, 0], [0, 10], [10, 10], [10, 0]])) + pth = Path([[0, 0], [0, 10], [10, 10], [10, 0]]) patch = mpatches.PathPatch(pth, transform=offset + na_offset + ax.transData) ax.add_patch(patch) @@ -433,7 +432,7 @@ def test_pathc_extents_non_affine(self): def test_pathc_extents_affine(self): ax = plt.axes() offset = mtransforms.Affine2D().translate(10, 10) - pth = Path(np.array([[0, 0], [0, 10], [10, 10], [10, 0]])) + pth = Path([[0, 0], [0, 10], [10, 10], [10, 0]]) patch = mpatches.PathPatch(pth, transform=offset + ax.transData) ax.add_patch(patch) expected_data_lim = np.array([[0., 0.], [10., 10.]]) + 10 @@ -511,7 +510,7 @@ def test_str_transform(): Affine2D().scale(1.0), Affine2D().scale(1.0))), PolarTransform( - PolarAxesSubplot(0.125,0.1;0.775x0.8), + PolarAxes(0.125,0.1;0.775x0.8), use_rmin=True, _apply_theta_transforms=False)), CompositeGenericTransform( @@ -723,3 +722,25 @@ def test_deepcopy(): b1.translate(3, 4) assert not s._invalid assert (s.get_matrix() == a.get_matrix()).all() + + +def test_transformwrapper(): + t = mtransforms.TransformWrapper(mtransforms.Affine2D()) + with pytest.raises(ValueError, match=( + r"The input and output dims of the new child \(1, 1\) " + r"do not match those of current child \(2, 2\)")): + t.set(scale.LogTransform(10)) + + +@check_figures_equal(extensions=["png"]) +def test_scale_swapping(fig_test, fig_ref): + np.random.seed(19680801) + samples = np.random.normal(size=10) + x = np.linspace(-5, 5, 10) + + for fig, log_state in zip([fig_test, fig_ref], [True, False]): + ax = fig.subplots() + ax.hist(samples, log=log_state, density=True) + ax.plot(x, np.exp(-(x**2) / 2) / np.sqrt(2 * np.pi)) + fig.canvas.draw() + ax.set_yscale('linear') diff --git a/lib/matplotlib/tests/test_triangulation.py b/lib/matplotlib/tests/test_triangulation.py index 600189163267..c292d82812d3 100644 --- a/lib/matplotlib/tests/test_triangulation.py +++ b/lib/matplotlib/tests/test_triangulation.py @@ -5,11 +5,94 @@ import pytest import matplotlib as mpl -import matplotlib.cm as cm import matplotlib.pyplot as plt import matplotlib.tri as mtri from matplotlib.path import Path -from matplotlib.testing.decorators import image_comparison +from matplotlib.testing.decorators import image_comparison, check_figures_equal + + +class TestTriangulationParams: + x = [-1, 0, 1, 0] + y = [0, -1, 0, 1] + triangles = [[0, 1, 2], [0, 2, 3]] + mask = [False, True] + + @pytest.mark.parametrize('args, kwargs, expected', [ + ([x, y], {}, [x, y, None, None]), + ([x, y, triangles], {}, [x, y, triangles, None]), + ([x, y], dict(triangles=triangles), [x, y, triangles, None]), + ([x, y], dict(mask=mask), [x, y, None, mask]), + ([x, y, triangles], dict(mask=mask), [x, y, triangles, mask]), + ([x, y], dict(triangles=triangles, mask=mask), [x, y, triangles, mask]) + ]) + def test_extract_triangulation_params(self, args, kwargs, expected): + other_args = [1, 2] + other_kwargs = {'a': 3, 'b': '4'} + x_, y_, triangles_, mask_, args_, kwargs_ = \ + mtri.Triangulation._extract_triangulation_params( + args + other_args, {**kwargs, **other_kwargs}) + x, y, triangles, mask = expected + assert x_ is x + assert y_ is y + assert_array_equal(triangles_, triangles) + assert mask_ is mask + assert args_ == other_args + assert kwargs_ == other_kwargs + + +def test_extract_triangulation_positional_mask(): + # mask cannot be passed positionally + mask = [True] + args = [[0, 2, 1], [0, 0, 1], [[0, 1, 2]], mask] + x_, y_, triangles_, mask_, args_, kwargs_ = \ + mtri.Triangulation._extract_triangulation_params(args, {}) + assert mask_ is None + assert args_ == [mask] + # the positional mask must be caught downstream because this must pass + # unknown args through + + +def test_triangulation_init(): + x = [-1, 0, 1, 0] + y = [0, -1, 0, 1] + with pytest.raises(ValueError, match="x and y must be equal-length"): + mtri.Triangulation(x, [1, 2]) + with pytest.raises( + ValueError, + match=r"triangles must be a \(N, 3\) int array, but found shape " + r"\(3,\)"): + mtri.Triangulation(x, y, [0, 1, 2]) + with pytest.raises( + ValueError, + match=r"triangles must be a \(N, 3\) int array, not 'other'"): + mtri.Triangulation(x, y, 'other') + with pytest.raises(ValueError, match="found value 99"): + mtri.Triangulation(x, y, [[0, 1, 99]]) + with pytest.raises(ValueError, match="found value -1"): + mtri.Triangulation(x, y, [[0, 1, -1]]) + + +def test_triangulation_set_mask(): + x = [-1, 0, 1, 0] + y = [0, -1, 0, 1] + triangles = [[0, 1, 2], [2, 3, 0]] + triang = mtri.Triangulation(x, y, triangles) + + # Check neighbors, which forces creation of C++ triangulation + assert_array_equal(triang.neighbors, [[-1, -1, 1], [-1, -1, 0]]) + + # Set mask + triang.set_mask([False, True]) + assert_array_equal(triang.mask, [False, True]) + + # Reset mask + triang.set_mask(None) + assert triang.mask is None + + msg = r"mask array must have same length as triangles array" + for mask in ([False, True, False], [False], [True], False, True): + with pytest.raises(ValueError, match=msg): + triang.set_mask(mask) def test_delaunay(): @@ -177,6 +260,59 @@ def test_tripcolor(): plt.title('facecolors') +def test_tripcolor_color(): + x = [-1, 0, 1, 0] + y = [0, -1, 0, 1] + fig, ax = plt.subplots() + with pytest.raises(TypeError, match=r"tripcolor\(\) missing 1 required "): + ax.tripcolor(x, y) + with pytest.raises(ValueError, match="The length of c must match either"): + ax.tripcolor(x, y, [1, 2, 3]) + with pytest.raises(ValueError, + match="length of facecolors must match .* triangles"): + ax.tripcolor(x, y, facecolors=[1, 2, 3, 4]) + with pytest.raises(ValueError, + match="'gouraud' .* at the points.* not at the faces"): + ax.tripcolor(x, y, facecolors=[1, 2], shading='gouraud') + with pytest.raises(ValueError, + match="'gouraud' .* at the points.* not at the faces"): + ax.tripcolor(x, y, [1, 2], shading='gouraud') # faces + with pytest.raises(TypeError, + match="positional.*'c'.*keyword-only.*'facecolors'"): + ax.tripcolor(x, y, C=[1, 2, 3, 4]) + + # smoke test for valid color specifications (via C or facecolors) + ax.tripcolor(x, y, [1, 2, 3, 4]) # edges + ax.tripcolor(x, y, [1, 2, 3, 4], shading='gouraud') # edges + ax.tripcolor(x, y, [1, 2]) # faces + ax.tripcolor(x, y, facecolors=[1, 2]) # faces + + +def test_tripcolor_clim(): + np.random.seed(19680801) + a, b, c = np.random.rand(10), np.random.rand(10), np.random.rand(10) + + ax = plt.figure().add_subplot() + clim = (0.25, 0.75) + norm = ax.tripcolor(a, b, c, clim=clim).norm + assert (norm.vmin, norm.vmax) == clim + + +def test_tripcolor_warnings(): + x = [-1, 0, 1, 0] + y = [0, -1, 0, 1] + c = [0.4, 0.5] + fig, ax = plt.subplots() + # additional parameters + with pytest.warns(DeprecationWarning, match="Additional positional param"): + ax.tripcolor(x, y, c, 'unused_positional') + # facecolors takes precedence over c + with pytest.warns(UserWarning, match="Positional parameter c .*no effect"): + ax.tripcolor(x, y, c, facecolors=c) + with pytest.warns(UserWarning, match="Positional parameter c .*no effect"): + ax.tripcolor(x, y, 'interpreted as c', facecolors=c) + + def test_no_modify(): # Test that Triangulation does not modify triangles array passed to it. triangles = np.array([[3, 2, 0], [3, 1, 0]], dtype=np.int32) @@ -501,15 +637,15 @@ def poisson_sparse_matrix(n, m): # Instantiating a sparse Poisson matrix of size 48 x 48: (n, m) = (12, 4) - mat = mtri.triinterpolate._Sparse_Matrix_coo(*poisson_sparse_matrix(n, m)) + mat = mtri._triinterpolate._Sparse_Matrix_coo(*poisson_sparse_matrix(n, m)) mat.compress_csc() mat_dense = mat.to_dense() # Testing a sparse solve for all 48 basis vector for itest in range(n*m): b = np.zeros(n*m, dtype=np.float64) b[itest] = 1. - x, _ = mtri.triinterpolate._cg(A=mat, b=b, x0=np.zeros(n*m), - tol=1.e-10) + x, _ = mtri._triinterpolate._cg(A=mat, b=b, x0=np.zeros(n*m), + tol=1.e-10) assert_array_almost_equal(np.dot(mat_dense, x), b) # 2) Same matrix with inserting 2 rows - cols with null diag terms @@ -522,16 +658,16 @@ def poisson_sparse_matrix(n, m): rows = np.concatenate([rows, [i_zero, i_zero-1, j_zero, j_zero-1]]) cols = np.concatenate([cols, [i_zero-1, i_zero, j_zero-1, j_zero]]) vals = np.concatenate([vals, [1., 1., 1., 1.]]) - mat = mtri.triinterpolate._Sparse_Matrix_coo(vals, rows, cols, - (n*m + 2, n*m + 2)) + mat = mtri._triinterpolate._Sparse_Matrix_coo(vals, rows, cols, + (n*m + 2, n*m + 2)) mat.compress_csc() mat_dense = mat.to_dense() # Testing a sparse solve for all 50 basis vec for itest in range(n*m + 2): b = np.zeros(n*m + 2, dtype=np.float64) b[itest] = 1. - x, _ = mtri.triinterpolate._cg(A=mat, b=b, x0=np.ones(n*m + 2), - tol=1.e-10) + x, _ = mtri._triinterpolate._cg(A=mat, b=b, x0=np.ones(n * m + 2), + tol=1.e-10) assert_array_almost_equal(np.dot(mat_dense, x), b) # 3) Now a simple test that summation of duplicate (i.e. with same rows, @@ -542,7 +678,7 @@ def poisson_sparse_matrix(n, m): cols = np.array([0, 1, 2, 1, 1, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2], dtype=np.int32) dim = (3, 3) - mat = mtri.triinterpolate._Sparse_Matrix_coo(vals, rows, cols, dim) + mat = mtri._triinterpolate._Sparse_Matrix_coo(vals, rows, cols, dim) mat.compress_csc() mat_dense = mat.to_dense() assert_array_almost_equal(mat_dense, np.array([ @@ -565,7 +701,7 @@ def test_triinterpcubic_geom_weights(): y_rot = -np.sin(theta)*x + np.cos(theta)*y triang = mtri.Triangulation(x_rot, y_rot, triangles) cubic_geom = mtri.CubicTriInterpolator(triang, z, kind='geom') - dof_estimator = mtri.triinterpolate._DOF_estimator_geom(cubic_geom) + dof_estimator = mtri._triinterpolate._DOF_estimator_geom(cubic_geom) weights = dof_estimator.compute_geom_weights() # Testing for the 4 possibilities... sum_w[0, :] = np.sum(weights, 1) - 1 @@ -811,7 +947,7 @@ def dipole_potential(x, y): plt.triplot(triang, color='0.8') levels = np.arange(0., 1., 0.01) - cmap = cm.get_cmap(name='hot', lut=None) + cmap = mpl.colormaps['hot'] plt.tricontour(tri_refi, z_test_refi, levels=levels, cmap=cmap, linewidths=[2.0, 1.0, 1.0, 1.0]) # Plots direction of the electrical vector field @@ -831,8 +967,7 @@ def test_tritools(): mask = np.array([False, False, True], dtype=bool) triang = mtri.Triangulation(x, y, triangles, mask=mask) analyser = mtri.TriAnalyzer(triang) - assert_array_almost_equal(analyser.scale_factors, - np.array([1., 1./(1.+0.5*np.sqrt(3.))])) + assert_array_almost_equal(analyser.scale_factors, [1, 1/(1+3**.5/2)]) assert_array_almost_equal( analyser.circle_ratios(rescale=False), np.ma.masked_array([0.5, 1./(1.+np.sqrt(2.)), np.nan], mask)) @@ -1054,55 +1189,62 @@ def test_internal_cpp_api(): # C++ Triangulation. with pytest.raises( TypeError, - match=r'function takes exactly 7 arguments \(0 given\)'): + match=r'__init__\(\): incompatible constructor arguments.'): mpl._tri.Triangulation() with pytest.raises( ValueError, match=r'x and y must be 1D arrays of the same length'): - mpl._tri.Triangulation([], [1], [[]], None, None, None, False) + mpl._tri.Triangulation([], [1], [[]], (), (), (), False) x = [0, 1, 1] y = [0, 0, 1] with pytest.raises( ValueError, match=r'triangles must be a 2D array of shape \(\?,3\)'): - mpl._tri.Triangulation(x, y, [[0, 1]], None, None, None, False) + mpl._tri.Triangulation(x, y, [[0, 1]], (), (), (), False) tris = [[0, 1, 2]] with pytest.raises( ValueError, match=r'mask must be a 1D array with the same length as the ' r'triangles array'): - mpl._tri.Triangulation(x, y, tris, [0, 1], None, None, False) + mpl._tri.Triangulation(x, y, tris, [0, 1], (), (), False) with pytest.raises( ValueError, match=r'edges must be a 2D array with shape \(\?,2\)'): - mpl._tri.Triangulation(x, y, tris, None, [[1]], None, False) + mpl._tri.Triangulation(x, y, tris, (), [[1]], (), False) with pytest.raises( ValueError, match=r'neighbors must be a 2D array with the same shape as the ' r'triangles array'): - mpl._tri.Triangulation(x, y, tris, None, None, [[-1]], False) + mpl._tri.Triangulation(x, y, tris, (), (), [[-1]], False) - triang = mpl._tri.Triangulation(x, y, tris, None, None, None, False) + triang = mpl._tri.Triangulation(x, y, tris, (), (), (), False) with pytest.raises( ValueError, - match=r'z array must have same length as triangulation x and y ' - r'array'): + match=r'z must be a 1D array with the same length as the ' + r'triangulation x and y arrays'): triang.calculate_plane_coefficients([]) - with pytest.raises( - ValueError, - match=r'mask must be a 1D array with the same length as the ' - r'triangles array'): - triang.set_mask([0, 1]) + for mask in ([0, 1], None): + with pytest.raises( + ValueError, + match=r'mask must be a 1D array with the same length as the ' + r'triangles array'): + triang.set_mask(mask) + + triang.set_mask([True]) + assert_array_equal(triang.get_edges(), np.empty((0, 2))) + + triang.set_mask(()) # Equivalent to Python Triangulation mask=None + assert_array_equal(triang.get_edges(), [[1, 0], [2, 0], [2, 1]]) # C++ TriContourGenerator. with pytest.raises( TypeError, - match=r'function takes exactly 2 arguments \(0 given\)'): + match=r'__init__\(\): incompatible constructor arguments.'): mpl._tri.TriContourGenerator() with pytest.raises( @@ -1120,7 +1262,8 @@ def test_internal_cpp_api(): # C++ TrapezoidMapTriFinder. with pytest.raises( - TypeError, match=r'function takes exactly 1 argument \(0 given\)'): + TypeError, + match=r'__init__\(\): incompatible constructor arguments.'): mpl._tri.TrapezoidMapTriFinder() trifinder = mpl._tri.TrapezoidMapTriFinder(triang) @@ -1177,3 +1320,24 @@ def test_tricontourset_reuse(): assert tcs2._contour_generator != tcs1._contour_generator tcs3 = ax.tricontour(tcs1, z) assert tcs3._contour_generator == tcs1._contour_generator + + +@check_figures_equal() +def test_triplot_with_ls(fig_test, fig_ref): + x = [0, 2, 1] + y = [0, 0, 1] + data = [[0, 1, 2]] + fig_test.subplots().triplot(x, y, data, ls='--') + fig_ref.subplots().triplot(x, y, data, linestyle='--') + + +def test_triplot_label(): + x = [0, 2, 1] + y = [0, 0, 1] + data = [[0, 1, 2]] + fig, ax = plt.subplots() + lines, markers = ax.triplot(x, y, data, label='label') + handles, labels = ax.get_legend_handles_labels() + assert labels == ['label'] + assert len(handles) == 1 + assert handles[0] is lines diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index 99cc3e500b0e..1e173d5ea84d 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -1,6 +1,7 @@ -import matplotlib.type1font as t1f +import matplotlib._type1font as t1f import os.path import difflib +import pytest def test_Type1Font(): @@ -13,10 +14,35 @@ def test_Type1Font(): assert font.parts[0] == rawdata[0x0006:0x10c5] assert font.parts[1] == rawdata[0x10cb:0x897f] assert font.parts[2] == rawdata[0x8985:0x8ba6] - assert font.parts[1:] == slanted.parts[1:] - assert font.parts[1:] == condensed.parts[1:] assert font.decrypted.startswith(b'dup\n/Private 18 dict dup begin') assert font.decrypted.endswith(b'mark currentfile closefile\n') + assert slanted.decrypted.startswith(b'dup\n/Private 18 dict dup begin') + assert slanted.decrypted.endswith(b'mark currentfile closefile\n') + assert b'UniqueID 5000793' in font.parts[0] + assert b'UniqueID 5000793' in font.decrypted + assert font._pos['UniqueID'] == [(797, 818), (4483, 4504)] + + len0 = len(font.parts[0]) + for key in font._pos.keys(): + for pos0, pos1 in font._pos[key]: + if pos0 < len0: + data = font.parts[0][pos0:pos1] + else: + data = font.decrypted[pos0-len0:pos1-len0] + assert data.startswith(f'/{key}'.encode('ascii')) + assert {'FontType', 'FontMatrix', 'PaintType', 'ItalicAngle', 'RD' + } < set(font._pos.keys()) + + assert b'UniqueID 5000793' not in slanted.parts[0] + assert b'UniqueID 5000793' not in slanted.decrypted + assert 'UniqueID' not in slanted._pos + assert font.prop['Weight'] == 'Medium' + assert not font.prop['isFixedPitch'] + assert font.prop['ItalicAngle'] == 0 + assert slanted.prop['ItalicAngle'] == -45 + assert font.prop['Encoding'][5] == 'Pi' + assert isinstance(font.prop['CharStrings']['Pi'], bytes) + assert font._abbr['ND'] == 'ND' differ = difflib.Differ() diff = list(differ.compare( @@ -24,14 +50,13 @@ def test_Type1Font(): slanted.parts[0].decode('latin-1').splitlines())) for line in ( # Removes UniqueID - '- FontDirectory/CMR10 known{/CMR10 findfont dup/UniqueID known{dup', - '+ FontDirectory/CMR10 known{/CMR10 findfont dup', + '- /UniqueID 5000793 def', # Changes the font name '- /FontName /CMR10 def', - '+ /FontName /CMR10_Slant_1000 def', + '+ /FontName/CMR10_Slant_1000 def', # Alters FontMatrix '- /FontMatrix [0.001 0 0 0.001 0 0 ]readonly def', - '+ /FontMatrix [0.001 0 0.001 0.001 0 0]readonly def', + '+ /FontMatrix [0.001 0 0.001 0.001 0 0] readonly def', # Alters ItalicAngle '- /ItalicAngle 0 def', '+ /ItalicAngle -45.0 def'): @@ -42,17 +67,73 @@ def test_Type1Font(): condensed.parts[0].decode('latin-1').splitlines())) for line in ( # Removes UniqueID - '- FontDirectory/CMR10 known{/CMR10 findfont dup/UniqueID known{dup', - '+ FontDirectory/CMR10 known{/CMR10 findfont dup', + '- /UniqueID 5000793 def', # Changes the font name '- /FontName /CMR10 def', - '+ /FontName /CMR10_Extend_500 def', + '+ /FontName/CMR10_Extend_500 def', # Alters FontMatrix '- /FontMatrix [0.001 0 0 0.001 0 0 ]readonly def', - '+ /FontMatrix [0.0005 0 0 0.001 0 0]readonly def'): + '+ /FontMatrix [0.0005 0 0 0.001 0 0] readonly def'): assert line in diff, 'diff to condensed font must contain %s' % line +def test_Type1Font_2(): + filename = os.path.join(os.path.dirname(__file__), + 'Courier10PitchBT-Bold.pfb') + font = t1f.Type1Font(filename) + assert font.prop['Weight'] == 'Bold' + assert font.prop['isFixedPitch'] + assert font.prop['Encoding'][65] == 'A' # the font uses StandardEncoding + (pos0, pos1), = font._pos['Encoding'] + assert font.parts[0][pos0:pos1] == b'/Encoding StandardEncoding' + assert font._abbr['ND'] == '|-' + + +def test_tokenize(): + data = (b'1234/abc false -9.81 Foo <<[0 1 2]<0 1ef a\t>>>\n' + b'(string with(nested\t\\) par)ens\\\\)') + # 1 2 x 2 xx1 + # 1 and 2 are matching parens, x means escaped character + n, w, num, kw, d = 'name', 'whitespace', 'number', 'keyword', 'delimiter' + b, s = 'boolean', 'string' + correct = [ + (num, 1234), (n, 'abc'), (w, ' '), (b, False), (w, ' '), (num, -9.81), + (w, ' '), (kw, 'Foo'), (w, ' '), (d, '<<'), (d, '['), (num, 0), + (w, ' '), (num, 1), (w, ' '), (num, 2), (d, ']'), (s, b'\x01\xef\xa0'), + (d, '>>'), (w, '\n'), (s, 'string with(nested\t) par)ens\\') + ] + correct_no_ws = [x for x in correct if x[0] != w] + + def convert(tokens): + return [(t.kind, t.value()) for t in tokens] + + assert convert(t1f._tokenize(data, False)) == correct + assert convert(t1f._tokenize(data, True)) == correct_no_ws + + def bin_after(n): + tokens = t1f._tokenize(data, True) + result = [] + for _ in range(n): + result.append(next(tokens)) + result.append(tokens.send(10)) + return convert(result) + + for n in range(1, len(correct_no_ws)): + result = bin_after(n) + assert result[:-1] == correct_no_ws[:n] + assert result[-1][0] == 'binary' + assert isinstance(result[-1][1], bytes) + + +def test_tokenize_errors(): + with pytest.raises(ValueError): + list(t1f._tokenize(b'1234 (this (string) is unterminated\\)', True)) + with pytest.raises(ValueError): + list(t1f._tokenize(b'/Foo<01234', True)) + with pytest.raises(ValueError): + list(t1f._tokenize(b'/Foo<01234abcg>/Bar', True)) + + def test_overprecision(): # We used to output too many digits in FontMatrix entries and # ItalicAngle, which could make Type-1 parsers unhappy. diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py index a6f6b44c9707..85a63ecc2f10 100644 --- a/lib/matplotlib/tests/test_units.py +++ b/lib/matplotlib/tests/test_units.py @@ -26,6 +26,9 @@ def to(self, new_units): else: return Quantity(self.magnitude, self.units) + def __copy__(self): + return Quantity(self.magnitude, self.units) + def __getattr__(self, attr): return getattr(self.magnitude, attr) @@ -75,7 +78,7 @@ def default_units(value, axis): # Tests that the conversion machinery works properly for classes that # work as a facade over numpy arrays (like pint) -@image_comparison(['plot_pint.png'], remove_text=False, style='mpl20', +@image_comparison(['plot_pint.png'], style='mpl20', tol=0 if platform.machine() == 'x86_64' else 0.01) def test_numpy_facade(quantity_converter): # use former defaults to match existing baseline image @@ -261,3 +264,38 @@ def test_empty_default_limits(quantity_converter): fig.draw_without_rendering() assert ax.get_ylim() == (0, 100) assert ax.get_xlim() == (28.5, 31.5) + + +# test array-like objects... +class Kernel: + def __init__(self, array): + self._array = np.asanyarray(array) + + def __array__(self): + return self._array + + @property + def shape(self): + return self._array.shape + + +def test_plot_kernel(): + # just a smoketest that fail + kernel = Kernel([1, 2, 3, 4, 5]) + plt.plot(kernel) + + +@pytest.mark.parametrize('plot_meth_name', ['scatter', 'plot']) +def test_unit_axis_label(plot_meth_name): + # Check that the correct Axis labels are set on plots with units + import matplotlib.testing.jpl_units as units + units.register() + + fig, ax = plt.subplots() + ax.xaxis.set_units('m') + ax.yaxis.set_units('sec') + plot_method = getattr(ax, plot_meth_name) + plot_method(np.arange(3) * units.m, np.arange(3) * units.sec) + assert ax.get_xlabel() == 'm' + assert ax.get_ylabel() == 'sec' + plt.close('all') diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py index 12557cf847f3..0f01ebaffb56 100644 --- a/lib/matplotlib/tests/test_usetex.py +++ b/lib/matplotlib/tests/test_usetex.py @@ -1,14 +1,17 @@ +from tempfile import TemporaryFile + import numpy as np import pytest import matplotlib as mpl +from matplotlib import dviread from matplotlib.testing import _has_tex_package from matplotlib.testing.decorators import check_figures_equal, image_comparison +from matplotlib.testing._markers import needs_usetex import matplotlib.pyplot as plt -if not mpl.checkdep_usetex(True): - pytestmark = pytest.mark.skip('Missing TeX of Ghostscript or dvipng') +pytestmark = needs_usetex @image_comparison( @@ -61,6 +64,21 @@ def test_mathdefault(): fig.canvas.draw() +@image_comparison(['eqnarray.png']) +def test_multiline_eqnarray(): + text = ( + r'\begin{eqnarray*}' + r'foo\\' + r'bar\\' + r'baz\\' + r'\end{eqnarray*}' + ) + + fig = plt.figure(figsize=(1, 1)) + fig.text(0.5, 0.5, text, usetex=True, + horizontalalignment='center', verticalalignment='center') + + @pytest.mark.parametrize("fontsize", [8, 10, 12]) def test_minus_no_descent(fontsize): # Test special-casing of minus descent in DviFont._height_depth_of, by @@ -119,3 +137,19 @@ def test_usetex_with_underscore(): ax.legend() ax.text(0, 0, "foo_bar", usetex=True) plt.draw() + + +@pytest.mark.flaky(reruns=3) # Tends to hit a TeX cache lock on AppVeyor. +@pytest.mark.parametrize("fmt", ["pdf", "svg"]) +def test_missing_psfont(fmt, monkeypatch): + """An error is raised if a TeX font lacks a Type-1 equivalent""" + monkeypatch.setattr( + dviread.PsfontsMap, '__getitem__', + lambda self, k: dviread.PsFont( + texname=b'texfont', psname=b'Some Font', + effects=None, encoding=None, filename=None)) + mpl.rcParams['text.usetex'] = True + fig, ax = plt.subplots() + ax.text(0.5, 0.5, 'hello') + with TemporaryFile() as tmpfile, pytest.raises(ValueError): + fig.savefig(tmpfile, format=fmt) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 1fbde1ed64d1..ce942964a313 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -1,24 +1,76 @@ +import functools +import io +from unittest import mock + from matplotlib._api.deprecation import MatplotlibDeprecationWarning +from matplotlib.backend_bases import MouseEvent import matplotlib.colors as mcolors import matplotlib.widgets as widgets import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle +from matplotlib.lines import Line2D from matplotlib.testing.decorators import check_figures_equal, image_comparison -from matplotlib.testing.widgets import do_event, get_ax, mock_event +from matplotlib.testing.widgets import (click_and_drag, do_event, get_ax, + mock_event, noop) +import numpy as np from numpy.testing import assert_allclose import pytest -def check_rectangle(**kwargs): - ax = get_ax() +@pytest.fixture +def ax(): + return get_ax() - def onselect(epress, erelease): - ax._got_onselect = True - assert epress.xdata == 100 - assert epress.ydata == 100 - assert erelease.xdata == 199 - assert erelease.ydata == 199 + +def test_save_blitted_widget_as_pdf(): + from matplotlib.widgets import CheckButtons, RadioButtons + from matplotlib.cbook import _get_running_interactive_framework + if _get_running_interactive_framework() not in ['headless', None]: + pytest.xfail("Callback exceptions are not raised otherwise.") + + fig, ax = plt.subplots( + nrows=2, ncols=2, figsize=(5, 2), width_ratios=[1, 2] + ) + default_rb = RadioButtons(ax[0, 0], ['Apples', 'Oranges']) + styled_rb = RadioButtons( + ax[0, 1], ['Apples', 'Oranges'], + label_props={'color': ['red', 'orange'], + 'fontsize': [16, 20]}, + radio_props={'edgecolor': ['red', 'orange'], + 'facecolor': ['mistyrose', 'peachpuff']} + ) + + default_cb = CheckButtons(ax[1, 0], ['Apples', 'Oranges'], + actives=[True, True]) + styled_cb = CheckButtons( + ax[1, 1], ['Apples', 'Oranges'], + actives=[True, True], + label_props={'color': ['red', 'orange'], + 'fontsize': [16, 20]}, + frame_props={'edgecolor': ['red', 'orange'], + 'facecolor': ['mistyrose', 'peachpuff']}, + check_props={'color': ['darkred', 'darkorange']} + ) + + ax[0, 0].set_title('Default') + ax[0, 1].set_title('Stylized') + # force an Agg render + fig.canvas.draw() + # force a pdf save + with io.BytesIO() as result_after: + fig.savefig(result_after, format='pdf') + + +@pytest.mark.parametrize('kwargs', [ + dict(), + dict(useblit=True, button=1), + dict(minspanx=10, minspany=10, spancoords='pixels'), + dict(props=dict(fill=True)), +]) +def test_rectangle_selector(ax, kwargs): + onselect = mock.Mock(spec=noop, return_value=None) tool = widgets.RectangleSelector(ax, onselect, **kwargs) do_event(tool, 'press', xdata=100, ydata=100, button=1) @@ -33,56 +85,73 @@ def onselect(epress, erelease): [100, 199, 199, 100, 100]], err_msg=tool.geometry) - assert ax._got_onselect + onselect.assert_called_once() + (epress, erelease), kwargs = onselect.call_args + assert epress.xdata == 100 + assert epress.ydata == 100 + assert erelease.xdata == 199 + assert erelease.ydata == 199 + assert kwargs == {} -def test_rectangle_selector(): - check_rectangle() +@pytest.mark.parametrize('spancoords', ['data', 'pixels']) +@pytest.mark.parametrize('minspanx, x1', [[0, 10], [1, 10.5], [1, 11]]) +@pytest.mark.parametrize('minspany, y1', [[0, 10], [1, 10.5], [1, 11]]) +def test_rectangle_minspan(ax, spancoords, minspanx, x1, minspany, y1): - with pytest.warns( - MatplotlibDeprecationWarning, - match="Support for drawtype='line' is deprecated"): - check_rectangle(drawtype='line', useblit=False) + onselect = mock.Mock(spec=noop, return_value=None) - check_rectangle(useblit=True, button=1) + x0, y0 = (10, 10) + if spancoords == 'pixels': + minspanx, minspany = (ax.transData.transform((x1, y1)) - + ax.transData.transform((x0, y0))) - with pytest.warns( - MatplotlibDeprecationWarning, - match="Support for drawtype='none' is deprecated"): - check_rectangle(drawtype='none', minspanx=10, minspany=10) + tool = widgets.RectangleSelector(ax, onselect, interactive=True, + spancoords=spancoords, + minspanx=minspanx, minspany=minspany) + # Too small to create a selector + click_and_drag(tool, start=(x0, x1), end=(y0, y1)) + assert not tool._selection_completed + onselect.assert_not_called() + + click_and_drag(tool, start=(20, 20), end=(30, 30)) + assert tool._selection_completed + onselect.assert_called_once() + + # Too small to create a selector. Should clear existing selector, and + # trigger onselect because there was a preexisting selector + onselect.reset_mock() + click_and_drag(tool, start=(x0, y0), end=(x1, y1)) + assert not tool._selection_completed + onselect.assert_called_once() + (epress, erelease), kwargs = onselect.call_args + assert epress.xdata == x0 + assert epress.ydata == y0 + assert erelease.xdata == x1 + assert erelease.ydata == y1 + assert kwargs == {} - check_rectangle(minspanx=10, minspany=10, spancoords='pixels') - check_rectangle(props=dict(fill=True)) +def test_deprecation_selector_visible_attribute(ax): + tool = widgets.RectangleSelector(ax, lambda *args: None) -def _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, - use_key=None): - do_event(tool, 'press', xdata=xdata, ydata=ydata, button=1) - if use_key is not None: - do_event(tool, 'on_key_press', key=use_key) - do_event(tool, 'onmove', xdata=xdata_new, ydata=ydata_new, button=1) - if use_key is not None: - do_event(tool, 'on_key_release', key=use_key) - do_event(tool, 'release', xdata=xdata_new, ydata=ydata_new, button=1) + assert tool.get_visible() - return tool + with pytest.warns( + MatplotlibDeprecationWarning, + match="was deprecated in Matplotlib 3.6"): + tool.visible = False + assert not tool.get_visible() @pytest.mark.parametrize('drag_from_anywhere, new_center', [[True, (60, 75)], [False, (30, 20)]]) -def test_rectangle_drag(drag_from_anywhere, new_center): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.RectangleSelector(ax, onselect, interactive=True, +def test_rectangle_drag(ax, drag_from_anywhere, new_center): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True, drag_from_anywhere=drag_from_anywhere) # Create rectangle - do_event(tool, 'press', xdata=0, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + click_and_drag(tool, start=(0, 10), end=(100, 120)) assert tool.center == (50, 65) # Drag inside rectangle, but away from centre handle # @@ -91,31 +160,20 @@ def onselect(epress, erelease): # # If drag_from_anywhere == False, this will create a new rectangle with # center (30, 20) - do_event(tool, 'press', xdata=25, ydata=15, button=1) - do_event(tool, 'onmove', xdata=35, ydata=25, button=1) - do_event(tool, 'release', xdata=35, ydata=25, button=1) + click_and_drag(tool, start=(25, 15), end=(35, 25)) assert tool.center == new_center # Check that in both cases, dragging outside the rectangle draws a new # rectangle - do_event(tool, 'press', xdata=175, ydata=185, button=1) - do_event(tool, 'onmove', xdata=185, ydata=195, button=1) - do_event(tool, 'release', xdata=185, ydata=195, button=1) + click_and_drag(tool, start=(175, 185), end=(185, 195)) assert tool.center == (180, 190) -def test_rectangle_selector_set_props_handle_props(): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.RectangleSelector(ax, onselect, interactive=True, +def test_rectangle_selector_set_props_handle_props(ax): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True, props=dict(facecolor='b', alpha=0.2), handle_props=dict(alpha=0.5)) # Create rectangle - do_event(tool, 'press', xdata=0, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + click_and_drag(tool, start=(0, 10), end=(100, 120)) artist = tool._selection_artist assert artist.get_facecolor() == mcolors.to_rgba('b', alpha=0.2) @@ -131,60 +189,65 @@ def onselect(epress, erelease): assert artist.get_alpha() == 0.3 -def test_rectangle_resize(): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.RectangleSelector(ax, onselect, interactive=True) +def test_rectangle_resize(ax): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) # Create rectangle - _resize_rectangle(tool, 0, 10, 100, 120) + click_and_drag(tool, start=(0, 10), end=(100, 120)) assert tool.extents == (0.0, 100.0, 10.0, 120.0) # resize NE handle extents = tool.extents xdata, ydata = extents[1], extents[3] xdata_new, ydata_new = xdata + 10, ydata + 5 - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) assert tool.extents == (extents[0], xdata_new, extents[2], ydata_new) # resize E handle extents = tool.extents xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdata_new, ydata_new = xdata + 10, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) assert tool.extents == (extents[0], xdata_new, extents[2], extents[3]) # resize W handle extents = tool.extents xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdata_new, ydata_new = xdata + 15, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) assert tool.extents == (xdata_new, extents[1], extents[2], extents[3]) # resize SW handle extents = tool.extents xdata, ydata = extents[0], extents[2] xdata_new, ydata_new = xdata + 20, ydata + 25 - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) assert tool.extents == (xdata_new, extents[1], ydata_new, extents[3]) -@pytest.mark.parametrize('use_default_state', [True, False]) -def test_rectangle_resize_center(use_default_state): - ax = get_ax() +def test_rectangle_add_state(ax): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) + # Create rectangle + click_and_drag(tool, start=(70, 65), end=(125, 130)) + + with pytest.raises(ValueError): + tool.add_state('unsupported_state') + + with pytest.raises(ValueError): + tool.add_state('clear') + tool.add_state('move') + tool.add_state('square') + tool.add_state('center') - def onselect(epress, erelease): - pass - tool = widgets.RectangleSelector(ax, onselect, interactive=True) +@pytest.mark.parametrize('add_state', [True, False]) +def test_rectangle_resize_center(ax, add_state): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) # Create rectangle - _resize_rectangle(tool, 70, 65, 125, 130) + click_and_drag(tool, start=(70, 65), end=(125, 130)) assert tool.extents == (70.0, 125.0, 65.0, 130.0) - if use_default_state: - tool._default_state.add('center') + if add_state: + tool.add_state('center') use_key = None else: use_key = 'control' @@ -194,7 +257,8 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[3] xdiff, ydiff = 10, 5 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0] - xdiff, xdata_new, extents[2] - ydiff, ydata_new) @@ -203,7 +267,8 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdiff = 10 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0] - xdiff, xdata_new, extents[2], extents[3]) @@ -212,7 +277,8 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdiff = -20 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0] - xdiff, xdata_new, extents[2], extents[3]) @@ -221,7 +287,8 @@ def onselect(epress, erelease): xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdiff = 15 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (xdata_new, extents[1] - xdiff, extents[2], extents[3]) @@ -230,7 +297,8 @@ def onselect(epress, erelease): xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdiff = -25 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (xdata_new, extents[1] - xdiff, extents[2], extents[3]) @@ -239,25 +307,21 @@ def onselect(epress, erelease): xdata, ydata = extents[0], extents[2] xdiff, ydiff = 20, 25 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (xdata_new, extents[1] - xdiff, ydata_new, extents[3] - ydiff) -@pytest.mark.parametrize('use_default_state', [True, False]) -def test_rectangle_resize_square(use_default_state): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.RectangleSelector(ax, onselect, interactive=True) +@pytest.mark.parametrize('add_state', [True, False]) +def test_rectangle_resize_square(ax, add_state): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) # Create rectangle - _resize_rectangle(tool, 70, 65, 120, 115) + click_and_drag(tool, start=(70, 65), end=(120, 115)) assert tool.extents == (70.0, 120.0, 65.0, 115.0) - if use_default_state: - tool._default_state.add('square') + if add_state: + tool.add_state('square') use_key = None else: use_key = 'shift' @@ -267,7 +331,8 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[3] xdiff, ydiff = 10, 5 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0], xdata_new, extents[2], extents[3] + xdiff) @@ -276,7 +341,8 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdiff = 10 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0], xdata_new, extents[2], extents[3] + xdiff) @@ -285,7 +351,8 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdiff = -20 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0], xdata_new, extents[2], extents[3] + xdiff) @@ -294,7 +361,8 @@ def onselect(epress, erelease): xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdiff = 15 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (xdata_new, extents[1], extents[2], extents[3] - xdiff) @@ -303,7 +371,8 @@ def onselect(epress, erelease): xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdiff = -25 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (xdata_new, extents[1], extents[2], extents[3] - xdiff) @@ -312,170 +381,209 @@ def onselect(epress, erelease): xdata, ydata = extents[0], extents[2] xdiff, ydiff = 20, 25 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new, use_key) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new), + key=use_key) assert tool.extents == (extents[0] + ydiff, extents[1], ydata_new, extents[3]) -def test_rectangle_resize_square_center(): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.RectangleSelector(ax, onselect, interactive=True) +def test_rectangle_resize_square_center(ax): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) # Create rectangle - _resize_rectangle(tool, 70, 65, 120, 115) - tool._default_state.add('square') - tool._default_state.add('center') - assert tool.extents == (70.0, 120.0, 65.0, 115.0) + click_and_drag(tool, start=(70, 65), end=(120, 115)) + tool.add_state('square') + tool.add_state('center') + assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0)) # resize NE handle extents = tool.extents xdata, ydata = extents[1], extents[3] xdiff, ydiff = 10, 5 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - xdiff, extents[3] + xdiff) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff)) # resize E handle extents = tool.extents xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdiff = 10 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - xdiff, extents[3] + xdiff) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff)) # resize E handle negative diff extents = tool.extents xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2 xdiff = -20 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - xdiff, extents[3] + xdiff) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff)) # resize W handle extents = tool.extents xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdiff = 5 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (xdata_new, extents[1] - xdiff, - extents[2] + xdiff, extents[3] - xdiff) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + extents[2] + xdiff, extents[3] - xdiff)) # resize W handle negative diff extents = tool.extents xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2 xdiff = -25 xdata_new, ydata_new = xdata + xdiff, ydata - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (xdata_new, extents[1] - xdiff, - extents[2] + xdiff, extents[3] - xdiff) + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + extents[2] + xdiff, extents[3] - xdiff)) # resize SW handle extents = tool.extents xdata, ydata = extents[0], extents[2] xdiff, ydiff = 20, 25 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] + ydiff, extents[1] - ydiff, - ydata_new, extents[3] - ydiff) - - -def test_ellipse(): + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, (extents[0] + ydiff, extents[1] - ydiff, + ydata_new, extents[3] - ydiff)) + + +@pytest.mark.parametrize('selector_class', + [widgets.RectangleSelector, widgets.EllipseSelector]) +def test_rectangle_rotate(ax, selector_class): + tool = selector_class(ax, onselect=noop, interactive=True) + # Draw rectangle + click_and_drag(tool, start=(100, 100), end=(130, 140)) + assert tool.extents == (100, 130, 100, 140) + assert len(tool._state) == 0 + + # Rotate anticlockwise using top-right corner + do_event(tool, 'on_key_press', key='r') + assert tool._state == set(['rotate']) + assert len(tool._state) == 1 + click_and_drag(tool, start=(130, 140), end=(120, 145)) + do_event(tool, 'on_key_press', key='r') + assert len(tool._state) == 0 + # Extents shouldn't change (as shape of rectangle hasn't changed) + assert tool.extents == (100, 130, 100, 140) + assert_allclose(tool.rotation, 25.56, atol=0.01) + tool.rotation = 45 + assert tool.rotation == 45 + # Corners should move + assert_allclose(tool.corners, + np.array([[118.53, 139.75, 111.46, 90.25], + [95.25, 116.46, 144.75, 123.54]]), atol=0.01) + + # Scale using top-right corner + click_and_drag(tool, start=(110, 145), end=(110, 160)) + assert_allclose(tool.extents, (100, 139.75, 100, 151.82), atol=0.01) + + if selector_class == widgets.RectangleSelector: + with pytest.raises(ValueError): + tool._selection_artist.rotation_point = 'unvalid_value' + + +def test_rectangle_add_remove_set(ax): + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) + # Draw rectangle + click_and_drag(tool, start=(100, 100), end=(130, 140)) + assert tool.extents == (100, 130, 100, 140) + assert len(tool._state) == 0 + for state in ['rotate', 'square', 'center']: + tool.add_state(state) + assert len(tool._state) == 1 + tool.remove_state(state) + assert len(tool._state) == 0 + + +@pytest.mark.parametrize('use_data_coordinates', [False, True]) +def test_rectangle_resize_square_center_aspect(ax, use_data_coordinates): + ax.set_aspect(0.8) + + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True, + use_data_coordinates=use_data_coordinates) + # Create rectangle + click_and_drag(tool, start=(70, 65), end=(120, 115)) + assert tool.extents == (70.0, 120.0, 65.0, 115.0) + tool.add_state('square') + tool.add_state('center') + + if use_data_coordinates: + # resize E handle + extents = tool.extents + xdata, ydata, width = extents[1], extents[3], extents[1] - extents[0] + xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 + xdata_new, ydata_new = xdata + xdiff, ydata + ychange = width / 2 + xdiff + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + ycenter - ychange, ycenter + ychange]) + else: + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + ychange = xdiff * 1 / tool._aspect_ratio_correction + click_and_drag(tool, start=(xdata, ydata), end=(xdata_new, ydata_new)) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + 46.25, 133.75]) + + +def test_ellipse(ax): """For ellipse, test out the key modifiers""" - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.EllipseSelector(ax, onselect=onselect, + tool = widgets.EllipseSelector(ax, onselect=noop, grab_range=10, interactive=True) tool.extents = (100, 150, 100, 150) # drag the rectangle - do_event(tool, 'press', xdata=10, ydata=10, button=1, - key=' ') - - do_event(tool, 'onmove', xdata=30, ydata=30, button=1) - do_event(tool, 'release', xdata=30, ydata=30, button=1) + click_and_drag(tool, start=(125, 125), end=(145, 145)) assert tool.extents == (120, 170, 120, 170) # create from center - do_event(tool, 'on_key_press', xdata=100, ydata=100, button=1, - key='control') - do_event(tool, 'press', xdata=100, ydata=100, button=1) - do_event(tool, 'onmove', xdata=125, ydata=125, button=1) - do_event(tool, 'release', xdata=125, ydata=125, button=1) - do_event(tool, 'on_key_release', xdata=100, ydata=100, button=1, - key='control') + click_and_drag(tool, start=(100, 100), end=(125, 125), key='control') assert tool.extents == (75, 125, 75, 125) # create a square - do_event(tool, 'on_key_press', xdata=10, ydata=10, button=1, - key='shift') - do_event(tool, 'press', xdata=10, ydata=10, button=1) - do_event(tool, 'onmove', xdata=35, ydata=30, button=1) - do_event(tool, 'release', xdata=35, ydata=30, button=1) - do_event(tool, 'on_key_release', xdata=10, ydata=10, button=1, - key='shift') + click_and_drag(tool, start=(10, 10), end=(35, 30), key='shift') extents = [int(e) for e in tool.extents] - assert extents == [10, 35, 10, 34] + assert extents == [10, 35, 10, 35] # create a square from center - do_event(tool, 'on_key_press', xdata=100, ydata=100, button=1, - key='ctrl+shift') - do_event(tool, 'press', xdata=100, ydata=100, button=1) - do_event(tool, 'onmove', xdata=125, ydata=130, button=1) - do_event(tool, 'release', xdata=125, ydata=130, button=1) - do_event(tool, 'on_key_release', xdata=100, ydata=100, button=1, - key='ctrl+shift') + click_and_drag(tool, start=(100, 100), end=(125, 130), key='ctrl+shift') extents = [int(e) for e in tool.extents] - assert extents == [70, 129, 70, 130] + assert extents == [70, 130, 70, 130] assert tool.geometry.shape == (2, 73) assert_allclose(tool.geometry[:, 0], [70., 100]) -def test_rectangle_handles(): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.RectangleSelector(ax, onselect=onselect, +def test_rectangle_handles(ax): + tool = widgets.RectangleSelector(ax, onselect=noop, grab_range=10, interactive=True, handle_props={'markerfacecolor': 'r', 'markeredgecolor': 'b'}) tool.extents = (100, 150, 100, 150) - assert tool.corners == ( - (100, 150, 150, 100), (100, 100, 150, 150)) + assert_allclose(tool.corners, ((100, 150, 150, 100), (100, 100, 150, 150))) assert tool.extents == (100, 150, 100, 150) - assert tool.edge_centers == ( - (100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150)) + assert_allclose(tool.edge_centers, + ((100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150))) assert tool.extents == (100, 150, 100, 150) # grab a corner and move it - do_event(tool, 'press', xdata=100, ydata=100) - do_event(tool, 'onmove', xdata=120, ydata=120) - do_event(tool, 'release', xdata=120, ydata=120) + click_and_drag(tool, start=(100, 100), end=(120, 120)) assert tool.extents == (120, 150, 120, 150) # grab the center and move it - do_event(tool, 'press', xdata=132, ydata=132) - do_event(tool, 'onmove', xdata=120, ydata=120) - do_event(tool, 'release', xdata=120, ydata=120) + click_and_drag(tool, start=(132, 132), end=(120, 120)) assert tool.extents == (108, 138, 108, 138) # create a new rectangle - do_event(tool, 'press', xdata=10, ydata=10) - do_event(tool, 'onmove', xdata=100, ydata=100) - do_event(tool, 'release', xdata=100, ydata=100) + click_and_drag(tool, start=(10, 10), end=(100, 100)) assert tool.extents == (10, 100, 10, 100) # Check that marker_props worked. @@ -486,175 +594,120 @@ def onselect(epress, erelease): @pytest.mark.parametrize('interactive', [True, False]) -def test_rectangle_selector_onselect(interactive): +def test_rectangle_selector_onselect(ax, interactive): # check when press and release events take place at the same position - ax = get_ax() - - def onselect(vmin, vmax): - ax._got_onselect = True + onselect = mock.Mock(spec=noop, return_value=None) tool = widgets.RectangleSelector(ax, onselect, interactive=interactive) - do_event(tool, 'press', xdata=100, ydata=110, button=1) # move outside of axis - do_event(tool, 'onmove', xdata=150, ydata=120, button=1) - do_event(tool, 'release', xdata=150, ydata=120, button=1) + click_and_drag(tool, start=(100, 110), end=(150, 120)) - assert tool.ax._got_onselect + onselect.assert_called_once() assert tool.extents == (100.0, 150.0, 110.0, 120.0) - # Reset tool.ax._got_onselect - tool.ax._got_onselect = False - - do_event(tool, 'press', xdata=10, ydata=100, button=1) - do_event(tool, 'release', xdata=10, ydata=100, button=1) - - assert tool.ax._got_onselect + onselect.reset_mock() + click_and_drag(tool, start=(10, 100), end=(10, 100)) + onselect.assert_called_once() @pytest.mark.parametrize('ignore_event_outside', [True, False]) -def test_rectangle_selector_ignore_outside(ignore_event_outside): - ax = get_ax() - def onselect(vmin, vmax): - ax._got_onselect = True +def test_rectangle_selector_ignore_outside(ax, ignore_event_outside): + onselect = mock.Mock(spec=noop, return_value=None) tool = widgets.RectangleSelector(ax, onselect, ignore_event_outside=ignore_event_outside) - do_event(tool, 'press', xdata=100, ydata=110, button=1) - do_event(tool, 'onmove', xdata=150, ydata=120, button=1) - do_event(tool, 'release', xdata=150, ydata=120, button=1) - - assert tool.ax._got_onselect + click_and_drag(tool, start=(100, 110), end=(150, 120)) + onselect.assert_called_once() assert tool.extents == (100.0, 150.0, 110.0, 120.0) - # Reset - ax._got_onselect = False + onselect.reset_mock() # Trigger event outside of span - do_event(tool, 'press', xdata=150, ydata=150, button=1) - do_event(tool, 'onmove', xdata=160, ydata=160, button=1) - do_event(tool, 'release', xdata=160, ydata=160, button=1) + click_and_drag(tool, start=(150, 150), end=(160, 160)) if ignore_event_outside: # event have been ignored and span haven't changed. - assert not ax._got_onselect + onselect.assert_not_called() assert tool.extents == (100.0, 150.0, 110.0, 120.0) else: # A new shape is created - assert ax._got_onselect + onselect.assert_called_once() assert tool.extents == (150.0, 160.0, 150.0, 160.0) -def check_span(*args, **kwargs): - ax = get_ax() - - def onselect(vmin, vmax): - ax._got_onselect = True - assert vmin == 100 - assert vmax == 199 - - def onmove(vmin, vmax): - assert vmin == 100 - assert vmax == 199 - ax._got_on_move = True - - if 'onmove_callback' in kwargs: +@pytest.mark.parametrize('orientation, onmove_callback, kwargs', [ + ('horizontal', False, dict(minspan=10, useblit=True)), + ('vertical', True, dict(button=1)), + ('horizontal', False, dict(props=dict(fill=True))), + ('horizontal', False, dict(interactive=True)), +]) +def test_span_selector(ax, orientation, onmove_callback, kwargs): + onselect = mock.Mock(spec=noop, return_value=None) + onmove = mock.Mock(spec=noop, return_value=None) + if onmove_callback: kwargs['onmove_callback'] = onmove - tool = widgets.SpanSelector(ax, onselect, *args, **kwargs) + tool = widgets.SpanSelector(ax, onselect, orientation, **kwargs) do_event(tool, 'press', xdata=100, ydata=100, button=1) # move outside of axis do_event(tool, 'onmove', xdata=199, ydata=199, button=1) do_event(tool, 'release', xdata=250, ydata=250, button=1) - assert ax._got_onselect - - if 'onmove_callback' in kwargs: - assert ax._got_on_move - - -def test_span_selector(): - check_span('horizontal', minspan=10, useblit=True) - check_span('vertical', onmove_callback=True, button=1) - check_span('horizontal', props=dict(fill=True)) - check_span('horizontal', interactive=True) + onselect.assert_called_once_with(100, 199) + if onmove_callback: + onmove.assert_called_once_with(100, 199) @pytest.mark.parametrize('interactive', [True, False]) -def test_span_selector_onselect(interactive): - # check when press and release events take place at the same position - ax = get_ax() - - def onselect(vmin, vmax): - ax._got_onselect = True +def test_span_selector_onselect(ax, interactive): + onselect = mock.Mock(spec=noop, return_value=None) tool = widgets.SpanSelector(ax, onselect, 'horizontal', interactive=interactive) - do_event(tool, 'press', xdata=100, ydata=100, button=1) # move outside of axis - do_event(tool, 'onmove', xdata=150, ydata=100, button=1) - do_event(tool, 'release', xdata=150, ydata=100, button=1) - - assert tool.ax._got_onselect + click_and_drag(tool, start=(100, 100), end=(150, 100)) + onselect.assert_called_once() assert tool.extents == (100, 150) - # Reset tool.ax._got_onselect - tool.ax._got_onselect = False - - do_event(tool, 'press', xdata=10, ydata=100, button=1) - do_event(tool, 'release', xdata=10, ydata=100, button=1) - - assert tool.ax._got_onselect + onselect.reset_mock() + click_and_drag(tool, start=(10, 100), end=(10, 100)) + onselect.assert_called_once() @pytest.mark.parametrize('ignore_event_outside', [True, False]) -def test_span_selector_ignore_outside(ignore_event_outside): - ax = get_ax() - def onselect(vmin, vmax): - ax._got_onselect = True - - def onmove(vmin, vmax): - ax._got_on_move = True +def test_span_selector_ignore_outside(ax, ignore_event_outside): + onselect = mock.Mock(spec=noop, return_value=None) + onmove = mock.Mock(spec=noop, return_value=None) tool = widgets.SpanSelector(ax, onselect, 'horizontal', onmove_callback=onmove, ignore_event_outside=ignore_event_outside) - do_event(tool, 'press', xdata=100, ydata=100, button=1) - do_event(tool, 'onmove', xdata=125, ydata=125, button=1) - do_event(tool, 'release', xdata=125, ydata=125, button=1) - assert ax._got_onselect - assert ax._got_on_move + click_and_drag(tool, start=(100, 100), end=(125, 125)) + onselect.assert_called_once() + onmove.assert_called_once() assert tool.extents == (100, 125) - # Reset - ax._got_onselect = False - ax._got_on_move = False + onselect.reset_mock() + onmove.reset_mock() # Trigger event outside of span - do_event(tool, 'press', xdata=150, ydata=150, button=1) - do_event(tool, 'onmove', xdata=160, ydata=160, button=1) - do_event(tool, 'release', xdata=160, ydata=160, button=1) + click_and_drag(tool, start=(150, 150), end=(160, 160)) if ignore_event_outside: # event have been ignored and span haven't changed. - assert not ax._got_onselect - assert not ax._got_on_move + onselect.assert_not_called() + onmove.assert_not_called() assert tool.extents == (100, 125) else: # A new shape is created - assert ax._got_onselect - assert ax._got_on_move + onselect.assert_called_once() + onmove.assert_called_once() assert tool.extents == (150, 160) @pytest.mark.parametrize('drag_from_anywhere', [True, False]) -def test_span_selector_drag(drag_from_anywhere): - ax = get_ax() - - def onselect(*args): - pass - +def test_span_selector_drag(ax, drag_from_anywhere): # Create span - tool = widgets.SpanSelector(ax, onselect, 'horizontal', interactive=True, + tool = widgets.SpanSelector(ax, onselect=noop, direction='horizontal', + interactive=True, drag_from_anywhere=drag_from_anywhere) - do_event(tool, 'press', xdata=10, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + click_and_drag(tool, start=(10, 10), end=(100, 120)) assert tool.extents == (10, 100) # Drag inside span # @@ -663,33 +716,26 @@ def onselect(*args): # # If drag_from_anywhere == False, this will create a new span with # value extents = 25, 35 - do_event(tool, 'press', xdata=25, ydata=15, button=1) - do_event(tool, 'onmove', xdata=35, ydata=25, button=1) - do_event(tool, 'release', xdata=35, ydata=25, button=1) + click_and_drag(tool, start=(25, 15), end=(35, 25)) if drag_from_anywhere: assert tool.extents == (20, 110) else: assert tool.extents == (25, 35) # Check that in both cases, dragging outside the span draws a new span - do_event(tool, 'press', xdata=175, ydata=185, button=1) - do_event(tool, 'onmove', xdata=185, ydata=195, button=1) - do_event(tool, 'release', xdata=185, ydata=195, button=1) + click_and_drag(tool, start=(175, 185), end=(185, 195)) assert tool.extents == (175, 185) -def test_span_selector_direction(): - ax = get_ax() - - def onselect(*args): - pass - - tool = widgets.SpanSelector(ax, onselect, 'horizontal', interactive=True) +def test_span_selector_direction(ax): + tool = widgets.SpanSelector(ax, onselect=noop, direction='horizontal', + interactive=True) assert tool.direction == 'horizontal' assert tool._edge_handles.direction == 'horizontal' with pytest.raises(ValueError): - tool = widgets.SpanSelector(ax, onselect, 'invalid_direction') + tool = widgets.SpanSelector(ax, onselect=noop, + direction='invalid_direction') tool.direction = 'vertical' assert tool.direction == 'vertical' @@ -699,19 +745,13 @@ def onselect(*args): tool.direction = 'invalid_string' -def test_span_selector_set_props_handle_props(): - ax = get_ax() - - def onselect(epress, erelease): - pass - - tool = widgets.SpanSelector(ax, onselect, 'horizontal', interactive=True, +def test_span_selector_set_props_handle_props(ax): + tool = widgets.SpanSelector(ax, onselect=noop, direction='horizontal', + interactive=True, props=dict(facecolor='b', alpha=0.2), handle_props=dict(alpha=0.5)) # Create rectangle - do_event(tool, 'press', xdata=0, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + click_and_drag(tool, start=(0, 10), end=(100, 120)) artist = tool._selection_artist assert artist.get_facecolor() == mcolors.to_rgba('b', alpha=0.2) @@ -728,13 +768,8 @@ def onselect(epress, erelease): @pytest.mark.parametrize('selector', ['span', 'rectangle']) -def test_selector_clear(selector): - ax = get_ax() - - def onselect(*args): - pass - - kwargs = dict(ax=ax, onselect=onselect, interactive=True) +def test_selector_clear(ax, selector): + kwargs = dict(ax=ax, onselect=noop, interactive=True) if selector == 'span': Selector = widgets.SpanSelector kwargs['direction'] = 'horizontal' @@ -742,26 +777,19 @@ def onselect(*args): Selector = widgets.RectangleSelector tool = Selector(**kwargs) - do_event(tool, 'press', xdata=10, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + click_and_drag(tool, start=(10, 10), end=(100, 120)) # press-release event outside the selector to clear the selector - do_event(tool, 'press', xdata=130, ydata=130, button=1) - do_event(tool, 'release', xdata=130, ydata=130, button=1) + click_and_drag(tool, start=(130, 130), end=(130, 130)) assert not tool._selection_completed - ax = get_ax() kwargs['ignore_event_outside'] = True tool = Selector(**kwargs) assert tool.ignore_event_outside - do_event(tool, 'press', xdata=10, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + click_and_drag(tool, start=(10, 10), end=(100, 120)) # press-release event outside the selector ignored - do_event(tool, 'press', xdata=130, ydata=130, button=1) - do_event(tool, 'release', xdata=130, ydata=130, button=1) + click_and_drag(tool, start=(130, 130), end=(130, 130)) assert tool._selection_completed do_event(tool, 'on_key_press', key='escape') @@ -769,45 +797,47 @@ def onselect(*args): @pytest.mark.parametrize('selector', ['span', 'rectangle']) -def test_selector_clear_method(selector): - ax = get_ax() - - def onselect(*args): - pass - +def test_selector_clear_method(ax, selector): if selector == 'span': - tool = widgets.SpanSelector(ax, onselect, 'horizontal', + tool = widgets.SpanSelector(ax, onselect=noop, direction='horizontal', interactive=True, ignore_event_outside=True) else: - tool = widgets.RectangleSelector(ax, onselect, interactive=True) - do_event(tool, 'press', xdata=10, ydata=10, button=1) - do_event(tool, 'onmove', xdata=100, ydata=120, button=1) - do_event(tool, 'release', xdata=100, ydata=120, button=1) + tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True) + click_and_drag(tool, start=(10, 10), end=(100, 120)) assert tool._selection_completed - assert tool.visible + assert tool.get_visible() if selector == 'span': assert tool.extents == (10, 100) tool.clear() assert not tool._selection_completed - assert not tool.visible + assert not tool.get_visible() # Do another cycle of events to make sure we can - do_event(tool, 'press', xdata=10, ydata=10, button=1) - do_event(tool, 'onmove', xdata=50, ydata=120, button=1) - do_event(tool, 'release', xdata=50, ydata=120, button=1) + click_and_drag(tool, start=(10, 10), end=(50, 120)) assert tool._selection_completed - assert tool.visible + assert tool.get_visible() if selector == 'span': assert tool.extents == (10, 50) -def test_tool_line_handle(): - ax = get_ax() +def test_span_selector_add_state(ax): + tool = widgets.SpanSelector(ax, noop, 'horizontal', + interactive=True) - positions = [20, 30, 50] + with pytest.raises(ValueError): + tool.add_state('unsupported_state') + with pytest.raises(ValueError): + tool.add_state('center') + with pytest.raises(ValueError): + tool.add_state('square') + tool.add_state('move') + + +def test_tool_line_handle(ax): + positions = [20, 30, 50] tool_line_handle = widgets.ToolLineHandles(ax, positions, 'horizontal', useblit=False) @@ -843,8 +873,7 @@ def test_span_selector_bound(direction): press_data = [10.5, 11.5] move_data = [11, 13] # Updating selector is done in onmove release_data = move_data - do_event(tool, 'press', xdata=press_data[0], ydata=press_data[1], button=1) - do_event(tool, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1) + click_and_drag(tool, start=press_data, end=move_data) assert ax.get_xbound() == x_bound assert ax.get_ybound() == y_bound @@ -854,29 +883,111 @@ def test_span_selector_bound(direction): assert tool._edge_handles.positions == handle_positions -def check_lasso_selector(**kwargs): - ax = get_ax() +@pytest.mark.backend('QtAgg', skip_on_importerror=True) +def test_span_selector_animated_artists_callback(): + """Check that the animated artists changed in callbacks are updated.""" + x = np.linspace(0, 2 * np.pi, 100) + values = np.sin(x) - def onselect(verts): + fig, ax = plt.subplots() + ln, = ax.plot(x, values, animated=True) + ln2, = ax.plot([], animated=True) + + # spin the event loop to let the backend process any pending operations + # before drawing artists + # See blitting tutorial + plt.pause(0.1) + ax.draw_artist(ln) + fig.canvas.blit(fig.bbox) + + def mean(vmin, vmax): + # Return mean of values in x between *vmin* and *vmax* + indmin, indmax = np.searchsorted(x, (vmin, vmax)) + v = values[indmin:indmax].mean() + ln2.set_data(x, np.full_like(x, v)) + + span = widgets.SpanSelector(ax, mean, direction='horizontal', + onmove_callback=mean, + interactive=True, + drag_from_anywhere=True, + useblit=True) + + # Add span selector and check that the line is draw after it was updated + # by the callback + press_data = [1, 2] + move_data = [2, 2] + do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1) + do_event(span, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1) + assert span._get_animated_artists() == (ln, ln2) + assert ln.stale is False + assert ln2.stale + assert_allclose(ln2.get_ydata(), 0.9547335049088455) + span.update() + assert ln2.stale is False + + # Change span selector and check that the line is drawn/updated after its + # value was updated by the callback + press_data = [4, 2] + move_data = [5, 2] + release_data = [5, 2] + do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1) + do_event(span, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1) + assert ln.stale is False + assert ln2.stale + assert_allclose(ln2.get_ydata(), -0.9424150707548072) + do_event(span, 'release', xdata=release_data[0], + ydata=release_data[1], button=1) + assert ln2.stale is False + + +def test_snapping_values_span_selector(ax): + def onselect(*args): + pass + + tool = widgets.SpanSelector(ax, onselect, direction='horizontal',) + snap_function = tool._snap + + snap_values = np.linspace(0, 5, 11) + values = np.array([-0.1, 0.1, 0.2, 0.5, 0.6, 0.7, 0.9, 4.76, 5.0, 5.5]) + expect = np.array([00.0, 0.0, 0.0, 0.5, 0.5, 0.5, 1.0, 5.00, 5.0, 5.0]) + values = snap_function(values, snap_values) + assert_allclose(values, expect) + + +def test_span_selector_snap(ax): + def onselect(vmin, vmax): ax._got_onselect = True - assert verts == [(100, 100), (125, 125), (150, 150)] + + snap_values = np.arange(50) * 4 + + tool = widgets.SpanSelector(ax, onselect, direction='horizontal', + snap_values=snap_values) + tool.extents = (17, 35) + assert tool.extents == (16, 36) + + tool.snap_values = None + assert tool.snap_values is None + tool.extents = (17, 35) + assert tool.extents == (17, 35) + + +@pytest.mark.parametrize('kwargs', [ + dict(), + dict(useblit=False, props=dict(color='red')), + dict(useblit=True, button=1), +]) +def test_lasso_selector(ax, kwargs): + onselect = mock.Mock(spec=noop, return_value=None) tool = widgets.LassoSelector(ax, onselect, **kwargs) do_event(tool, 'press', xdata=100, ydata=100, button=1) do_event(tool, 'onmove', xdata=125, ydata=125, button=1) do_event(tool, 'release', xdata=150, ydata=150, button=1) - assert ax._got_onselect - + onselect.assert_called_once_with([(100, 100), (125, 125), (150, 150)]) -def test_lasso_selector(): - check_lasso_selector() - check_lasso_selector(useblit=False, props=dict(color='red')) - check_lasso_selector(useblit=True, button=1) - -def test_CheckButtons(): - ax = get_ax() +def test_CheckButtons(ax): check = widgets.CheckButtons(ax, ('a', 'b', 'c'), (True, False, True)) assert check.get_status() == [True, False, True] check.set_active(0) @@ -887,14 +998,12 @@ def test_CheckButtons(): @pytest.mark.parametrize("toolbar", ["none", "toolbar2", "toolmanager"]) -def test_TextBox(toolbar): +def test_TextBox(ax, toolbar): # Avoid "toolmanager is provisional" warning. - dict.__setitem__(plt.rcParams, "toolbar", toolbar) + plt.rcParams._set("toolbar", toolbar) - from unittest.mock import Mock - submit_event = Mock() - text_change_event = Mock() - ax = get_ax() + submit_event = mock.Mock(spec=noop, return_value=None) + text_change_event = mock.Mock(spec=noop, return_value=None) tool = widgets.TextBox(ax, '') tool.on_submit(submit_event) tool.on_text_change(text_change_event) @@ -908,7 +1017,7 @@ def test_TextBox(toolbar): assert tool.text == 'x**2' assert text_change_event.call_count == 1 - tool.begin_typing(tool.text) + tool.begin_typing() tool.stop_typing() assert submit_event.call_count == 2 @@ -922,25 +1031,170 @@ def test_TextBox(toolbar): @image_comparison(['check_radio_buttons.png'], style='mpl20', remove_text=True) def test_check_radio_buttons_image(): - # Remove this line when this test image is regenerated. - plt.rcParams['text.kerning_factor'] = 6 - - get_ax() - plt.subplots_adjust(left=0.3) - rax1 = plt.axes([0.05, 0.7, 0.15, 0.15]) - rax2 = plt.axes([0.05, 0.2, 0.15, 0.15]) - widgets.RadioButtons(rax1, ('Radio 1', 'Radio 2', 'Radio 3')) - widgets.CheckButtons(rax2, ('Check 1', 'Check 2', 'Check 3'), - (False, True, True)) - - -@image_comparison(['check_bunch_of_radio_buttons.png'], - style='mpl20', remove_text=True) -def test_check_bunch_of_radio_buttons(): - rax = plt.axes([0.05, 0.1, 0.15, 0.7]) - widgets.RadioButtons(rax, ('B1', 'B2', 'B3', 'B4', 'B5', 'B6', - 'B7', 'B8', 'B9', 'B10', 'B11', 'B12', - 'B13', 'B14', 'B15')) + ax = get_ax() + fig = ax.figure + fig.subplots_adjust(left=0.3) + + rax1 = fig.add_axes([0.05, 0.7, 0.2, 0.15]) + rb1 = widgets.RadioButtons(rax1, ('Radio 1', 'Radio 2', 'Radio 3')) + with pytest.warns(DeprecationWarning, + match='The circles attribute was deprecated'): + rb1.circles # Trigger the old-style elliptic radiobuttons. + + rax2 = fig.add_axes([0.05, 0.5, 0.2, 0.15]) + cb1 = widgets.CheckButtons(rax2, ('Check 1', 'Check 2', 'Check 3'), + (False, True, True)) + with pytest.warns(DeprecationWarning, + match='The rectangles attribute was deprecated'): + cb1.rectangles # Trigger old-style Rectangle check boxes + + rax3 = fig.add_axes([0.05, 0.3, 0.2, 0.15]) + rb3 = widgets.RadioButtons( + rax3, ('Radio 1', 'Radio 2', 'Radio 3'), + label_props={'fontsize': [8, 12, 16], + 'color': ['red', 'green', 'blue']}, + radio_props={'edgecolor': ['red', 'green', 'blue'], + 'facecolor': ['mistyrose', 'palegreen', 'lightblue']}) + + rax4 = fig.add_axes([0.05, 0.1, 0.2, 0.15]) + cb4 = widgets.CheckButtons( + rax4, ('Check 1', 'Check 2', 'Check 3'), (False, True, True), + label_props={'fontsize': [8, 12, 16], + 'color': ['red', 'green', 'blue']}, + frame_props={'edgecolor': ['red', 'green', 'blue'], + 'facecolor': ['mistyrose', 'palegreen', 'lightblue']}, + check_props={'color': ['red', 'green', 'blue']}) + + +@check_figures_equal(extensions=["png"]) +def test_radio_buttons(fig_test, fig_ref): + widgets.RadioButtons(fig_test.subplots(), ["tea", "coffee"]) + ax = fig_ref.add_subplot(xticks=[], yticks=[]) + ax.scatter([.15, .15], [2/3, 1/3], transform=ax.transAxes, + s=(plt.rcParams["font.size"] / 2) ** 2, c=["C0", "none"]) + ax.text(.25, 2/3, "tea", transform=ax.transAxes, va="center") + ax.text(.25, 1/3, "coffee", transform=ax.transAxes, va="center") + + +@check_figures_equal(extensions=['png']) +def test_radio_buttons_props(fig_test, fig_ref): + label_props = {'color': ['red'], 'fontsize': [24]} + radio_props = {'facecolor': 'green', 'edgecolor': 'blue', 'linewidth': 2} + + widgets.RadioButtons(fig_ref.subplots(), ['tea', 'coffee'], + label_props=label_props, radio_props=radio_props) + + cb = widgets.RadioButtons(fig_test.subplots(), ['tea', 'coffee']) + cb.set_label_props(label_props) + # Setting the label size automatically increases default marker size, so we + # need to do that here as well. + cb.set_radio_props({**radio_props, 's': (24 / 2)**2}) + + +def test_radio_button_active_conflict(ax): + with pytest.warns(UserWarning, + match=r'Both the \*activecolor\* parameter'): + rb = widgets.RadioButtons(ax, ['tea', 'coffee'], activecolor='red', + radio_props={'facecolor': 'green'}) + # *radio_props*' facecolor wins over *activecolor* + assert mcolors.same_color(rb._buttons.get_facecolor(), ['green', 'none']) + + +@check_figures_equal(extensions=['png']) +def test_radio_buttons_activecolor_change(fig_test, fig_ref): + widgets.RadioButtons(fig_ref.subplots(), ['tea', 'coffee'], + activecolor='green') + + # Test property setter. + cb = widgets.RadioButtons(fig_test.subplots(), ['tea', 'coffee'], + activecolor='red') + cb.activecolor = 'green' + + +@check_figures_equal(extensions=["png"]) +def test_check_buttons(fig_test, fig_ref): + widgets.CheckButtons(fig_test.subplots(), ["tea", "coffee"], [True, True]) + ax = fig_ref.add_subplot(xticks=[], yticks=[]) + ax.scatter([.15, .15], [2/3, 1/3], marker='s', transform=ax.transAxes, + s=(plt.rcParams["font.size"] / 2) ** 2, c=["none", "none"]) + ax.scatter([.15, .15], [2/3, 1/3], marker='x', transform=ax.transAxes, + s=(plt.rcParams["font.size"] / 2) ** 2, c=["k", "k"]) + ax.text(.25, 2/3, "tea", transform=ax.transAxes, va="center") + ax.text(.25, 1/3, "coffee", transform=ax.transAxes, va="center") + + +@check_figures_equal(extensions=['png']) +def test_check_button_props(fig_test, fig_ref): + label_props = {'color': ['red'], 'fontsize': [24]} + frame_props = {'facecolor': 'green', 'edgecolor': 'blue', 'linewidth': 2} + check_props = {'facecolor': 'red', 'linewidth': 2} + + widgets.CheckButtons(fig_ref.subplots(), ['tea', 'coffee'], [True, True], + label_props=label_props, frame_props=frame_props, + check_props=check_props) + + cb = widgets.CheckButtons(fig_test.subplots(), ['tea', 'coffee'], + [True, True]) + cb.set_label_props(label_props) + # Setting the label size automatically increases default marker size, so we + # need to do that here as well. + cb.set_frame_props({**frame_props, 's': (24 / 2)**2}) + # FIXME: Axes.scatter promotes facecolor to edgecolor on unfilled markers, + # but Collection.update doesn't do that (it forgot the marker already). + # This means we cannot pass facecolor to both setters directly. + check_props['edgecolor'] = check_props.pop('facecolor') + cb.set_check_props({**check_props, 's': (24 / 2)**2}) + + +@check_figures_equal(extensions=["png"]) +def test_check_buttons_rectangles(fig_test, fig_ref): + # Test should be removed once .rectangles is removed + cb = widgets.CheckButtons(fig_test.subplots(), ["", ""], + [False, False]) + with pytest.warns(DeprecationWarning, + match='The rectangles attribute was deprecated'): + cb.rectangles + ax = fig_ref.add_subplot(xticks=[], yticks=[]) + ys = [2/3, 1/3] + dy = 1/3 + w, h = dy / 2, dy / 2 + rectangles = [ + Rectangle(xy=(0.05, ys[i] - h / 2), width=w, height=h, + edgecolor="black", + facecolor="none", + transform=ax.transAxes + ) + for i, y in enumerate(ys) + ] + for rectangle in rectangles: + ax.add_patch(rectangle) + + +@check_figures_equal(extensions=["png"]) +def test_check_buttons_lines(fig_test, fig_ref): + # Test should be removed once .lines is removed + cb = widgets.CheckButtons(fig_test.subplots(), ["", ""], [True, True]) + with pytest.warns(DeprecationWarning, + match='The lines attribute was deprecated'): + cb.lines + for rectangle in cb._rectangles: + rectangle.set_visible(False) + ax = fig_ref.add_subplot(xticks=[], yticks=[]) + ys = [2/3, 1/3] + dy = 1/3 + w, h = dy / 2, dy / 2 + lineparams = {'color': 'k', 'linewidth': 1.25, + 'transform': ax.transAxes, + 'solid_capstyle': 'butt'} + for i, y in enumerate(ys): + x, y = 0.05, y - h / 2 + l1 = Line2D([x, x + w], [y + h, y], **lineparams) + l2 = Line2D([x, x + w], [y, y + h], **lineparams) + + l1.set_visible(True) + l2.set_visible(True) + ax.add_line(l1) + ax.add_line(l2) def test_slider_slidermin_slidermax_invalid(): @@ -1063,7 +1317,25 @@ def handle_positions(slider): assert_allclose(handle_positions(slider), (0.1, 0.34)) -def check_polygon_selector(event_sequence, expected_result, selections_count): +@pytest.mark.parametrize("orientation", ["horizontal", "vertical"]) +def test_range_slider_same_init_values(orientation): + if orientation == "vertical": + idx = [1, 0, 3, 2] + else: + idx = [0, 1, 2, 3] + + fig, ax = plt.subplots() + + slider = widgets.RangeSlider( + ax=ax, label="", valmin=0.0, valmax=1.0, orientation=orientation, + valinit=[0, 0] + ) + box = slider.poly.get_extents().transformed(ax.transAxes.inverted()) + assert_allclose(box.get_points().flatten()[idx], [0, 0.25, 0, 0.75]) + + +def check_polygon_selector(event_sequence, expected_result, selections_count, + **kwargs): """ Helper function to test Polygon Selector. @@ -1080,22 +1352,20 @@ def check_polygon_selector(event_sequence, expected_result, selections_count): selections_count : int Wait for the tool to call its `onselect` function `selections_count` times, before comparing the result to the `expected_result` + **kwargs + Keyword arguments are passed to PolygonSelector. """ ax = get_ax() - ax._selections_count = 0 - - def onselect(vertices): - ax._selections_count += 1 - ax._current_result = vertices + onselect = mock.Mock(spec=noop, return_value=None) - tool = widgets.PolygonSelector(ax, onselect) + tool = widgets.PolygonSelector(ax, onselect, **kwargs) for (etype, event_args) in event_sequence: do_event(tool, etype, **event_args) - assert ax._selections_count == selections_count - assert ax._current_result == expected_result + assert onselect.call_count == selections_count + assert onselect.call_args == ((expected_result, ), {}) def polygon_place_vertex(xdata, ydata): @@ -1110,119 +1380,133 @@ def polygon_remove_vertex(xdata, ydata): ('release', dict(xdata=xdata, ydata=ydata, button=3))] -def test_polygon_selector(): +@pytest.mark.parametrize('draw_bounding_box', [False, True]) +def test_polygon_selector(draw_bounding_box): + check_selector = functools.partial( + check_polygon_selector, draw_bounding_box=draw_bounding_box) + # Simple polygon expected_result = [(50, 50), (150, 50), (50, 150)] - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 50)) - check_polygon_selector(event_sequence, expected_result, 1) + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 50), + ] + check_selector(event_sequence, expected_result, 1) # Move first vertex before completing the polygon. expected_result = [(75, 50), (150, 50), (50, 150)] - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + [('on_key_press', dict(key='control')), - ('onmove', dict(xdata=50, ydata=50)), - ('press', dict(xdata=50, ydata=50)), - ('onmove', dict(xdata=75, ydata=50)), - ('release', dict(xdata=75, ydata=50)), - ('on_key_release', dict(key='control'))] - + polygon_place_vertex(50, 150) - + polygon_place_vertex(75, 50)) - check_polygon_selector(event_sequence, expected_result, 1) + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + ('on_key_press', dict(key='control')), + ('onmove', dict(xdata=50, ydata=50)), + ('press', dict(xdata=50, ydata=50)), + ('onmove', dict(xdata=75, ydata=50)), + ('release', dict(xdata=75, ydata=50)), + ('on_key_release', dict(key='control')), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(75, 50), + ] + check_selector(event_sequence, expected_result, 1) # Move first two vertices at once before completing the polygon. expected_result = [(50, 75), (150, 75), (50, 150)] - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + [('on_key_press', dict(key='shift')), - ('onmove', dict(xdata=100, ydata=100)), - ('press', dict(xdata=100, ydata=100)), - ('onmove', dict(xdata=100, ydata=125)), - ('release', dict(xdata=100, ydata=125)), - ('on_key_release', dict(key='shift'))] - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 75)) - check_polygon_selector(event_sequence, expected_result, 1) + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + ('on_key_press', dict(key='shift')), + ('onmove', dict(xdata=100, ydata=100)), + ('press', dict(xdata=100, ydata=100)), + ('onmove', dict(xdata=100, ydata=125)), + ('release', dict(xdata=100, ydata=125)), + ('on_key_release', dict(key='shift')), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 75), + ] + check_selector(event_sequence, expected_result, 1) # Move first vertex after completing the polygon. expected_result = [(75, 50), (150, 50), (50, 150)] - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 50) - + [('onmove', dict(xdata=50, ydata=50)), - ('press', dict(xdata=50, ydata=50)), - ('onmove', dict(xdata=75, ydata=50)), - ('release', dict(xdata=75, ydata=50))]) - check_polygon_selector(event_sequence, expected_result, 2) + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 50), + ('onmove', dict(xdata=50, ydata=50)), + ('press', dict(xdata=50, ydata=50)), + ('onmove', dict(xdata=75, ydata=50)), + ('release', dict(xdata=75, ydata=50)), + ] + check_selector(event_sequence, expected_result, 2) # Move all vertices after completing the polygon. expected_result = [(75, 75), (175, 75), (75, 175)] - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 50) - + [('on_key_press', dict(key='shift')), - ('onmove', dict(xdata=100, ydata=100)), - ('press', dict(xdata=100, ydata=100)), - ('onmove', dict(xdata=125, ydata=125)), - ('release', dict(xdata=125, ydata=125)), - ('on_key_release', dict(key='shift'))]) - check_polygon_selector(event_sequence, expected_result, 2) + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 50), + ('on_key_press', dict(key='shift')), + ('onmove', dict(xdata=100, ydata=100)), + ('press', dict(xdata=100, ydata=100)), + ('onmove', dict(xdata=125, ydata=125)), + ('release', dict(xdata=125, ydata=125)), + ('on_key_release', dict(key='shift')), + ] + check_selector(event_sequence, expected_result, 2) # Try to move a vertex and move all before placing any vertices. expected_result = [(50, 50), (150, 50), (50, 150)] - event_sequence = ([('on_key_press', dict(key='control')), - ('onmove', dict(xdata=100, ydata=100)), - ('press', dict(xdata=100, ydata=100)), - ('onmove', dict(xdata=125, ydata=125)), - ('release', dict(xdata=125, ydata=125)), - ('on_key_release', dict(key='control')), - ('on_key_press', dict(key='shift')), - ('onmove', dict(xdata=100, ydata=100)), - ('press', dict(xdata=100, ydata=100)), - ('onmove', dict(xdata=125, ydata=125)), - ('release', dict(xdata=125, ydata=125)), - ('on_key_release', dict(key='shift'))] - + polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 50)) - check_polygon_selector(event_sequence, expected_result, 1) + event_sequence = [ + ('on_key_press', dict(key='control')), + ('onmove', dict(xdata=100, ydata=100)), + ('press', dict(xdata=100, ydata=100)), + ('onmove', dict(xdata=125, ydata=125)), + ('release', dict(xdata=125, ydata=125)), + ('on_key_release', dict(key='control')), + ('on_key_press', dict(key='shift')), + ('onmove', dict(xdata=100, ydata=100)), + ('press', dict(xdata=100, ydata=100)), + ('onmove', dict(xdata=125, ydata=125)), + ('release', dict(xdata=125, ydata=125)), + ('on_key_release', dict(key='shift')), + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 50), + ] + check_selector(event_sequence, expected_result, 1) # Try to place vertex out-of-bounds, then reset, and start a new polygon. expected_result = [(50, 50), (150, 50), (50, 150)] - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(250, 50) - + [('on_key_press', dict(key='escape')), - ('on_key_release', dict(key='escape'))] - + polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 50)) - check_polygon_selector(event_sequence, expected_result, 1) - - -def test_polygon_selector_set_props_handle_props(): - ax = get_ax() - - ax._selections_count = 0 - - def onselect(vertices): - ax._selections_count += 1 - ax._current_result = vertices - - tool = widgets.PolygonSelector(ax, onselect, + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(250, 50), + ('on_key_press', dict(key='escape')), + ('on_key_release', dict(key='escape')), + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 50), + ] + check_selector(event_sequence, expected_result, 1) + + +@pytest.mark.parametrize('draw_bounding_box', [False, True]) +def test_polygon_selector_set_props_handle_props(ax, draw_bounding_box): + tool = widgets.PolygonSelector(ax, onselect=noop, props=dict(color='b', alpha=0.2), - handle_props=dict(alpha=0.5)) + handle_props=dict(alpha=0.5), + draw_bounding_box=draw_bounding_box) - event_sequence = (polygon_place_vertex(50, 50) - + polygon_place_vertex(150, 50) - + polygon_place_vertex(50, 150) - + polygon_place_vertex(50, 50)) + event_sequence = [ + *polygon_place_vertex(50, 50), + *polygon_place_vertex(150, 50), + *polygon_place_vertex(50, 150), + *polygon_place_vertex(50, 50), + ] for (etype, event_args) in event_sequence: do_event(tool, etype, **event_args) @@ -1243,63 +1527,21 @@ def onselect(vertices): assert artist.get_alpha() == 0.3 -@pytest.mark.parametrize( - "horizOn, vertOn", - [(True, True), (True, False), (False, True)], -) -def test_MultiCursor(horizOn, vertOn): - fig, (ax1, ax2, ax3) = plt.subplots(3, sharex=True) - - # useblit=false to avoid having to draw the figure to cache the renderer - multi = widgets.MultiCursor( - fig.canvas, (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn - ) - - # Only two of the axes should have a line drawn on them. - if vertOn: - assert len(multi.vlines) == 2 - if horizOn: - assert len(multi.hlines) == 2 - - # mock a motion_notify_event - # Can't use `do_event` as that helper requires the widget - # to have a single .ax attribute. - event = mock_event(ax1, xdata=.5, ydata=.25) - multi.onmove(event) - - # the lines in the first two ax should both move - for l in multi.vlines: - assert l.get_xdata() == (.5, .5) - for l in multi.hlines: - assert l.get_ydata() == (.25, .25) - - # test a move event in an axes not part of the MultiCursor - # the lines in ax1 and ax2 should not have moved. - event = mock_event(ax3, xdata=.75, ydata=.75) - multi.onmove(event) - for l in multi.vlines: - assert l.get_xdata() == (.5, .5) - for l in multi.hlines: - assert l.get_ydata() == (.25, .25) - - @check_figures_equal() def test_rect_visibility(fig_test, fig_ref): # Check that requesting an invisible selector makes it invisible ax_test = fig_test.subplots() _ = fig_ref.subplots() - def onselect(verts): - pass - - tool = widgets.RectangleSelector(ax_test, onselect, + tool = widgets.RectangleSelector(ax_test, onselect=noop, props={'visible': False}) tool.extents = (0.2, 0.8, 0.3, 0.7) # Change the order that the extra point is inserted in @pytest.mark.parametrize('idx', [1, 2, 3]) -def test_polygon_selector_remove(idx): +@pytest.mark.parametrize('draw_bounding_box', [False, True]) +def test_polygon_selector_remove(idx, draw_bounding_box): verts = [(50, 50), (150, 50), (50, 150)] event_sequence = [polygon_place_vertex(*verts[0]), polygon_place_vertex(*verts[1]), @@ -1312,40 +1554,176 @@ def test_polygon_selector_remove(idx): event_sequence.append(polygon_remove_vertex(200, 200)) # Flatten list of lists event_sequence = sum(event_sequence, []) - check_polygon_selector(event_sequence, verts, 2) + check_polygon_selector(event_sequence, verts, 2, + draw_bounding_box=draw_bounding_box) -def test_polygon_selector_remove_first_point(): +@pytest.mark.parametrize('draw_bounding_box', [False, True]) +def test_polygon_selector_remove_first_point(draw_bounding_box): verts = [(50, 50), (150, 50), (50, 150)] - event_sequence = (polygon_place_vertex(*verts[0]) + - polygon_place_vertex(*verts[1]) + - polygon_place_vertex(*verts[2]) + - polygon_place_vertex(*verts[0]) + - polygon_remove_vertex(*verts[0])) - check_polygon_selector(event_sequence, verts[1:], 2) - - -def test_polygon_selector_redraw(): + event_sequence = [ + *polygon_place_vertex(*verts[0]), + *polygon_place_vertex(*verts[1]), + *polygon_place_vertex(*verts[2]), + *polygon_place_vertex(*verts[0]), + *polygon_remove_vertex(*verts[0]), + ] + check_polygon_selector(event_sequence, verts[1:], 2, + draw_bounding_box=draw_bounding_box) + + +@pytest.mark.parametrize('draw_bounding_box', [False, True]) +def test_polygon_selector_redraw(ax, draw_bounding_box): verts = [(50, 50), (150, 50), (50, 150)] - event_sequence = (polygon_place_vertex(*verts[0]) + - polygon_place_vertex(*verts[1]) + - polygon_place_vertex(*verts[2]) + - polygon_place_vertex(*verts[0]) + - # Polygon completed, now remove first two verts - polygon_remove_vertex(*verts[1]) + - polygon_remove_vertex(*verts[2]) + - # At this point the tool should be reset so we can add - # more vertices - polygon_place_vertex(*verts[1])) - - ax = get_ax() - - def onselect(vertices): - pass - - tool = widgets.PolygonSelector(ax, onselect) + event_sequence = [ + *polygon_place_vertex(*verts[0]), + *polygon_place_vertex(*verts[1]), + *polygon_place_vertex(*verts[2]), + *polygon_place_vertex(*verts[0]), + # Polygon completed, now remove first two verts. + *polygon_remove_vertex(*verts[1]), + *polygon_remove_vertex(*verts[2]), + # At this point the tool should be reset so we can add more vertices. + *polygon_place_vertex(*verts[1]), + ] + + tool = widgets.PolygonSelector(ax, onselect=noop, + draw_bounding_box=draw_bounding_box) for (etype, event_args) in event_sequence: do_event(tool, etype, **event_args) # After removing two verts, only one remains, and the # selector should be automatically resete assert tool.verts == verts[0:2] + + +@pytest.mark.parametrize('draw_bounding_box', [False, True]) +@check_figures_equal(extensions=['png']) +def test_polygon_selector_verts_setter(fig_test, fig_ref, draw_bounding_box): + verts = [(0.1, 0.4), (0.5, 0.9), (0.3, 0.2)] + ax_test = fig_test.add_subplot() + + tool_test = widgets.PolygonSelector( + ax_test, onselect=noop, draw_bounding_box=draw_bounding_box) + tool_test.verts = verts + assert tool_test.verts == verts + + ax_ref = fig_ref.add_subplot() + tool_ref = widgets.PolygonSelector( + ax_ref, onselect=noop, draw_bounding_box=draw_bounding_box) + event_sequence = [ + *polygon_place_vertex(*verts[0]), + *polygon_place_vertex(*verts[1]), + *polygon_place_vertex(*verts[2]), + *polygon_place_vertex(*verts[0]), + ] + for (etype, event_args) in event_sequence: + do_event(tool_ref, etype, **event_args) + + +def test_polygon_selector_box(ax): + # Create a diamond shape + verts = [(20, 0), (0, 20), (20, 40), (40, 20)] + event_sequence = [ + *polygon_place_vertex(*verts[0]), + *polygon_place_vertex(*verts[1]), + *polygon_place_vertex(*verts[2]), + *polygon_place_vertex(*verts[3]), + *polygon_place_vertex(*verts[0]), + ] + + # Create selector + tool = widgets.PolygonSelector(ax, onselect=noop, draw_bounding_box=True) + for (etype, event_args) in event_sequence: + do_event(tool, etype, **event_args) + + # In order to trigger the correct callbacks, trigger events on the canvas + # instead of the individual tools + t = ax.transData + canvas = ax.figure.canvas + + # Scale to half size using the top right corner of the bounding box + MouseEvent( + "button_press_event", canvas, *t.transform((40, 40)), 1)._process() + MouseEvent( + "motion_notify_event", canvas, *t.transform((20, 20)))._process() + MouseEvent( + "button_release_event", canvas, *t.transform((20, 20)), 1)._process() + np.testing.assert_allclose( + tool.verts, [(10, 0), (0, 10), (10, 20), (20, 10)]) + + # Move using the center of the bounding box + MouseEvent( + "button_press_event", canvas, *t.transform((10, 10)), 1)._process() + MouseEvent( + "motion_notify_event", canvas, *t.transform((30, 30)))._process() + MouseEvent( + "button_release_event", canvas, *t.transform((30, 30)), 1)._process() + np.testing.assert_allclose( + tool.verts, [(30, 20), (20, 30), (30, 40), (40, 30)]) + + # Remove a point from the polygon and check that the box extents update + np.testing.assert_allclose( + tool._box.extents, (20.0, 40.0, 20.0, 40.0)) + + MouseEvent( + "button_press_event", canvas, *t.transform((30, 20)), 3)._process() + MouseEvent( + "button_release_event", canvas, *t.transform((30, 20)), 3)._process() + np.testing.assert_allclose( + tool.verts, [(20, 30), (30, 40), (40, 30)]) + np.testing.assert_allclose( + tool._box.extents, (20.0, 40.0, 30.0, 40.0)) + + +@pytest.mark.parametrize("horizOn", [False, True]) +@pytest.mark.parametrize("vertOn", [False, True]) +def test_MultiCursor(horizOn, vertOn): + (ax1, ax3) = plt.figure().subplots(2, sharex=True) + ax2 = plt.figure().subplots() + + # useblit=false to avoid having to draw the figure to cache the renderer + multi = widgets.MultiCursor( + None, (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn + ) + + # Only two of the axes should have a line drawn on them. + assert len(multi.vlines) == 2 + assert len(multi.hlines) == 2 + + # mock a motion_notify_event + # Can't use `do_event` as that helper requires the widget + # to have a single .ax attribute. + event = mock_event(ax1, xdata=.5, ydata=.25) + multi.onmove(event) + # force a draw + draw event to exercise clear + ax1.figure.canvas.draw() + + # the lines in the first two ax should both move + for l in multi.vlines: + assert l.get_xdata() == (.5, .5) + for l in multi.hlines: + assert l.get_ydata() == (.25, .25) + # The relevant lines get turned on after move. + assert len([line for line in multi.vlines if line.get_visible()]) == ( + 2 if vertOn else 0) + assert len([line for line in multi.hlines if line.get_visible()]) == ( + 2 if horizOn else 0) + + # After toggling settings, the opposite lines should be visible after move. + multi.horizOn = not multi.horizOn + multi.vertOn = not multi.vertOn + event = mock_event(ax1, xdata=.5, ydata=.25) + multi.onmove(event) + assert len([line for line in multi.vlines if line.get_visible()]) == ( + 0 if vertOn else 2) + assert len([line for line in multi.hlines if line.get_visible()]) == ( + 0 if horizOn else 2) + + # test a move event in an Axes not part of the MultiCursor + # the lines in ax1 and ax2 should not have moved. + event = mock_event(ax3, xdata=.75, ydata=.75) + multi.onmove(event) + for l in multi.vlines: + assert l.get_xdata() == (.5, .5) + for l in multi.hlines: + assert l.get_ydata() == (.25, .25) diff --git a/lib/matplotlib/tests/tinypages/range6.py b/lib/matplotlib/tests/tinypages/range6.py index fa5d035e4ab2..b6655ae07e1f 100644 --- a/lib/matplotlib/tests/tinypages/range6.py +++ b/lib/matplotlib/tests/tinypages/range6.py @@ -11,3 +11,10 @@ def range6(): plt.figure() plt.plot(range(6)) plt.show() + + +def range10(): + """The function that should be executed.""" + plt.figure() + plt.plot(range(10)) + plt.show() diff --git a/lib/matplotlib/tests/tinypages/some_plots.rst b/lib/matplotlib/tests/tinypages/some_plots.rst index 6f90c3976044..dd1f79892b0e 100644 --- a/lib/matplotlib/tests/tinypages/some_plots.rst +++ b/lib/matplotlib/tests/tinypages/some_plots.rst @@ -120,11 +120,9 @@ Plot 14 uses ``include-source``: # Only a comment -Plot 15 uses an external file with the plot commands and a caption (the -encoding is ignored and just verifies the deprecation is not broken): +Plot 15 uses an external file with the plot commands and a caption: .. plot:: range4.py - :encoding: utf-8 This is the caption for plot 15. @@ -168,8 +166,11 @@ scenario: plt.figure() plt.plot(range(4)) - + Plot 21 is generated via an include directive: .. include:: included_plot_21.rst +Plot 22 uses a different specific function in a file with plot commands: + +.. plot:: range6.py range10 diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 62aca98a32b6..54d724b328ec 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -29,10 +29,9 @@ from tempfile import TemporaryDirectory import numpy as np -from packaging.version import parse as parse_version import matplotlib as mpl -from matplotlib import _api, cbook, dviread, rcParams +from matplotlib import _api, cbook, dviread _log = logging.getLogger(__name__) @@ -62,75 +61,92 @@ class TexManager: """ texcache = os.path.join(mpl.get_cachedir(), 'tex.cache') - _grey_arrayd = {} - _font_family = 'serif' + _font_families = ('serif', 'sans-serif', 'cursive', 'monospace') - _font_info = { - 'new century schoolbook': ('pnc', r'\renewcommand{\rmdefault}{pnc}'), - 'bookman': ('pbk', r'\renewcommand{\rmdefault}{pbk}'), - 'times': ('ptm', r'\usepackage{mathptmx}'), - 'palatino': ('ppl', r'\usepackage{mathpazo}'), - 'zapf chancery': ('pzc', r'\usepackage{chancery}'), - 'cursive': ('pzc', r'\usepackage{chancery}'), - 'charter': ('pch', r'\usepackage{charter}'), - 'serif': ('cmr', ''), - 'sans-serif': ('cmss', ''), - 'helvetica': ('phv', r'\usepackage{helvet}'), - 'avant garde': ('pag', r'\usepackage{avant}'), - 'courier': ('pcr', r'\usepackage{courier}'), + _font_preambles = { + 'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}', + 'bookman': r'\renewcommand{\rmdefault}{pbk}', + 'times': r'\usepackage{mathptmx}', + 'palatino': r'\usepackage{mathpazo}', + 'zapf chancery': r'\usepackage{chancery}', + 'cursive': r'\usepackage{chancery}', + 'charter': r'\usepackage{charter}', + 'serif': '', + 'sans-serif': '', + 'helvetica': r'\usepackage{helvet}', + 'avant garde': r'\usepackage{avant}', + 'courier': r'\usepackage{courier}', # Loading the type1ec package ensures that cm-super is installed, which - # is necessary for unicode computer modern. (It also allows the use of + # is necessary for Unicode computer modern. (It also allows the use of # computer modern at arbitrary sizes, but that's just a side effect.) - 'monospace': ('cmtt', r'\usepackage{type1ec}'), - 'computer modern roman': ('cmr', r'\usepackage{type1ec}'), - 'computer modern sans serif': ('cmss', r'\usepackage{type1ec}'), - 'computer modern typewriter': ('cmtt', r'\usepackage{type1ec}')} + 'monospace': r'\usepackage{type1ec}', + 'computer modern roman': r'\usepackage{type1ec}', + 'computer modern sans serif': r'\usepackage{type1ec}', + 'computer modern typewriter': r'\usepackage{type1ec}', + } _font_types = { - 'new century schoolbook': 'serif', 'bookman': 'serif', - 'times': 'serif', 'palatino': 'serif', 'charter': 'serif', - 'computer modern roman': 'serif', 'zapf chancery': 'cursive', - 'helvetica': 'sans-serif', 'avant garde': 'sans-serif', + 'new century schoolbook': 'serif', + 'bookman': 'serif', + 'times': 'serif', + 'palatino': 'serif', + 'zapf chancery': 'cursive', + 'charter': 'serif', + 'helvetica': 'sans-serif', + 'avant garde': 'sans-serif', + 'courier': 'monospace', + 'computer modern roman': 'serif', 'computer modern sans serif': 'sans-serif', - 'courier': 'monospace', 'computer modern typewriter': 'monospace'} - - grey_arrayd = _api.deprecate_privatize_attribute("3.5") - font_family = _api.deprecate_privatize_attribute("3.5") - font_families = _api.deprecate_privatize_attribute("3.5") - font_info = _api.deprecate_privatize_attribute("3.5") + 'computer modern typewriter': 'monospace', + } @functools.lru_cache() # Always return the same instance. def __new__(cls): Path(cls.texcache).mkdir(parents=True, exist_ok=True) return object.__new__(cls) + @_api.deprecated("3.6") def get_font_config(self): - ff = rcParams['font.family'] + preamble, font_cmd = self._get_font_preamble_and_command() + # Add a hash of the latex preamble to fontconfig so that the + # correct png is selected for strings rendered with same font and dpi + # even if the latex preamble changes within the session + preambles = preamble + font_cmd + self.get_custom_preamble() + return hashlib.md5(preambles.encode('utf-8')).hexdigest() + + @classmethod + def _get_font_family_and_reduced(cls): + """Return the font family name and whether the font is reduced.""" + ff = mpl.rcParams['font.family'] ff_val = ff[0].lower() if len(ff) == 1 else None - reduced_notation = False - if len(ff) == 1 and ff_val in self._font_families: - self._font_family = ff_val - elif len(ff) == 1 and ff_val in self._font_info: - reduced_notation = True - self._font_family = self._font_types[ff_val] + if len(ff) == 1 and ff_val in cls._font_families: + return ff_val, False + elif len(ff) == 1 and ff_val in cls._font_preambles: + return cls._font_types[ff_val], True else: _log.info('font.family must be one of (%s) when text.usetex is ' 'True. serif will be used by default.', - ', '.join(self._font_families)) - self._font_family = 'serif' - - fontconfig = [self._font_family] - fonts = {} - for font_family in self._font_families: - if reduced_notation and self._font_family == font_family: - fonts[font_family] = self._font_info[ff_val] + ', '.join(cls._font_families)) + return 'serif', False + + @classmethod + def _get_font_preamble_and_command(cls): + requested_family, is_reduced_font = cls._get_font_family_and_reduced() + + preambles = {} + for font_family in cls._font_families: + if is_reduced_font and font_family == requested_family: + preambles[font_family] = cls._font_preambles[ + mpl.rcParams['font.family'][0].lower()] else: - for font in rcParams['font.' + font_family]: - if font.lower() in self._font_info: - fonts[font_family] = self._font_info[font.lower()] + for font in mpl.rcParams['font.' + font_family]: + if font.lower() in cls._font_preambles: + preambles[font_family] = \ + cls._font_preambles[font.lower()] _log.debug( 'family: %s, font: %s, info: %s', - font_family, font, self._font_info[font.lower()]) + font_family, font, + cls._font_preambles[font.lower()]) break else: _log.debug('%s font is not compatible with usetex.', @@ -139,99 +155,105 @@ def get_font_config(self): _log.info('No LaTeX-compatible font found for the %s font' 'family in rcParams. Using default.', font_family) - fonts[font_family] = self._font_info[font_family] - fontconfig.append(fonts[font_family][0]) - # Add a hash of the latex preamble to fontconfig so that the - # correct png is selected for strings rendered with same font and dpi - # even if the latex preamble changes within the session - preamble_bytes = self.get_custom_preamble().encode('utf-8') - fontconfig.append(hashlib.md5(preamble_bytes).hexdigest()) + preambles[font_family] = cls._font_preambles[font_family] # The following packages and commands need to be included in the latex # file's preamble: - cmd = {fonts[family][1] + cmd = {preambles[family] for family in ['serif', 'sans-serif', 'monospace']} - if self._font_family == 'cursive': - cmd.add(fonts['cursive'][1]) + if requested_family == 'cursive': + cmd.add(preambles['cursive']) cmd.add(r'\usepackage{type1cm}') - self._font_preamble = '\n'.join(sorted(cmd)) - - return ''.join(fontconfig) - - def get_basefile(self, tex, fontsize, dpi=None): + preamble = '\n'.join(sorted(cmd)) + fontcmd = (r'\sffamily' if requested_family == 'sans-serif' else + r'\ttfamily' if requested_family == 'monospace' else + r'\rmfamily') + return preamble, fontcmd + + @classmethod + def get_basefile(cls, tex, fontsize, dpi=None): """ Return a filename based on a hash of the string, fontsize, and dpi. """ - s = ''.join([tex, self.get_font_config(), '%f' % fontsize, - self.get_custom_preamble(), str(dpi or '')]) - return os.path.join( - self.texcache, hashlib.md5(s.encode('utf-8')).hexdigest()) + src = cls._get_tex_source(tex, fontsize) + str(dpi) + filehash = hashlib.md5(src.encode('utf-8')).hexdigest() + filepath = Path(cls.texcache) - def get_font_preamble(self): + num_letters, num_levels = 2, 2 + for i in range(0, num_letters*num_levels, num_letters): + filepath = filepath / Path(filehash[i:i+2]) + + filepath.mkdir(parents=True, exist_ok=True) + return os.path.join(filepath, filehash) + + @classmethod + def get_font_preamble(cls): """ Return a string containing font configuration for the tex preamble. """ - return self._font_preamble + font_preamble, command = cls._get_font_preamble_and_command() + return font_preamble - def get_custom_preamble(self): + @classmethod + def get_custom_preamble(cls): """Return a string containing user additions to the tex preamble.""" - return rcParams['text.latex.preamble'] + return mpl.rcParams['text.latex.preamble'] - def _get_preamble(self): + @classmethod + def _get_tex_source(cls, tex, fontsize): + """Return the complete TeX source for processing a TeX string.""" + font_preamble, fontcmd = cls._get_font_preamble_and_command() + baselineskip = 1.25 * fontsize return "\n".join([ r"\documentclass{article}", - # Pass-through \mathdefault, which is used in non-usetex mode to - # use the default text font but was historically suppressed in - # usetex mode. + r"% Pass-through \mathdefault, which is used in non-usetex mode", + r"% to use the default text font but was historically suppressed", + r"% in usetex mode.", r"\newcommand{\mathdefault}[1]{#1}", - self._font_preamble, + font_preamble, r"\usepackage[utf8]{inputenc}", r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", - # geometry is loaded before the custom preamble as convert_psfrags - # relies on a custom preamble to change the geometry. + r"% geometry is loaded before the custom preamble as ", + r"% convert_psfrags relies on a custom preamble to change the ", + r"% geometry.", r"\usepackage[papersize=72in, margin=1in]{geometry}", - self.get_custom_preamble(), - # Use `underscore` package to take care of underscores in text - # The [strings] option allows to use underscores in file names + cls.get_custom_preamble(), + r"% Use `underscore` package to take care of underscores in text.", + r"% The [strings] option allows to use underscores in file names.", _usepackage_if_not_loaded("underscore", option="strings"), - # Custom packages (e.g. newtxtext) may already have loaded textcomp - # with different options. + r"% Custom packages (e.g. newtxtext) may already have loaded ", + r"% textcomp with different options.", _usepackage_if_not_loaded("textcomp"), + r"\pagestyle{empty}", + r"\begin{document}", + r"% The empty hbox ensures that a page is printed even for empty", + r"% inputs, except when using psfrag which gets confused by it.", + r"% matplotlibbaselinemarker is used by dviread to detect the", + r"% last line's baseline.", + rf"\fontsize{{{fontsize}}}{{{baselineskip}}}%", + r"\ifdefined\psfrag\else\hbox{}\fi%", + rf"{{{fontcmd} {tex}}}%", + r"\end{document}", ]) - def make_tex(self, tex, fontsize): + @classmethod + def make_tex(cls, tex, fontsize): """ Generate a tex file to render the tex string at a specific font size. Return the file name. """ - basefile = self.get_basefile(tex, fontsize) - texfile = '%s.tex' % basefile - fontcmd = {'sans-serif': r'{\sffamily %s}', - 'monospace': r'{\ttfamily %s}'}.get(self._font_family, - r'{\rmfamily %s}') - - Path(texfile).write_text( - r""" -%s -\pagestyle{empty} -\begin{document} -%% The empty hbox ensures that a page is printed even for empty inputs, except -%% when using psfrag which gets confused by it. -\fontsize{%f}{%f}%% -\ifdefined\psfrag\else\hbox{}\fi%% -%s -\end{document} -""" % (self._get_preamble(), fontsize, fontsize * 1.25, fontcmd % tex), - encoding='utf-8') - + texfile = cls.get_basefile(tex, fontsize) + ".tex" + Path(texfile).write_text(cls._get_tex_source(tex, fontsize), + encoding='utf-8') return texfile - def _run_checked_subprocess(self, command, tex, *, cwd=None): + @classmethod + def _run_checked_subprocess(cls, command, tex, *, cwd=None): _log.debug(cbook._pformat_subprocess(command)) try: report = subprocess.check_output( - command, cwd=cwd if cwd is not None else self.texcache, + command, cwd=cwd if cwd is not None else cls.texcache, stderr=subprocess.STDOUT) except FileNotFoundError as exc: raise RuntimeError( @@ -241,24 +263,28 @@ def _run_checked_subprocess(self, command, tex, *, cwd=None): raise RuntimeError( '{prog} was not able to process the following string:\n' '{tex!r}\n\n' - 'Here is the full report generated by {prog}:\n' + 'Here is the full command invocation and its output:\n\n' + '{format_command}\n\n' '{exc}\n\n'.format( prog=command[0], + format_command=cbook._pformat_subprocess(command), tex=tex.encode('unicode_escape'), - exc=exc.output.decode('utf-8'))) from exc + exc=exc.output.decode('utf-8', 'backslashreplace')) + ) from None _log.debug(report) return report - def make_dvi(self, tex, fontsize): + @classmethod + def make_dvi(cls, tex, fontsize): """ Generate a dvi file containing latex's layout of tex string. Return the file name. """ - basefile = self.get_basefile(tex, fontsize) + basefile = cls.get_basefile(tex, fontsize) dvifile = '%s.dvi' % basefile if not os.path.exists(dvifile): - texfile = Path(self.make_tex(tex, fontsize)) + texfile = Path(cls.make_tex(tex, fontsize)) # Generate the dvi in a temporary directory to avoid race # conditions e.g. if multiple processes try to process the same tex # string at the same time. Having tmpdir be a subdirectory of the @@ -266,53 +292,59 @@ def make_dvi(self, tex, fontsize): # and thus replace() works atomically. It also allows referring to # the texfile with a relative path (for pathological MPLCONFIGDIRs, # the absolute path may contain characters (e.g. ~) that TeX does - # not support.) - with TemporaryDirectory(dir=Path(dvifile).parent) as tmpdir: - self._run_checked_subprocess( + # not support; n.b. relative paths cannot traverse parents, or it + # will be blocked when `openin_any = p` in texmf.cnf). + cwd = Path(dvifile).parent + with TemporaryDirectory(dir=cwd) as tmpdir: + tmppath = Path(tmpdir) + cls._run_checked_subprocess( ["latex", "-interaction=nonstopmode", "--halt-on-error", - f"../{texfile.name}"], tex, cwd=tmpdir) - (Path(tmpdir) / Path(dvifile).name).replace(dvifile) + f"--output-directory={tmppath.name}", + f"{texfile.name}"], tex, cwd=cwd) + (tmppath / Path(dvifile).name).replace(dvifile) return dvifile - def make_png(self, tex, fontsize, dpi): + @classmethod + def make_png(cls, tex, fontsize, dpi): """ Generate a png file containing latex's rendering of tex string. Return the file name. """ - basefile = self.get_basefile(tex, fontsize, dpi) + basefile = cls.get_basefile(tex, fontsize, dpi) pngfile = '%s.png' % basefile # see get_rgba for a discussion of the background if not os.path.exists(pngfile): - dvifile = self.make_dvi(tex, fontsize) + dvifile = cls.make_dvi(tex, fontsize) cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi), "-T", "tight", "-o", pngfile, dvifile] # When testing, disable FreeType rendering for reproducibility; but # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0 # mode, so for it we keep FreeType enabled; the image will be # slightly off. - bad_ver = parse_version("1.16") - if (getattr(mpl, "_called_from_pytest", False) - and mpl._get_executable_info("dvipng").version != bad_ver): + if (getattr(mpl, "_called_from_pytest", False) and + mpl._get_executable_info("dvipng").raw_version != "1.16"): cmd.insert(1, "--freetype0") - self._run_checked_subprocess(cmd, tex) + cls._run_checked_subprocess(cmd, tex) return pngfile - def get_grey(self, tex, fontsize=None, dpi=None): + @classmethod + def get_grey(cls, tex, fontsize=None, dpi=None): """Return the alpha channel.""" if not fontsize: - fontsize = rcParams['font.size'] + fontsize = mpl.rcParams['font.size'] if not dpi: - dpi = rcParams['savefig.dpi'] - key = tex, self.get_font_config(), fontsize, dpi - alpha = self._grey_arrayd.get(key) + dpi = mpl.rcParams['savefig.dpi'] + key = cls._get_tex_source(tex, fontsize), dpi + alpha = cls._grey_arrayd.get(key) if alpha is None: - pngfile = self.make_png(tex, fontsize, dpi) - rgba = mpl.image.imread(os.path.join(self.texcache, pngfile)) - self._grey_arrayd[key] = alpha = rgba[:, :, -1] + pngfile = cls.make_png(tex, fontsize, dpi) + rgba = mpl.image.imread(os.path.join(cls.texcache, pngfile)) + cls._grey_arrayd[key] = alpha = rgba[:, :, -1] return alpha - def get_rgba(self, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)): + @classmethod + def get_rgba(cls, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)): r""" Return latex's rendering of the tex string as an rgba array. @@ -322,17 +354,18 @@ def get_rgba(self, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)): >>> s = r"\TeX\ is $\displaystyle\sum_n\frac{-e^{i\pi}}{2^n}$!" >>> Z = texmanager.get_rgba(s, fontsize=12, dpi=80, rgb=(1, 0, 0)) """ - alpha = self.get_grey(tex, fontsize, dpi) + alpha = cls.get_grey(tex, fontsize, dpi) rgba = np.empty((*alpha.shape, 4)) rgba[..., :3] = mpl.colors.to_rgb(rgb) rgba[..., -1] = alpha return rgba - def get_text_width_height_descent(self, tex, fontsize, renderer=None): + @classmethod + def get_text_width_height_descent(cls, tex, fontsize, renderer=None): """Return width, height and descent of the text.""" if tex.strip() == '': return 0, 0, 0 - dvifile = self.make_dvi(tex, fontsize) + dvifile = cls.make_dvi(tex, fontsize) dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1 with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi: page, = dvi diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 89fe86035eee..d20e06f204d8 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -2,18 +2,20 @@ Classes for including text in a figure. """ +import functools import logging import math +from numbers import Real import weakref import numpy as np import matplotlib as mpl -from . import _api, artist, cbook, docstring +from . import _api, artist, cbook, _docstring from .artist import Artist from .font_manager import FontProperties from .patches import FancyArrowPatch, FancyBboxPatch, Rectangle -from .textpath import TextPath # Unused, but imported by others. +from .textpath import TextPath, TextToPath # noqa # Logically located here from .transforms import ( Affine2D, Bbox, BboxBase, BboxTransformTo, IdentityTransform, Transform) @@ -21,7 +23,7 @@ _log = logging.getLogger(__name__) -# Extracted from Text's method to serve as a function +@_api.deprecated("3.6") def get_rotation(rotation): """ Return *rotation* normalized to an angle between 0 and 360 degrees. @@ -88,8 +90,23 @@ def _get_textbox(text, renderer): return x_box, y_box, w_box, h_box -@docstring.interpd -@cbook._define_aliases({ +def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): + """Call ``renderer.get_text_width_height_descent``, caching the results.""" + # Cached based on a copy of fontprop so that later in-place mutations of + # the passed-in argument do not mess up the cache. + return _get_text_metrics_with_cache_impl( + weakref.ref(renderer), text, fontprop.copy(), ismath, dpi) + + +@functools.lru_cache(4096) +def _get_text_metrics_with_cache_impl( + renderer_ref, text, fontprop, ismath, dpi): + # dpi is unused, but participates in cache invalidation (via the renderer). + return renderer_ref().get_text_width_height_descent(text, fontprop, ismath) + + +@_docstring.interpd +@_api.define_aliases({ "color": ["c"], "fontfamily": ["family"], "fontproperties": ["font", "font_properties"], @@ -107,11 +124,12 @@ class Text(Artist): """Handle storing and drawing of text in window or data coordinates.""" zorder = 3 - _cached = cbook.maxdict(50) + _charsize_cache = dict() def __repr__(self): return "Text(%s, %s, %s)" % (self._x, self._y, repr(self._text)) + @_api.make_keyword_only("3.6", name="color") def __init__(self, x=0, y=0, text='', color=None, # defaults to rc params @@ -126,12 +144,20 @@ def __init__(self, wrap=False, transform_rotates_text=False, *, - parse_math=True, + parse_math=None, # defaults to rcParams['text.parse_math'] **kwargs ): """ Create a `.Text` instance at *x*, *y* with string *text*. + The text is aligned relative to the anchor point (*x*, *y*) according + to ``horizontalalignment`` (default: 'left') and ``verticalalignment`` + (default: 'bottom'). See also + :doc:`/gallery/text_labels_and_annotations/text_alignment`. + + While Text accepts the 'label' keyword argument, by default it is not + added to the handles of a legend. + Valid keyword arguments are: %(Text:kwdoc)s @@ -139,25 +165,58 @@ def __init__(self, super().__init__() self._x, self._y = x, y self._text = '' + self._reset_visual_defaults( + text=text, + color=color, + fontproperties=fontproperties, + usetex=usetex, + parse_math=parse_math, + wrap=wrap, + verticalalignment=verticalalignment, + horizontalalignment=horizontalalignment, + multialignment=multialignment, + rotation=rotation, + transform_rotates_text=transform_rotates_text, + linespacing=linespacing, + rotation_mode=rotation_mode, + ) + self.update(kwargs) + + def _reset_visual_defaults( + self, + text='', + color=None, + fontproperties=None, + usetex=None, + parse_math=None, + wrap=False, + verticalalignment='baseline', + horizontalalignment='left', + multialignment=None, + rotation=None, + transform_rotates_text=False, + linespacing=None, + rotation_mode=None, + ): self.set_text(text) self.set_color( color if color is not None else mpl.rcParams["text.color"]) self.set_fontproperties(fontproperties) self.set_usetex(usetex) - self.set_parse_math(parse_math) + self.set_parse_math(parse_math if parse_math is not None else + mpl.rcParams['text.parse_math']) self.set_wrap(wrap) self.set_verticalalignment(verticalalignment) self.set_horizontalalignment(horizontalalignment) self._multialignment = multialignment - self._rotation = rotation + self.set_rotation(rotation) self._transform_rotates_text = transform_rotates_text self._bbox_patch = None # a FancyBboxPatch instance self._renderer = None if linespacing is None: - linespacing = 1.2 # Maybe use rcParam later. - self._linespacing = linespacing + linespacing = 1.2 # Maybe use rcParam later. + self.set_linespacing(linespacing) self.set_rotation_mode(rotation_mode) - self.update(kwargs) def update(self, kwargs): # docstring inherited @@ -221,16 +280,45 @@ def _get_multialignment(self): else: return self._horizontalalignment + def _char_index_at(self, x): + """ + Calculate the index closest to the coordinate x in display space. + + The position of text[index] is assumed to be the sum of the widths + of all preceding characters text[:index]. + + This works only on single line texts. + """ + if not self._text: + return 0 + + text = self._text + + fontproperties = str(self._fontproperties) + if fontproperties not in Text._charsize_cache: + Text._charsize_cache[fontproperties] = dict() + + charsize_cache = Text._charsize_cache[fontproperties] + for char in set(text): + if char not in charsize_cache: + self.set_text(char) + bb = self.get_window_extent() + charsize_cache[char] = bb.x1 - bb.x0 + + self.set_text(text) + bb = self.get_window_extent() + + size_accum = np.cumsum([0] + [charsize_cache[x] for x in text]) + std_x = x - bb.x0 + return (np.abs(size_accum - std_x)).argmin() + def get_rotation(self): """Return the text angle in degrees between 0 and 360.""" if self.get_transform_rotates_text(): - angle = get_rotation(self._rotation) - x, y = self.get_unitless_position() - angles = [angle, ] - pts = [[x, y]] - return self.get_transform().transform_angles(angles, pts).item(0) + return self.get_transform().transform_angles( + [self._rotation], [self.get_unitless_position()]).item(0) else: - return get_rotation(self._rotation) # string_or_number -> number + return self._rotation def get_transform_rotates_text(self): """ @@ -272,31 +360,12 @@ def update_from(self, other): self._linespacing = other._linespacing self.stale = True - def _get_layout_cache_key(self, renderer=None): - """ - Return a hashable tuple of properties that lets `_get_layout` know - whether a previously computed layout can be reused. - """ - x, y = self.get_unitless_position() - renderer = renderer or self._renderer - return ( - x, y, self.get_text(), hash(self._fontproperties), - self._verticalalignment, self._horizontalalignment, - self._linespacing, - self._rotation, self._rotation_mode, self._transform_rotates_text, - self.figure.dpi, weakref.ref(renderer), - ) - def _get_layout(self, renderer): """ Return the extent (bbox) of the text together with multiple-alignment information. Note that it returns an extent of a rotated text when necessary. """ - key = self._get_layout_cache_key(renderer=renderer) - if key in self._cached: - return self._cached[key] - thisx, thisy = 0.0, 0.0 lines = self.get_text().split("\n") # Ensures lines is not empty. @@ -306,16 +375,17 @@ def _get_layout(self, renderer): ys = [] # Full vertical extent of font, including ascenders and descenders: - _, lp_h, lp_d = renderer.get_text_width_height_descent( - "lp", self._fontproperties, - ismath="TeX" if self.get_usetex() else False) + _, lp_h, lp_d = _get_text_metrics_with_cache( + renderer, "lp", self._fontproperties, + ismath="TeX" if self.get_usetex() else False, dpi=self.figure.dpi) min_dy = (lp_h - lp_d) * self._linespacing for i, line in enumerate(lines): clean_line, ismath = self._preprocess_math(line) if clean_line: - w, h, d = renderer.get_text_width_height_descent( - clean_line, self._fontproperties, ismath=ismath) + w, h, d = _get_text_metrics_with_cache( + renderer, clean_line, self._fontproperties, + ismath=ismath, dpi=self.figure.dpi) else: w = h = d = 0 @@ -352,7 +422,6 @@ def _get_layout(self, renderer): xmax = width ymax = 0 ymin = ys[-1] - descent # baseline of last line minus its descent - height = ymax - ymin # get the rotation matrix M = Affine2D().rotate_deg(self.get_rotation()) @@ -440,9 +509,7 @@ def _get_layout(self, renderer): # now rotate the positions around the first (x, y) position xys = M.transform(offset_layout) - (offsetx, offsety) - ret = bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent - self._cached[key] = ret - return ret + return bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent def set_bbox(self, rectprops): """ @@ -837,27 +904,6 @@ def get_position(self): # specified with 'set_x' and 'set_y'. return self._x, self._y - # When removing, also remove the hash(color) check in set_color() - @_api.deprecated("3.5") - def get_prop_tup(self, renderer=None): - """ - Return a hashable tuple of properties. - - Not intended to be human readable, but useful for backends who - want to cache derived information about text (e.g., layouts) and - need to know if the text has changed. - """ - x, y = self.get_unitless_position() - renderer = renderer or self._renderer - return (x, y, self.get_text(), self._color, - self._verticalalignment, self._horizontalalignment, - hash(self._fontproperties), - self._rotation, self._rotation_mode, - self._transform_rotates_text, - self.figure.dpi, weakref.ref(renderer), - self._linespacing - ) - def get_text(self): """Return the text string.""" return self._text @@ -882,8 +928,9 @@ def get_window_extent(self, renderer=None, dpi=None): A renderer is needed to compute the bounding box. If the artist has already been drawn, the renderer is cached; thus, it is only necessary to pass this argument when calling `get_window_extent` - before the first `draw`. In practice, it is usually easier to - trigger a draw first (e.g. by saving the figure). + before the first draw. In practice, it is usually easier to + trigger a draw first, e.g. by calling + `~.Figure.draw_without_rendering` or ``plt.show()``. dpi : float, optional The dpi value for computing the bbox, defaults to @@ -902,9 +949,11 @@ def get_window_extent(self, renderer=None, dpi=None): if renderer is not None: self._renderer = renderer if self._renderer is None: - self._renderer = self.figure._cachedRenderer + self._renderer = self.figure._get_renderer() if self._renderer is None: - raise RuntimeError('Cannot get window extent w/o renderer') + raise RuntimeError( + "Cannot get window extent of text w/o renderer. You likely " + "want to call 'figure.draw_without_rendering()' first.") with cbook._setattr_cm(self.figure, dpi=dpi): bbox, info, descent = self._get_layout(self._renderer) @@ -945,22 +994,18 @@ def set_color(self, color): # out at draw time for simplicity. if not cbook._str_equal(color, "auto"): mpl.colors._check_color_like(color=color) - # Make sure it is hashable, or get_prop_tup will fail (remove this once - # get_prop_tup is removed). - try: - hash(color) - except TypeError: - color = tuple(color) self._color = color self.stale = True def set_horizontalalignment(self, align): """ - Set the horizontal alignment to one of + Set the horizontal alignment relative to the anchor point. + + See also :doc:`/gallery/text_labels_and_annotations/text_alignment`. Parameters ---------- - align : {'center', 'right', 'left'} + align : {'left', 'center', 'right'} """ _api.check_in_list(['center', 'right', 'left'], align=align) self._horizontalalignment = align @@ -992,12 +1037,13 @@ def set_linespacing(self, spacing): ---------- spacing : float (multiple of font size) """ + _api.check_isinstance(Real, spacing=spacing) self._linespacing = spacing self.stale = True def set_fontfamily(self, fontname): """ - Set the font family. May be either a single string, or a list of + Set the font family. Can be either a single string, or a list of strings in decreasing priority. Each string may be either a real font name or a generic font class name. If the latter, the specific font names will be looked up in the corresponding rcParams. @@ -1057,7 +1103,7 @@ def set_fontsize(self, fontsize): ---------- fontsize : float or {'xx-small', 'x-small', 'small', 'medium', \ 'large', 'x-large', 'xx-large'} - If float, the fontsize in points. The string values denote sizes + If a float, the fontsize in points. The string values denote sizes relative to the default font size. See Also @@ -1178,7 +1224,15 @@ def set_rotation(self, s): The rotation angle in degrees in mathematically positive direction (counterclockwise). 'horizontal' equals 0, 'vertical' equals 90. """ - self._rotation = s + if isinstance(s, Real): + self._rotation = float(s) % 360 + elif cbook._str_equal(s, 'horizontal') or s is None: + self._rotation = 0. + elif cbook._str_equal(s, 'vertical'): + self._rotation = 90. + else: + raise ValueError("rotation must be 'vertical', 'horizontal' or " + f"a number, not {s}") self.stale = True def set_transform_rotates_text(self, t): @@ -1194,11 +1248,13 @@ def set_transform_rotates_text(self, t): def set_verticalalignment(self, align): """ - Set the vertical alignment. + Set the vertical alignment relative to the anchor point. + + See also :doc:`/gallery/text_labels_and_annotations/text_alignment`. Parameters ---------- - align : {'center', 'top', 'bottom', 'baseline', 'center_baseline'} + align : {'bottom', 'baseline', 'center', 'center_baseline', 'top'} """ _api.check_in_list( ['top', 'bottom', 'center', 'baseline', 'center_baseline'], @@ -1437,7 +1493,7 @@ def _get_xy_transform(self, renderer, s): elif isinstance(tr, Transform): return tr else: - raise RuntimeError("unknown return type ...") + raise RuntimeError("Unknown return type") elif isinstance(s, Artist): bbox = s.get_window_extent(renderer) return BboxTransformTo(bbox) @@ -1446,7 +1502,7 @@ def _get_xy_transform(self, renderer, s): elif isinstance(s, Transform): return s elif not isinstance(s, str): - raise RuntimeError("unknown coordinate type : %s" % s) + raise RuntimeError(f"Unknown coordinate type: {s!r}") if s == 'data': return self.axes.transData @@ -1458,7 +1514,7 @@ def _get_xy_transform(self, renderer, s): s_ = s.split() if len(s_) != 2: - raise ValueError("%s is not a recognized coordinate" % s) + raise ValueError(f"{s!r} is not a recognized coordinate") bbox0, xy0 = None, None @@ -1486,24 +1542,24 @@ def _get_xy_transform(self, renderer, s): ref_x, ref_y = xy0 if unit == "points": # dots per points - dpp = self.figure.get_dpi() / 72. + dpp = self.figure.dpi / 72 tr = Affine2D().scale(dpp) elif unit == "pixels": tr = Affine2D() elif unit == "fontsize": fontsize = self.get_size() - dpp = fontsize * self.figure.get_dpi() / 72. + dpp = fontsize * self.figure.dpi / 72 tr = Affine2D().scale(dpp) elif unit == "fraction": w, h = bbox0.size tr = Affine2D().scale(w, h) else: - raise ValueError("%s is not a recognized coordinate" % s) + raise ValueError(f"{unit!r} is not a recognized unit") return tr.translate(ref_x, ref_y) else: - raise ValueError("%s is not a recognized coordinate" % s) + raise ValueError(f"{s!r} is not a recognized coordinate") def _get_ref_xy(self, renderer): """ @@ -1529,12 +1585,11 @@ def set_annotation_clip(self, b): Parameters ---------- b : bool or None - - True: the annotation will only be drawn when ``self.xy`` is - inside the axes. - - False: the annotation will always be drawn regardless of its - position. - - None: the ``self.xy`` will be checked only if *xycoords* is - "data". + - True: The annotation will be clipped when ``self.xy`` is + outside the axes. + - False: The annotation will always be drawn. + - None: The annotation will be clipped when ``self.xy`` is + outside the axes and ``self.xycoords == "data"``. """ self._annotation_clip = b @@ -1551,8 +1606,10 @@ def _get_position_xy(self, renderer): x, y = self.xy return self._get_xy(renderer, x, y, self.xycoords) - def _check_xy(self, renderer): + def _check_xy(self, renderer=None): """Check whether the annotation at *xy_pixel* should be drawn.""" + if renderer is None: + renderer = self.figure._get_renderer() b = self.get_annotation_clip() if b or (b is None and self.xycoords == "data"): # check if self.xy is inside the axes. @@ -1641,8 +1698,8 @@ def __init__(self, text, xy, The position *(x, y)* to place the text at. The coordinate system is determined by *textcoords*. - xycoords : str or `.Artist` or `.Transform` or callable or \ -(float, float), default: 'data' + xycoords : single or two-tuple of str or `.Artist` or `.Transform` or \ +callable, default: 'data' The coordinate system that *xy* is given in. The following types of values are supported: @@ -1694,8 +1751,8 @@ def transform(renderer) -> Transform See :ref:`plotting-guide-annotation` for more details. - textcoords : str or `.Artist` or `.Transform` or callable or \ -(float, float), default: value of *xycoords* + textcoords : single or two-tuple of str or `.Artist` or `.Transform` \ +or callable, default: value of *xycoords* The coordinate system that *xytext* is given in. All *xycoords* values are valid as well as the following @@ -1760,18 +1817,18 @@ def transform(renderer) -> Transform *relpos*. It's a tuple of relative coordinates of the text box, where (0, 0) is the lower left corner and (1, 1) is the upper right corner. Values <0 and >1 are supported and specify points - outside the text box. By default (0.5, 0.5) the starting point is - centered in the text box. + outside the text box. By default (0.5, 0.5), so the starting point + is centered in the text box. annotation_clip : bool or None, default: None - Whether to draw the annotation when the annotation point *xy* is - outside the axes area. + Whether to clip (i.e. not draw) the annotation when the annotation + point *xy* is outside the axes area. - - If *True*, the annotation will only be drawn when *xy* is - within the axes. + - If *True*, the annotation will be clipped when *xy* is outside + the axes. - If *False*, the annotation will always be drawn. - - If *None*, the annotation will only be drawn when *xy* is - within the axes and *xycoords* is 'data'. + - If *None*, the annotation will be clipped when *xy* is outside + the axes and *xycoords* is 'data'. **kwargs Additional kwargs are passed to `~matplotlib.text.Text`. @@ -1890,32 +1947,30 @@ def update_positions(self, renderer): """ Update the pixel positions of the annotation text and the arrow patch. """ - x1, y1 = self._get_position_xy(renderer) # Annotated position. - # generate transformation, + # generate transformation self.set_transform(self._get_xy_transform(renderer, self.anncoords)) - if self.arrowprops is None: + arrowprops = self.arrowprops + if arrowprops is None: return bbox = Text.get_window_extent(self, renderer) - d = self.arrowprops.copy() - ms = d.pop("mutation_scale", self.get_size()) + arrow_end = x1, y1 = self._get_position_xy(renderer) # Annotated pos. + + ms = arrowprops.get("mutation_scale", self.get_size()) self.arrow_patch.set_mutation_scale(ms) - if "arrowstyle" not in d: + if "arrowstyle" not in arrowprops: # Approximately simulate the YAArrow. - # Pop its kwargs: - shrink = d.pop('shrink', 0.0) - width = d.pop('width', 4) - headwidth = d.pop('headwidth', 12) - # Ignore frac--it is useless. - frac = d.pop('frac', None) - if frac is not None: + shrink = arrowprops.get('shrink', 0.0) + width = arrowprops.get('width', 4) + headwidth = arrowprops.get('headwidth', 12) + if 'frac' in arrowprops: _api.warn_external( "'frac' option in 'arrowprops' is no longer supported;" " use 'headlength' to set the head length in points.") - headlength = d.pop('headlength', 12) + headlength = arrowprops.get('headlength', 12) # NB: ms is in pts stylekw = dict(head_length=headlength / ms, @@ -1937,29 +1992,25 @@ def update_positions(self, renderer): # adjust the starting point of the arrow relative to the textbox. # TODO : Rotation needs to be accounted. - relposx, relposy = self._arrow_relpos - x0 = bbox.x0 + bbox.width * relposx - y0 = bbox.y0 + bbox.height * relposy - - # The arrow will be drawn from (x0, y0) to (x1, y1). It will be first + arrow_begin = bbox.p0 + bbox.size * self._arrow_relpos + # The arrow is drawn from arrow_begin to arrow_end. It will be first # clipped by patchA and patchB. Then it will be shrunk by shrinkA and - # shrinkB (in points). If patch A is not set, self.bbox_patch is used. - self.arrow_patch.set_positions((x0, y0), (x1, y1)) - - if "patchA" in d: - self.arrow_patch.set_patchA(d.pop("patchA")) + # shrinkB (in points). If patchA is not set, self.bbox_patch is used. + self.arrow_patch.set_positions(arrow_begin, arrow_end) + + if "patchA" in arrowprops: + patchA = arrowprops["patchA"] + elif self._bbox_patch: + patchA = self._bbox_patch + elif self.get_text() == "": + patchA = None else: - if self._bbox_patch: - self.arrow_patch.set_patchA(self._bbox_patch) - else: - if self.get_text() == "": - self.arrow_patch.set_patchA(None) - return - pad = renderer.points_to_pixels(4) - r = Rectangle(xy=(bbox.x0 - pad / 2, bbox.y0 - pad / 2), - width=bbox.width + pad, height=bbox.height + pad, - transform=IdentityTransform(), clip_on=False) - self.arrow_patch.set_patchA(r) + pad = renderer.points_to_pixels(4) + patchA = Rectangle( + xy=(bbox.x0 - pad / 2, bbox.y0 - pad / 2), + width=bbox.width + pad, height=bbox.height + pad, + transform=IdentityTransform(), clip_on=False) + self.arrow_patch.set_patchA(patchA) @artist.allow_rasterization def draw(self, renderer): @@ -1972,7 +2023,7 @@ def draw(self, renderer): # FancyArrowPatch is correctly positioned. self.update_positions(renderer) self.update_bbox_position_size(renderer) - if self.arrow_patch is not None: # FancyArrowPatch + if self.arrow_patch is not None: # FancyArrowPatch if self.arrow_patch.figure is None and self.figure is not None: self.arrow_patch.figure = self.figure self.arrow_patch.draw(renderer) @@ -1989,7 +2040,7 @@ def get_window_extent(self, renderer=None): if renderer is not None: self._renderer = renderer if self._renderer is None: - self._renderer = self.figure._cachedRenderer + self._renderer = self.figure._get_renderer() if self._renderer is None: raise RuntimeError('Cannot get window extent w/o renderer') @@ -2003,11 +2054,11 @@ def get_window_extent(self, renderer=None): return Bbox.union(bboxes) - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer=None): # docstring inherited if not self._check_xy(renderer): return Bbox.null() return super().get_tightbbox(renderer) -docstring.interpd.update(Annotation=Annotation.__init__.__doc__) +_docstring.interpd.update(Annotation=Annotation.__init__.__doc__) diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index 4a192a443d13..e4bb791f6f93 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -1,15 +1,17 @@ from collections import OrderedDict -import functools import logging import urllib.parse import numpy as np -from matplotlib import _text_helpers, dviread, font_manager -from matplotlib.font_manager import FontProperties, get_font +from matplotlib import _api, _text_helpers, dviread +from matplotlib.font_manager import ( + FontProperties, get_font, fontManager as _fontManager +) from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_TARGET_LIGHT from matplotlib.mathtext import MathTextParser from matplotlib.path import Path +from matplotlib.texmanager import TexManager from matplotlib.transforms import Affine2D _log = logging.getLogger(__name__) @@ -29,8 +31,8 @@ def _get_font(self, prop): """ Find the `FT2Font` matching font properties *prop*, with its size set. """ - fname = font_manager.findfont(prop) - font = get_font(fname) + filenames = _fontManager._find_fonts_by_props(prop) + font = get_font(filenames) font.set_size(self.FONT_SCALE, self.DPI) return font @@ -44,14 +46,11 @@ def _get_char_id(self, font, ccode): return urllib.parse.quote(f"{font.postscript_name}-{ccode:x}") def get_text_width_height_descent(self, s, prop, ismath): + fontsize = prop.get_size_in_points() + if ismath == "TeX": - texmanager = self.get_texmanager() - fontsize = prop.get_size_in_points() - w, h, d = texmanager.get_text_width_height_descent(s, fontsize, - renderer=None) - return w, h, d + return TexManager().get_text_width_height_descent(s, fontsize) - fontsize = prop.get_size_in_points() scale = fontsize / self.FONT_SCALE if ismath: @@ -101,7 +100,7 @@ def get_text_path(self, prop, s, ismath=False): from those:: from matplotlib.path import Path - from matplotlib.textpath import TextToPath + from matplotlib.text import TextToPath from matplotlib.font_manager import FontProperties fp = FontProperties(family="Humor Sans", style="italic") @@ -119,14 +118,10 @@ def get_text_path(self, prop, s, ismath=False): glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) verts, codes = [], [] - for glyph_id, xposition, yposition, scale in glyph_info: verts1, codes1 = glyph_map[glyph_id] - if len(verts1): - verts1 = np.array(verts1) * scale + [xposition, yposition] - verts.extend(verts1) - codes.extend(codes1) - + verts.extend(verts1 * scale + [xposition, yposition]) + codes.extend(codes1) for verts1, codes1 in rects: verts.extend(verts1) codes.extend(codes1) @@ -155,11 +150,11 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, xpositions = [] glyph_ids = [] for item in _text_helpers.layout(s, font): - char_id = self._get_char_id(font, ord(item.char)) + char_id = self._get_char_id(item.ft_object, ord(item.char)) glyph_ids.append(char_id) xpositions.append(item.x) if char_id not in glyph_map: - glyph_map_new[char_id] = font.get_path() + glyph_map_new[char_id] = item.ft_object.get_path() ypositions = [0] * len(xpositions) sizes = [1.] * len(xpositions) @@ -220,10 +215,10 @@ def get_glyphs_mathtext(self, prop, s, glyph_map=None, return (list(zip(glyph_ids, xpositions, ypositions, sizes)), glyph_map_new, myrects) + @_api.deprecated("3.6", alternative="TexManager()") def get_texmanager(self): """Return the cached `~.texmanager.TexManager` instance.""" if self._texmanager is None: - from matplotlib.texmanager import TexManager self._texmanager = TexManager() return self._texmanager @@ -232,7 +227,7 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, """Convert the string *s* to vertices and codes using usetex mode.""" # Mostly borrowed from pdf backend. - dvifile = self.get_texmanager().make_dvi(s, self.FONT_SCALE) + dvifile = TexManager().make_dvi(s, self.FONT_SCALE) with dviread.Dvi(dvifile, self.DPI) as dvi: page, = dvi @@ -248,25 +243,29 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, # Gather font information and do some setup for combining # characters into strings. - for x1, y1, dvifont, glyph, width in page.text: - font, enc = self._get_ps_font_and_encoding(dvifont.texname) - char_id = self._get_char_id(font, glyph) - + for text in page.text: + font = get_font(text.font_path) + char_id = self._get_char_id(font, text.glyph) if char_id not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) - # See comments in _get_ps_font_and_encoding. - if enc is not None: - index = font.get_name_index(enc[glyph]) + glyph_name_or_index = text.glyph_name_or_index + if isinstance(glyph_name_or_index, str): + index = font.get_name_index(glyph_name_or_index) font.load_glyph(index, flags=LOAD_TARGET_LIGHT) - else: - font.load_char(glyph, flags=LOAD_TARGET_LIGHT) + elif isinstance(glyph_name_or_index, int): + self._select_native_charmap(font) + font.load_char( + glyph_name_or_index, flags=LOAD_TARGET_LIGHT) + else: # Should not occur. + raise TypeError(f"Glyph spec of unexpected type: " + f"{glyph_name_or_index!r}") glyph_map_new[char_id] = font.get_path() glyph_ids.append(char_id) - xpositions.append(x1) - ypositions.append(y1) - sizes.append(dvifont.size / self.FONT_SCALE) + xpositions.append(text.x) + ypositions.append(text.y) + sizes.append(text.font_size / self.FONT_SCALE) myrects = [] @@ -282,48 +281,21 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, glyph_map_new, myrects) @staticmethod - @functools.lru_cache(50) - def _get_ps_font_and_encoding(texname): - tex_font_map = dviread.PsfontsMap(dviread.find_tex_file('pdftex.map')) - psfont = tex_font_map[texname] - if psfont.filename is None: - raise ValueError( - f"No usable font file found for {psfont.psname} ({texname}). " - f"The font may lack a Type-1 version.") - - font = get_font(psfont.filename) - - if psfont.encoding: - # If psfonts.map specifies an encoding, use it: it gives us a - # mapping of glyph indices to Adobe glyph names; use it to convert - # dvi indices to glyph names and use the FreeType-synthesized - # unicode charmap to convert glyph names to glyph indices (with - # FT_Get_Name_Index/get_name_index), and load the glyph using - # FT_Load_Glyph/load_glyph. (That charmap has a coverage at least - # as good as, and possibly better than, the native charmaps.) - enc = dviread._parse_enc(psfont.encoding) - else: - # If psfonts.map specifies no encoding, the indices directly - # map to the font's "native" charmap; so don't use the - # FreeType-synthesized charmap but the native ones (we can't - # directly identify it but it's typically an Adobe charmap), and - # directly load the dvi glyph indices using FT_Load_Char/load_char. - for charmap_code in [ - 1094992451, # ADOBE_CUSTOM. - 1094995778, # ADOBE_STANDARD. - ]: - try: - font.select_charmap(charmap_code) - except (ValueError, RuntimeError): - pass - else: - break + def _select_native_charmap(font): + # Select the native charmap. (we can't directly identify it but it's + # typically an Adobe charmap). + for charmap_code in [ + 1094992451, # ADOBE_CUSTOM. + 1094995778, # ADOBE_STANDARD. + ]: + try: + font.select_charmap(charmap_code) + except (ValueError, RuntimeError): + pass else: - _log.warning("No supported encoding in font (%s).", - psfont.filename) - enc = None - - return font, enc + break + else: + _log.warning("No supported encoding in font (%s).", font.fname) text_to_path = TextToPath() @@ -369,7 +341,7 @@ def __init__(self, xy, s, size=None, prop=None, The following creates a path from the string "ABC" with Helvetica font face; and another path from the latex fraction 1/2:: - from matplotlib.textpath import TextPath + from matplotlib.text import TextPath from matplotlib.font_manager import FontProperties fp = FontProperties(family="Helvetica", style="italic") diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 7dc538a91a2b..db593838ea5f 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -33,9 +33,11 @@ `IndexLocator` Locator for index plots (e.g., where ``x = range(len(y))``). `NullLocator` No ticks. -`SymmetricalLogLocator` Locator for use with with the symlog norm; works like +`SymmetricalLogLocator` Locator for use with the symlog norm; works like `LogLocator` for the part outside of the threshold and adds 0 if inside the limits. +`AsinhLocator` Locator for use with the asinh norm, attempting to + space ticks approximately uniformly. `LogitLocator` Locator for logit scaling. `AutoMinorLocator` Locator for minor ticks when the axis is linear and the major ticks are uniformly spaced. Subdivides the major @@ -53,7 +55,7 @@ view limits from the data limits. If you want to override the default locator, use one of the above or a custom -locator and pass it to the x or y axis instance. The relevant methods are:: +locator and pass it to the x- or y-axis instance. The relevant methods are:: ax.xaxis.set_major_locator(xmajor_locator) ax.xaxis.set_minor_locator(xminor_locator) @@ -146,31 +148,38 @@ 'Locator', 'IndexLocator', 'FixedLocator', 'NullLocator', 'LinearLocator', 'LogLocator', 'AutoLocator', 'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator', - 'SymmetricalLogLocator', 'LogitLocator') + 'SymmetricalLogLocator', 'AsinhLocator', 'LogitLocator') class _DummyAxis: __name__ = "dummy" + # Once the deprecation elapses, replace dataLim and viewLim by plain + # _view_interval and _data_interval private tuples. + dataLim = _api.deprecate_privatize_attribute( + "3.6", alternative="get_data_interval() and set_data_interval()") + viewLim = _api.deprecate_privatize_attribute( + "3.6", alternative="get_view_interval() and set_view_interval()") + def __init__(self, minpos=0): - self.dataLim = mtransforms.Bbox.unit() - self.viewLim = mtransforms.Bbox.unit() + self._dataLim = mtransforms.Bbox.unit() + self._viewLim = mtransforms.Bbox.unit() self._minpos = minpos def get_view_interval(self): - return self.viewLim.intervalx + return self._viewLim.intervalx def set_view_interval(self, vmin, vmax): - self.viewLim.intervalx = vmin, vmax + self._viewLim.intervalx = vmin, vmax def get_minpos(self): return self._minpos def get_data_interval(self): - return self.dataLim.intervalx + return self._dataLim.intervalx def set_data_interval(self, vmin, vmax): - self.dataLim.intervalx = vmin, vmax + self._dataLim.intervalx = vmin, vmax def get_tick_space(self): # Just use the long-standing default of nbins==9 @@ -187,21 +196,6 @@ def create_dummy_axis(self, **kwargs): if self.axis is None: self.axis = _DummyAxis(**kwargs) - @_api.deprecated("3.5", alternative="`.Axis.set_view_interval`") - def set_view_interval(self, vmin, vmax): - self.axis.set_view_interval(vmin, vmax) - - @_api.deprecated("3.5", alternative="`.Axis.set_data_interval`") - def set_data_interval(self, vmin, vmax): - self.axis.set_data_interval(vmin, vmax) - - @_api.deprecated( - "3.5", - alternative="`.Axis.set_view_interval` and `.Axis.set_data_interval`") - def set_bounds(self, vmin, vmax): - self.set_view_interval(vmin, vmax) - self.set_data_interval(vmin, vmax) - class Formatter(TickHelper): """ @@ -254,7 +248,7 @@ def set_locs(self, locs): def fix_minus(s): """ Some classes may want to replace a hyphen for minus with the proper - unicode symbol (U+2212) for typographical correctness. This is a + Unicode symbol (U+2212) for typographical correctness. This is a helper method to perform such a replacement when it is enabled via :rc:`axes.unicode_minus`. """ @@ -344,9 +338,9 @@ class FormatStrFormatter(Formatter): The format string should have a single variable format (%) in it. It will be applied to the value (not the position) of the tick. - Negative numeric values will use a dash not a unicode minus, - use mathtext to get a unicode minus by wrappping the format specifier - with $ (e.g. "$%g$"). + Negative numeric values will use a dash, not a Unicode minus; use mathtext + to get a Unicode minus by wrapping the format specifier with $ (e.g. + "$%g$"). """ def __init__(self, fmt): self.fmt = fmt @@ -437,33 +431,12 @@ def __init__(self, useOffset=None, useMathText=None, useLocale=None): mpl.rcParams['axes.formatter.offset_threshold'] self.set_useOffset(useOffset) self._usetex = mpl.rcParams['text.usetex'] - if useMathText is None: - useMathText = mpl.rcParams['axes.formatter.use_mathtext'] - if useMathText is False: - try: - from matplotlib import font_manager - ufont = font_manager.findfont( - font_manager.FontProperties( - mpl.rcParams["font.family"] - ), - fallback_to_default=False, - ) - except ValueError: - ufont = None - - if ufont == str(cbook._get_data_path("fonts/ttf/cmr10.ttf")): - _api.warn_external( - "cmr10 font should ideally be used with " - "mathtext, set axes.formatter.use_mathtext to True" - ) self.set_useMathText(useMathText) self.orderOfMagnitude = 0 self.format = '' self._scientific = True self._powerlimits = mpl.rcParams['axes.formatter.limits'] - if useLocale is None: - useLocale = mpl.rcParams['axes.formatter.use_locale'] - self._useLocale = useLocale + self.set_useLocale(useLocale) def get_useOffset(self): """ @@ -542,7 +515,7 @@ def set_useLocale(self, val): def _format_maybe_minus_and_locale(self, fmt, arg): """ - Format *arg* with *fmt*, applying unicode minus and locale if desired. + Format *arg* with *fmt*, applying Unicode minus and locale if desired. """ return self.fix_minus(locale.format_string(fmt, (arg,), True) if self._useLocale else fmt % arg) @@ -570,6 +543,23 @@ def set_useMathText(self, val): """ if val is None: self._useMathText = mpl.rcParams['axes.formatter.use_mathtext'] + if self._useMathText is False: + try: + from matplotlib import font_manager + ufont = font_manager.findfont( + font_manager.FontProperties( + mpl.rcParams["font.family"] + ), + fallback_to_default=False, + ) + except ValueError: + ufont = None + + if ufont == str(cbook._get_data_path("fonts/ttf/cmr10.ttf")): + _api.warn_external( + "cmr10 font should ideally be used with " + "mathtext, set axes.formatter.use_mathtext to True" + ) else: self._useMathText = val @@ -633,7 +623,7 @@ def set_powerlimits(self, lims): def format_data_short(self, value): # docstring inherited - if isinstance(value, np.ma.MaskedArray) and value.mask: + if value is np.ma.masked: return "" if isinstance(value, Integral): fmt = "%d" @@ -753,7 +743,7 @@ def _compute_offset(self): def _set_order_of_magnitude(self): # if scientific notation is to be used, find the appropriate exponent - # if using an numerical offset, find the exponent after applying the + # if using a numerical offset, find the exponent after applying the # offset. When lower power limit = upper <> 0, use provided exponent. if not self._scientific: self.orderOfMagnitude = 0 @@ -814,7 +804,7 @@ def _set_format(self): else: break sigfigs += 1 - self.format = '%1.' + str(sigfigs) + 'f' + self.format = f'%1.{sigfigs}f' if self._usetex or self._useMathText: self.format = r'$\mathdefault{%s}$' % self.format @@ -881,8 +871,8 @@ def __init__(self, base=10.0, labelOnlyBase=False, minor_thresholds=None, linthresh=None): - self._base = float(base) - self.labelOnlyBase = labelOnlyBase + self.set_base(base) + self.set_label_minor(labelOnlyBase) if minor_thresholds is None: if mpl.rcParams['_internal.classic_mode']: minor_thresholds = (0, 0) @@ -892,6 +882,7 @@ def __init__(self, base=10.0, labelOnlyBase=False, self._sublabels = None self._linthresh = linthresh + @_api.deprecated("3.6", alternative='set_base()') def base(self, base): """ Change the *base* for labeling. @@ -899,12 +890,33 @@ def base(self, base): .. warning:: Should always match the base used for :class:`LogLocator` """ - self._base = base + self.set_base(base) + + def set_base(self, base): + """ + Change the *base* for labeling. + .. warning:: + Should always match the base used for :class:`LogLocator` + """ + self._base = float(base) + + @_api.deprecated("3.6", alternative='set_label_minor()') def label_minor(self, labelOnlyBase): """ Switch minor tick labeling on or off. + Parameters + ---------- + labelOnlyBase : bool + If True, label ticks only at integer powers of base. + """ + self.set_label_minor(labelOnlyBase) + + def set_label_minor(self, labelOnlyBase): + """ + Switch minor tick labeling on or off. + Parameters ---------- labelOnlyBase : bool @@ -989,7 +1001,7 @@ def __call__(self, x, pos=None): b = self._base # only label the decades fx = math.log(x) / math.log(b) - is_x_decade = is_close_to_int(fx) + is_x_decade = _is_close_to_int(fx) exponent = round(fx) if is_x_decade else np.floor(fx) coeff = round(b ** (fx - exponent)) @@ -1001,7 +1013,7 @@ def __call__(self, x, pos=None): vmin, vmax = self.axis.get_view_interval() vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05) s = self._num_to_string(x, vmin, vmax) - return s + return self.fix_minus(s) def format_data(self, value): with cbook._setattr_cm(self, labelOnlyBase=False): @@ -1073,7 +1085,7 @@ def __call__(self, x, pos=None): # only label the decades fx = math.log(x) / math.log(b) - is_x_decade = is_close_to_int(fx) + is_x_decade = _is_close_to_int(fx) exponent = round(fx) if is_x_decade else np.floor(fx) coeff = round(b ** (fx - exponent)) if is_x_decade: @@ -1108,7 +1120,7 @@ def _non_decade_format(self, sign_string, base, fx, usetex): b = float(base) exponent = math.floor(fx) coeff = b ** (fx - exponent) - if is_close_to_int(coeff): + if _is_close_to_int(coeff): coeff = round(coeff) return r'$\mathdefault{%s%g\times%s^{%d}}$' \ % (sign_string, coeff, base, exponent) @@ -1212,9 +1224,10 @@ def set_locs(self, locs): if not self._minor: return None if all( - is_decade(x, rtol=1e-7) - or is_decade(1 - x, rtol=1e-7) - or (is_close_to_int(2 * x) and int(np.round(2 * x)) == 1) + _is_decade(x, rtol=1e-7) + or _is_decade(1 - x, rtol=1e-7) + or (_is_close_to_int(2 * x) and + int(np.round(2 * x)) == 1) for x in locs ): # minor ticks are subsample from ideal, so no label @@ -1261,7 +1274,7 @@ def _format_value(self, x, locs, sci_notation=True): precision = -np.log10(diff) + exponent precision = ( int(np.round(precision)) - if is_close_to_int(precision) + if _is_close_to_int(precision) else math.ceil(precision) ) if precision < min_precision: @@ -1283,13 +1296,13 @@ def __call__(self, x, pos=None): return "" if x <= 0 or x >= 1: return "" - if is_close_to_int(2 * x) and round(2 * x) == 1: + if _is_close_to_int(2 * x) and round(2 * x) == 1: s = self._one_half - elif x < 0.5 and is_decade(x, rtol=1e-7): - exponent = round(np.log10(x)) + elif x < 0.5 and _is_decade(x, rtol=1e-7): + exponent = round(math.log10(x)) s = "10^{%d}" % exponent - elif x > 0.5 and is_decade(1 - x, rtol=1e-7): - exponent = round(np.log10(1 - x)) + elif x > 0.5 and _is_decade(1 - x, rtol=1e-7): + exponent = round(math.log10(1 - x)) s = self._one_minus("10^{%d}" % exponent) elif x < 0.1: s = self._format_value(x, self.locs) @@ -1413,13 +1426,13 @@ def format_eng(self, num): representing the power of 1000 of the original number. Some examples: - >>> format_eng(0) # for self.places = 0 + >>> format_eng(0) # for self.places = 0 '0' - >>> format_eng(1000000) # for self.places = 1 + >>> format_eng(1000000) # for self.places = 1 '1.0 M' - >>> format_eng("-1e-6") # for self.places = 2 + >>> format_eng(-1e-6) # for self.places = 2 '-1.00 \N{MICRO SIGN}' """ sign = 1 @@ -1507,17 +1520,14 @@ def format_pct(self, x, display_range): decimal point is set based on the *display_range* of the axis as follows: - +---------------+----------+------------------------+ - | display_range | decimals | sample | - +---------------+----------+------------------------+ - | >50 | 0 | ``x = 34.5`` => 35% | - +---------------+----------+------------------------+ - | >5 | 1 | ``x = 34.5`` => 34.5% | - +---------------+----------+------------------------+ - | >0.5 | 2 | ``x = 34.5`` => 34.50% | - +---------------+----------+------------------------+ - | ... | ... | ... | - +---------------+----------+------------------------+ + ============= ======== ======================= + display_range decimals sample + ============= ======== ======================= + >50 0 ``x = 34.5`` => 35% + >5 1 ``x = 34.5`` => 34.5% + >0.5 2 ``x = 34.5`` => 34.50% + ... ... ... + ============= ======== ======================= This method will not be very good for tiny axis ranges or extremely large ones. It assumes that the values on the chart @@ -1594,7 +1604,7 @@ def tick_values(self, vmin, vmax): .. note:: To get tick locations with the vmin and vmax values defined - automatically for the associated :attr:`axis` simply call + automatically for the associated ``axis`` simply call the Locator instance:: >>> print(type(loc)) @@ -1694,10 +1704,10 @@ def tick_values(self, vmin, vmax): class FixedLocator(Locator): """ - Tick locations are fixed. If nbins is not None, - the array of possible positions will be subsampled to - keep the number of ticks <= nbins +1. - The subsampling will be done so as to include the smallest + Tick locations are fixed at *locs*. If *nbins* is not None, + the *locs* array of possible positions will be subsampled to + keep the number of ticks <= *nbins* +1. + The subsampling will be done to include the smallest absolute value; for example, if zero is included in the array of possibilities, then it is guaranteed to be one of the chosen ticks. @@ -1705,6 +1715,7 @@ class FixedLocator(Locator): def __init__(self, locs, nbins=None): self.locs = np.asarray(locs) + _api.check_shape((None,), locs=self.locs) self.nbins = max(nbins, 2) if nbins is not None else None def set_params(self, nbins=None): @@ -1761,14 +1772,21 @@ class LinearLocator(Locator): Determine the tick locations The first time this function is called it will try to set the - number of ticks to make a nice tick partitioning. Thereafter the + number of ticks to make a nice tick partitioning. Thereafter, the number of ticks will be fixed so that interactive navigation will be nice """ def __init__(self, numticks=None, presets=None): """ - Use presets to set locs based on lom. A dict mapping vmin, vmax->locs + Parameters + ---------- + numticks : int or None, default None + Number of ticks. If None, *numticks* = 11. + presets : dict or None, default: None + Dictionary mapping ``(vmin, vmax)`` to an array of locations. + Overrides *numticks* if there is an entry for the current + ``(vmin, vmax)``. """ self.numticks = numticks if presets is None: @@ -1834,7 +1852,8 @@ def view_limits(self, vmin, vmax): class MultipleLocator(Locator): """ - Set a tick on each integer multiple of a base within the view interval. + Set a tick on each integer multiple of the *base* within the view + interval. """ def __init__(self, base=1.0): @@ -1861,7 +1880,7 @@ def tick_values(self, vmin, vmax): def view_limits(self, dmin, dmax): """ - Set the view limits to the nearest multiples of base that + Set the view limits to the nearest multiples of *base* that contain the data. """ if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers': @@ -1890,16 +1909,20 @@ def scale_range(vmin, vmax, n=1, threshold=100): class _Edge_integer: """ - Helper for MaxNLocator, MultipleLocator, etc. + Helper for `.MaxNLocator`, `.MultipleLocator`, etc. - Take floating point precision limitations into account when calculating + Take floating-point precision limitations into account when calculating tick locations as integer multiples of a step. """ def __init__(self, step, offset): """ - *step* is a positive floating-point interval between ticks. - *offset* is the offset subtracted from the data limits - prior to calculating tick locations. + Parameters + ---------- + step : float > 0 + Interval between ticks. + offset : float + Offset subtracted from the data limits prior to calculating tick + locations. """ if step <= 0: raise ValueError("'step' must be positive") @@ -1933,8 +1956,8 @@ def ge(self, x): class MaxNLocator(Locator): """ - Find nice tick locations with no more than N being within the view limits. - Locations beyond the limits are added to support autoscaling. + Find nice tick locations with no more than *nbins* + 1 being within the + view limits. Locations beyond the limits are added to support autoscaling. """ default_params = dict(nbins=10, steps=None, @@ -2046,9 +2069,7 @@ def set_params(self, **kwargs): if 'integer' in kwargs: self._integer = kwargs.pop('integer') if kwargs: - key, _ = kwargs.popitem() - raise TypeError( - f"set_params() got an unexpected keyword argument '{key}'") + raise _api.kwarg_error("set_params", kwargs) def _raw_ticks(self, vmin, vmax): """ @@ -2145,6 +2166,7 @@ def view_limits(self, dmin, dmax): return dmin, dmax +@_api.deprecated("3.6") def is_decade(x, base=10, *, rtol=1e-10): if not np.isfinite(x): return False @@ -2154,6 +2176,19 @@ def is_decade(x, base=10, *, rtol=1e-10): return is_close_to_int(lx, atol=rtol) +def _is_decade(x, *, base=10, rtol=None): + """Return True if *x* is an integer power of *base*.""" + if not np.isfinite(x): + return False + if x == 0.0: + return True + lx = np.log(abs(x)) / np.log(base) + if rtol is None: + return np.isclose(lx, np.round(lx)) + else: + return np.isclose(lx, np.round(lx), rtol=rtol) + + def _decade_less_equal(x, base): """ Return the largest integer power of *base* that's less or equal to *x*. @@ -2204,69 +2239,82 @@ def _decade_greater(x, base): return greater +@_api.deprecated("3.6") def is_close_to_int(x, *, atol=1e-10): return abs(x - np.round(x)) < atol +def _is_close_to_int(x): + return math.isclose(x, round(x)) + + class LogLocator(Locator): """ - Determine the tick locations for log axes + + Determine the tick locations for log axes. + + Place ticks on the locations : ``subs[j] * base**i`` + + Parameters + ---------- + base : float, default: 10.0 + The base of the log used, so major ticks are placed at + ``base**n``, where ``n`` is an integer. + subs : None or {'auto', 'all'} or sequence of float, default: (1.0,) + Gives the multiples of integer powers of the base at which + to place ticks. The default of ``(1.0, )`` places ticks only at + integer powers of the base. + Permitted string values are ``'auto'`` and ``'all'``. + Both of these use an algorithm based on the axis view + limits to determine whether and how to put ticks between + integer powers of the base. With ``'auto'``, ticks are + placed only between integer powers; with ``'all'``, the + integer powers are included. A value of None is + equivalent to ``'auto'``. + numticks : None or int, default: None + The maximum number of ticks to allow on a given axis. The default + of ``None`` will try to choose intelligently as long as this + Locator has already been assigned to an axis using + `~.axis.Axis.get_tick_space`, but otherwise falls back to 9. + """ def __init__(self, base=10.0, subs=(1.0,), numdecs=4, numticks=None): - """ - Place ticks on the locations : subs[j] * base**i - - Parameters - ---------- - base : float, default: 10.0 - The base of the log used, so ticks are placed at ``base**n``. - subs : None or str or sequence of float, default: (1.0,) - Gives the multiples of integer powers of the base at which - to place ticks. The default places ticks only at - integer powers of the base. - The permitted string values are ``'auto'`` and ``'all'``, - both of which use an algorithm based on the axis view - limits to determine whether and how to put ticks between - integer powers of the base. With ``'auto'``, ticks are - placed only between integer powers; with ``'all'``, the - integer powers are included. A value of None is - equivalent to ``'auto'``. - numticks : None or int, default: None - The maximum number of ticks to allow on a given axis. The default - of ``None`` will try to choose intelligently as long as this - Locator has already been assigned to an axis using - `~.axis.Axis.get_tick_space`, but otherwise falls back to 9. - """ + """Place ticks on the locations : subs[j] * base**i.""" if numticks is None: if mpl.rcParams['_internal.classic_mode']: numticks = 15 else: numticks = 'auto' - self.base(base) - self.subs(subs) + self._base = float(base) + self._set_subs(subs) self.numdecs = numdecs self.numticks = numticks def set_params(self, base=None, subs=None, numdecs=None, numticks=None): """Set parameters within this locator.""" if base is not None: - self.base(base) + self._base = float(base) if subs is not None: - self.subs(subs) + self._set_subs(subs) if numdecs is not None: self.numdecs = numdecs if numticks is not None: self.numticks = numticks - # FIXME: these base and subs functions are contrary to our - # usual and desired API. - + @_api.deprecated("3.6", alternative='set_params(base=...)') def base(self, base): """Set the log base (major tick every ``base**i``, i integer).""" self._base = float(base) + @_api.deprecated("3.6", alternative='set_params(subs=...)') def subs(self, subs): + """ + Set the minor ticks for the log scaling every ``base**i*subs[j]``. + """ + self._set_subs(subs) + + def _set_subs(self, subs): """ Set the minor ticks for the log scaling every ``base**i*subs[j]``. """ @@ -2302,14 +2350,6 @@ def tick_values(self, vmin, vmax): numticks = self.numticks b = self._base - # dummy axis has no axes attribute - if hasattr(self.axis, 'axes') and self.axis.axes.name == 'polar': - vmax = math.ceil(math.log(vmax) / math.log(b)) - decades = np.arange(vmax - self.numdecs, vmax) - ticklocs = b ** decades - - return ticklocs - if vmin <= 0.0: if self.axis is not None: vmin = self.axis.get_minpos() @@ -2394,10 +2434,6 @@ def view_limits(self, vmin, vmax): vmin, vmax = self.nonsingular(vmin, vmax) - if self.axis.axes.name == 'polar': - vmax = math.ceil(math.log(vmax) / math.log(b)) - vmin = b ** (vmax - self.numdecs) - if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers': vmin = _decade_less_equal(vmin, self._base) vmax = _decade_greater_equal(vmax, self._base) @@ -2584,6 +2620,118 @@ def view_limits(self, vmin, vmax): return result +class AsinhLocator(Locator): + """ + An axis tick locator specialized for the inverse-sinh scale + + This is very unlikely to have any use beyond + the `~.scale.AsinhScale` class. + + .. note:: + + This API is provisional and may be revised in the future + based on early user feedback. + """ + def __init__(self, linear_width, numticks=11, symthresh=0.2, + base=10, subs=None): + """ + Parameters + ---------- + linear_width : float + The scale parameter defining the extent + of the quasi-linear region. + numticks : int, default: 11 + The approximate number of major ticks that will fit + along the entire axis + symthresh : float, default: 0.2 + The fractional threshold beneath which data which covers + a range that is approximately symmetric about zero + will have ticks that are exactly symmetric. + base : int, default: 10 + The number base used for rounding tick locations + on a logarithmic scale. If this is less than one, + then rounding is to the nearest integer multiple + of powers of ten. + subs : tuple, default: None + Multiples of the number base, typically used + for the minor ticks, e.g. (2, 5) when base=10. + """ + super().__init__() + self.linear_width = linear_width + self.numticks = numticks + self.symthresh = symthresh + self.base = base + self.subs = subs + + def set_params(self, numticks=None, symthresh=None, + base=None, subs=None): + """Set parameters within this locator.""" + if numticks is not None: + self.numticks = numticks + if symthresh is not None: + self.symthresh = symthresh + if base is not None: + self.base = base + if subs is not None: + self.subs = subs if len(subs) > 0 else None + + def __call__(self): + vmin, vmax = self.axis.get_view_interval() + if (vmin * vmax) < 0 and abs(1 + vmax / vmin) < self.symthresh: + # Data-range appears to be almost symmetric, so round up: + bound = max(abs(vmin), abs(vmax)) + return self.tick_values(-bound, bound) + else: + return self.tick_values(vmin, vmax) + + def tick_values(self, vmin, vmax): + # Construct a set of "on-screen" locations + # that are uniformly spaced: + ymin, ymax = self.linear_width * np.arcsinh(np.array([vmin, vmax]) + / self.linear_width) + ys = np.linspace(ymin, ymax, self.numticks) + zero_dev = np.abs(ys / (ymax - ymin)) + if (ymin * ymax) < 0: + # Ensure that the zero tick-mark is included, + # if the axis straddles zero + ys = np.hstack([ys[(zero_dev > 0.5 / self.numticks)], 0.0]) + + # Transform the "on-screen" grid to the data space: + xs = self.linear_width * np.sinh(ys / self.linear_width) + zero_xs = (ys == 0) + + # Round the data-space values to be intuitive base-n numbers, + # keeping track of positive and negative values separately, + # but giving careful treatment to the zero value: + if self.base > 1: + log_base = math.log(self.base) + powers = ( + np.where(zero_xs, 0, np.sign(xs)) * + np.power(self.base, + np.where(zero_xs, 0.0, + np.floor(np.log(np.abs(xs) + zero_xs*1e-6) + / log_base))) + ) + if self.subs: + qs = np.outer(powers, self.subs).flatten() + else: + qs = powers + else: + powers = ( + np.where(xs >= 0, 1, -1) * + np.power(10, np.where(zero_xs, 0.0, + np.floor(np.log10(np.abs(xs) + + zero_xs*1e-6)))) + ) + qs = powers * np.round(xs / powers) + ticks = np.array(sorted(set(qs))) + + if len(ticks) >= 2: + return ticks + else: + return np.linspace(vmin, vmax, self.numticks) + + class LogitLocator(MaxNLocator): """ Determine the tick locations for logit axes diff --git a/lib/matplotlib/tight_bbox.py b/lib/matplotlib/tight_bbox.py index 5904ebc1fa1c..88c4c1ec51af 100644 --- a/lib/matplotlib/tight_bbox.py +++ b/lib/matplotlib/tight_bbox.py @@ -1,88 +1,3 @@ -""" -Helper module for the *bbox_inches* parameter in `.Figure.savefig`. -""" - -from matplotlib.transforms import Bbox, TransformedBbox, Affine2D - - -def adjust_bbox(fig, bbox_inches, fixed_dpi=None): - """ - Temporarily adjust the figure so that only the specified area - (bbox_inches) is saved. - - It modifies fig.bbox, fig.bbox_inches, - fig.transFigure._boxout, and fig.patch. While the figure size - changes, the scale of the original figure is conserved. A - function which restores the original values are returned. - """ - origBbox = fig.bbox - origBboxInches = fig.bbox_inches - orig_tight_layout = fig.get_tight_layout() - _boxout = fig.transFigure._boxout - - fig.set_tight_layout(False) - - old_aspect = [] - locator_list = [] - sentinel = object() - for ax in fig.axes: - locator_list.append(ax.get_axes_locator()) - current_pos = ax.get_position(original=False).frozen() - ax.set_axes_locator(lambda a, r, _pos=current_pos: _pos) - # override the method that enforces the aspect ratio on the Axes - if 'apply_aspect' in ax.__dict__: - old_aspect.append(ax.apply_aspect) - else: - old_aspect.append(sentinel) - ax.apply_aspect = lambda pos=None: None - - def restore_bbox(): - for ax, loc, aspect in zip(fig.axes, locator_list, old_aspect): - ax.set_axes_locator(loc) - if aspect is sentinel: - # delete our no-op function which un-hides the original method - del ax.apply_aspect - else: - ax.apply_aspect = aspect - - fig.bbox = origBbox - fig.bbox_inches = origBboxInches - fig.set_tight_layout(orig_tight_layout) - fig.transFigure._boxout = _boxout - fig.transFigure.invalidate() - fig.patch.set_bounds(0, 0, 1, 1) - - if fixed_dpi is None: - fixed_dpi = fig.dpi - tr = Affine2D().scale(fixed_dpi) - dpi_scale = fixed_dpi / fig.dpi - - _bbox = TransformedBbox(bbox_inches, tr) - - fig.bbox_inches = Bbox.from_bounds(0, 0, - bbox_inches.width, bbox_inches.height) - x0, y0 = _bbox.x0, _bbox.y0 - w1, h1 = fig.bbox.width * dpi_scale, fig.bbox.height * dpi_scale - fig.transFigure._boxout = Bbox.from_bounds(-x0, -y0, w1, h1) - fig.transFigure.invalidate() - - fig.bbox = TransformedBbox(fig.bbox_inches, tr) - - fig.patch.set_bounds(x0 / w1, y0 / h1, - fig.bbox.width / w1, fig.bbox.height / h1) - - return restore_bbox - - -def process_figure_for_rasterizing(fig, bbox_inches_restore, fixed_dpi=None): - """ - A function that needs to be called when figure dpi changes during the - drawing (e.g., rasterizing). It recovers the bbox and re-adjust it with - the new dpi. - """ - - bbox_inches, restore_bbox = bbox_inches_restore - restore_bbox() - r = adjust_bbox(fig, bbox_inches, fixed_dpi) - - return bbox_inches, r +from matplotlib._tight_bbox import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated("3.6", name=__name__, obj_type="module") diff --git a/lib/matplotlib/tight_layout.py b/lib/matplotlib/tight_layout.py index ccd4b928af70..233e96c0d47a 100644 --- a/lib/matplotlib/tight_layout.py +++ b/lib/matplotlib/tight_layout.py @@ -1,355 +1,13 @@ -""" -Routines to adjust subplot params so that subplots are -nicely fit in the figure. In doing so, only axis labels, tick labels, axes -titles and offsetboxes that are anchored to axes are currently considered. - -Internally, this module assumes that the margins (left margin, etc.) which are -differences between ``Axes.get_tightbbox`` and ``Axes.bbox`` are independent of -Axes position. This may fail if ``Axes.adjustable`` is ``datalim`` as well as -such cases as when left or right margin are affected by xlabel. -""" - -import numpy as np - -from matplotlib import _api, rcParams -from matplotlib.font_manager import FontProperties -from matplotlib.transforms import TransformedBbox, Bbox - - -def _auto_adjust_subplotpars( - fig, renderer, shape, span_pairs, subplot_list, - ax_bbox_list=None, pad=1.08, h_pad=None, w_pad=None, rect=None): - """ - Return a dict of subplot parameters to adjust spacing between subplots - or ``None`` if resulting axes would have zero height or width. - - Note that this function ignores geometry information of subplot itself, but - uses what is given by the *shape* and *subplot_list* parameters. Also, the - results could be incorrect if some subplots have ``adjustable=datalim``. - - Parameters - ---------- - shape : tuple[int, int] - Number of rows and columns of the grid. - span_pairs : list[tuple[slice, slice]] - List of rowspans and colspans occupied by each subplot. - subplot_list : list of subplots - List of subplots that will be used to calculate optimal subplot_params. - pad : float - Padding between the figure edge and the edges of subplots, as a - fraction of the font size. - h_pad, w_pad : float - Padding (height/width) between edges of adjacent subplots, as a - fraction of the font size. Defaults to *pad*. - rect : tuple[float, float, float, float] - [left, bottom, right, top] in normalized (0, 1) figure coordinates. - """ - rows, cols = shape - - font_size_inch = ( - FontProperties(size=rcParams["font.size"]).get_size_in_points() / 72) - pad_inch = pad * font_size_inch - vpad_inch = h_pad * font_size_inch if h_pad is not None else pad_inch - hpad_inch = w_pad * font_size_inch if w_pad is not None else pad_inch - - if len(span_pairs) != len(subplot_list) or len(subplot_list) == 0: - raise ValueError - - if rect is None: - margin_left = margin_bottom = margin_right = margin_top = None - else: - margin_left, margin_bottom, _right, _top = rect - margin_right = 1 - _right if _right else None - margin_top = 1 - _top if _top else None - - vspaces = np.zeros((rows + 1, cols)) - hspaces = np.zeros((rows, cols + 1)) - - if ax_bbox_list is None: - ax_bbox_list = [ - Bbox.union([ax.get_position(original=True) for ax in subplots]) - for subplots in subplot_list] - - for subplots, ax_bbox, (rowspan, colspan) in zip( - subplot_list, ax_bbox_list, span_pairs): - if all(not ax.get_visible() for ax in subplots): - continue - - bb = [] - for ax in subplots: - if ax.get_visible(): - try: - bb += [ax.get_tightbbox(renderer, for_layout_only=True)] - except TypeError: - bb += [ax.get_tightbbox(renderer)] - - tight_bbox_raw = Bbox.union(bb) - tight_bbox = TransformedBbox(tight_bbox_raw, - fig.transFigure.inverted()) - - hspaces[rowspan, colspan.start] += ax_bbox.xmin - tight_bbox.xmin # l - hspaces[rowspan, colspan.stop] += tight_bbox.xmax - ax_bbox.xmax # r - vspaces[rowspan.start, colspan] += tight_bbox.ymax - ax_bbox.ymax # t - vspaces[rowspan.stop, colspan] += ax_bbox.ymin - tight_bbox.ymin # b - - fig_width_inch, fig_height_inch = fig.get_size_inches() - - # margins can be negative for axes with aspect applied, so use max(, 0) to - # make them nonnegative. - if not margin_left: - margin_left = max(hspaces[:, 0].max(), 0) + pad_inch/fig_width_inch - suplabel = fig._supylabel - if suplabel and suplabel.get_in_layout(): - rel_width = fig.transFigure.inverted().transform_bbox( - suplabel.get_window_extent(renderer)).width - margin_left += rel_width + pad_inch/fig_width_inch - if not margin_right: - margin_right = max(hspaces[:, -1].max(), 0) + pad_inch/fig_width_inch - if not margin_top: - margin_top = max(vspaces[0, :].max(), 0) + pad_inch/fig_height_inch - if fig._suptitle and fig._suptitle.get_in_layout(): - rel_height = fig.transFigure.inverted().transform_bbox( - fig._suptitle.get_window_extent(renderer)).height - margin_top += rel_height + pad_inch/fig_height_inch - if not margin_bottom: - margin_bottom = max(vspaces[-1, :].max(), 0) + pad_inch/fig_height_inch - suplabel = fig._supxlabel - if suplabel and suplabel.get_in_layout(): - rel_height = fig.transFigure.inverted().transform_bbox( - suplabel.get_window_extent(renderer)).height - margin_bottom += rel_height + pad_inch/fig_height_inch - - if margin_left + margin_right >= 1: - _api.warn_external('Tight layout not applied. The left and right ' - 'margins cannot be made large enough to ' - 'accommodate all axes decorations.') - return None - if margin_bottom + margin_top >= 1: - _api.warn_external('Tight layout not applied. The bottom and top ' - 'margins cannot be made large enough to ' - 'accommodate all axes decorations.') - return None - - kwargs = dict(left=margin_left, - right=1 - margin_right, - bottom=margin_bottom, - top=1 - margin_top) - - if cols > 1: - hspace = hspaces[:, 1:-1].max() + hpad_inch / fig_width_inch - # axes widths: - h_axes = (1 - margin_right - margin_left - hspace * (cols - 1)) / cols - if h_axes < 0: - _api.warn_external('Tight layout not applied. tight_layout ' - 'cannot make axes width small enough to ' - 'accommodate all axes decorations') - return None - else: - kwargs["wspace"] = hspace / h_axes - if rows > 1: - vspace = vspaces[1:-1, :].max() + vpad_inch / fig_height_inch - v_axes = (1 - margin_top - margin_bottom - vspace * (rows - 1)) / rows - if v_axes < 0: - _api.warn_external('Tight layout not applied. tight_layout ' - 'cannot make axes height small enough to ' - 'accommodate all axes decorations.') - return None - else: - kwargs["hspace"] = vspace / v_axes - - return kwargs - - -@_api.deprecated("3.5") -def auto_adjust_subplotpars( - fig, renderer, nrows_ncols, num1num2_list, subplot_list, - ax_bbox_list=None, pad=1.08, h_pad=None, w_pad=None, rect=None): - """ - Return a dict of subplot parameters to adjust spacing between subplots - or ``None`` if resulting axes would have zero height or width. - - Note that this function ignores geometry information of subplot - itself, but uses what is given by the *nrows_ncols* and *num1num2_list* - parameters. Also, the results could be incorrect if some subplots have - ``adjustable=datalim``. - - Parameters - ---------- - nrows_ncols : tuple[int, int] - Number of rows and number of columns of the grid. - num1num2_list : list[tuple[int, int]] - List of numbers specifying the area occupied by the subplot - subplot_list : list of subplots - List of subplots that will be used to calculate optimal subplot_params. - pad : float - Padding between the figure edge and the edges of subplots, as a - fraction of the font size. - h_pad, w_pad : float - Padding (height/width) between edges of adjacent subplots, as a - fraction of the font size. Defaults to *pad*. - rect : tuple[float, float, float, float] - [left, bottom, right, top] in normalized (0, 1) figure coordinates. - """ - nrows, ncols = nrows_ncols - span_pairs = [] - for n1, n2 in num1num2_list: - if n2 is None: - n2 = n1 - span_pairs.append((slice(n1 // ncols, n2 // ncols + 1), - slice(n1 % ncols, n2 % ncols + 1))) - return _auto_adjust_subplotpars( - fig, renderer, nrows_ncols, num1num2_list, subplot_list, - ax_bbox_list, pad, h_pad, w_pad, rect) +from matplotlib._tight_layout import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated("3.6", name=__name__, obj_type="module") +@_api.deprecated("3.6", alternative="figure.canvas.get_renderer()") def get_renderer(fig): - if fig._cachedRenderer: - return fig._cachedRenderer + canvas = fig.canvas + if canvas and hasattr(canvas, "get_renderer"): + return canvas.get_renderer() else: - canvas = fig.canvas - if canvas and hasattr(canvas, "get_renderer"): - return canvas.get_renderer() - else: - from . import backend_bases - return backend_bases._get_renderer(fig) - - -def get_subplotspec_list(axes_list, grid_spec=None): - """ - Return a list of subplotspec from the given list of axes. - - For an instance of axes that does not support subplotspec, None is inserted - in the list. - - If grid_spec is given, None is inserted for those not from the given - grid_spec. - """ - subplotspec_list = [] - for ax in axes_list: - axes_or_locator = ax.get_axes_locator() - if axes_or_locator is None: - axes_or_locator = ax - - if hasattr(axes_or_locator, "get_subplotspec"): - subplotspec = axes_or_locator.get_subplotspec() - if subplotspec is not None: - subplotspec = subplotspec.get_topmost_subplotspec() - gs = subplotspec.get_gridspec() - if grid_spec is not None: - if gs != grid_spec: - subplotspec = None - elif gs.locally_modified_subplot_params(): - subplotspec = None - else: - subplotspec = None - - subplotspec_list.append(subplotspec) - - return subplotspec_list - - -def get_tight_layout_figure(fig, axes_list, subplotspec_list, renderer, - pad=1.08, h_pad=None, w_pad=None, rect=None): - """ - Return subplot parameters for tight-layouted-figure with specified padding. - - Parameters - ---------- - fig : Figure - axes_list : list of Axes - subplotspec_list : list of `.SubplotSpec` - The subplotspecs of each axes. - renderer : renderer - pad : float - Padding between the figure edge and the edges of subplots, as a - fraction of the font size. - h_pad, w_pad : float - Padding (height/width) between edges of adjacent subplots. Defaults to - *pad*. - rect : tuple[float, float, float, float], optional - (left, bottom, right, top) rectangle in normalized figure coordinates - that the whole subplots area (including labels) will fit into. - Defaults to using the entire figure. - - Returns - ------- - subplotspec or None - subplotspec kwargs to be passed to `.Figure.subplots_adjust` or - None if tight_layout could not be accomplished. - """ - - # Multiple axes can share same subplotspec (e.g., if using axes_grid1); - # we need to group them together. - ss_to_subplots = {ss: [] for ss in subplotspec_list} - for ax, ss in zip(axes_list, subplotspec_list): - ss_to_subplots[ss].append(ax) - ss_to_subplots.pop(None, None) # Skip subplotspec == None. - if not ss_to_subplots: - return {} - subplot_list = list(ss_to_subplots.values()) - ax_bbox_list = [ss.get_position(fig) for ss in ss_to_subplots] - - max_nrows = max(ss.get_gridspec().nrows for ss in ss_to_subplots) - max_ncols = max(ss.get_gridspec().ncols for ss in ss_to_subplots) - - span_pairs = [] - for ss in ss_to_subplots: - # The intent here is to support axes from different gridspecs where - # one's nrows (or ncols) is a multiple of the other (e.g. 2 and 4), - # but this doesn't actually work because the computed wspace, in - # relative-axes-height, corresponds to different physical spacings for - # the 2-row grid and the 4-row grid. Still, this code is left, mostly - # for backcompat. - rows, cols = ss.get_gridspec().get_geometry() - div_row, mod_row = divmod(max_nrows, rows) - div_col, mod_col = divmod(max_ncols, cols) - if mod_row != 0: - _api.warn_external('tight_layout not applied: number of rows ' - 'in subplot specifications must be ' - 'multiples of one another.') - return {} - if mod_col != 0: - _api.warn_external('tight_layout not applied: number of ' - 'columns in subplot specifications must be ' - 'multiples of one another.') - return {} - span_pairs.append(( - slice(ss.rowspan.start * div_row, ss.rowspan.stop * div_row), - slice(ss.colspan.start * div_col, ss.colspan.stop * div_col))) - - kwargs = _auto_adjust_subplotpars(fig, renderer, - shape=(max_nrows, max_ncols), - span_pairs=span_pairs, - subplot_list=subplot_list, - ax_bbox_list=ax_bbox_list, - pad=pad, h_pad=h_pad, w_pad=w_pad) - - # kwargs can be none if tight_layout fails... - if rect is not None and kwargs is not None: - # if rect is given, the whole subplots area (including - # labels) will fit into the rect instead of the - # figure. Note that the rect argument of - # *auto_adjust_subplotpars* specify the area that will be - # covered by the total area of axes.bbox. Thus we call - # auto_adjust_subplotpars twice, where the second run - # with adjusted rect parameters. - - left, bottom, right, top = rect - if left is not None: - left += kwargs["left"] - if bottom is not None: - bottom += kwargs["bottom"] - if right is not None: - right -= (1 - kwargs["right"]) - if top is not None: - top -= (1 - kwargs["top"]) - - kwargs = _auto_adjust_subplotpars(fig, renderer, - shape=(max_nrows, max_ncols), - span_pairs=span_pairs, - subplot_list=subplot_list, - ax_bbox_list=ax_bbox_list, - pad=pad, h_pad=h_pad, w_pad=w_pad, - rect=(left, bottom, right, top)) - - return kwargs + from . import backend_bases + return backend_bases._get_renderer(fig) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index d7ac521242a5..c5c051be570f 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -17,7 +17,7 @@ .. image:: ../_static/transforms.png The framework can be used for both affine and non-affine -transformations. However, for speed, we want use the backend +transformations. However, for speed, we want to use the backend renderers to perform affine transformations whenever possible. Therefore, it is possible to perform just the affine or non-affine part of a transformation on a set of data. The affine is always @@ -27,6 +27,9 @@ The backends are not expected to handle non-affine transformations themselves. + +See the tutorial :doc:`/tutorials/advanced/transforms_tutorial` for examples +of how to use transforms. """ # Note: There are a number of places in the code where we use `np.min` or @@ -151,22 +154,6 @@ def __copy__(self): other.set_children(val) # val == getattr(other, key) return other - def __deepcopy__(self, memo): - # We could deepcopy the entire transform tree, but nothing except - # `self` is accessible publicly, so we may as well just freeze `self`. - other = self.frozen() - if other is not self: - return other - # Some classes implement frozen() as returning self, which is not - # acceptable for deepcopying, so we need to handle them separately. - other = copy.deepcopy(super(), memo) - # If `c = a + b; a1 = copy(a)`, then modifications to `a1` do not - # propagate back to `c`, i.e. we need to clear the parents of `a1`. - other._parents = {} - # If `c = a + b; c1 = copy(c)`, this creates a separate tree - # (`c1 = a1 + b1`) so nothing needs to be done. - return other - def invalidate(self): """ Invalidate this `TransformNode` and triggers an invalidation of its @@ -768,7 +755,7 @@ def __init__(self, points, **kwargs): """ Parameters ---------- - points : ndarray + points : `~numpy.ndarray` A 2x2 numpy array of the form ``[[x0, y0], [x1, y1]]``. """ super().__init__(**kwargs) @@ -910,7 +897,7 @@ def update_from_data_x(self, x, ignore=None): Parameters ---------- - x : ndarray + x : `~numpy.ndarray` Array of x-values. ignore : bool, optional @@ -930,7 +917,7 @@ def update_from_data_y(self, y, ignore=None): Parameters ---------- - y : ndarray + y : `~numpy.ndarray` Array of y-values. ignore : bool, optional @@ -938,7 +925,7 @@ def update_from_data_y(self, y, ignore=None): - When ``False``, include the existing bounds of the `Bbox`. - When ``None``, use the last value passed to :meth:`ignore`. """ - y = np.array(y).ravel() + y = np.ravel(y) self.update_from_data_xy(np.column_stack([np.ones(y.size), y]), ignore=ignore, updatex=False) @@ -950,7 +937,7 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True): Parameters ---------- - xy : ndarray + xy : `~numpy.ndarray` A numpy array of 2D points. ignore : bool, optional @@ -1411,7 +1398,7 @@ def contains_branch_seperately(self, other_transform): each separate dimension. A common use for this method is to identify if a transform is a blended - transform containing an axes' data transform. e.g.:: + transform containing an Axes' data transform. e.g.:: x_isdata, y_isdata = trans.contains_branch_seperately(ax.transData) @@ -1490,7 +1477,7 @@ def transform(self, values): Returns ------- array - The output values as NumPy array of length :attr:`input_dims` or + The output values as NumPy array of length :attr:`output_dims` or shape (N x :attr:`output_dims`), depending on the input. """ # Ensure that values is a 2d array (but remember whether @@ -1535,7 +1522,7 @@ def transform_affine(self, values): Returns ------- array - The output values as NumPy array of length :attr:`input_dims` or + The output values as NumPy array of length :attr:`output_dims` or shape (N x :attr:`output_dims`), depending on the input. """ return self.get_affine().transform(values) @@ -1560,7 +1547,7 @@ def transform_non_affine(self, values): Returns ------- array - The output values as NumPy array of length :attr:`input_dims` or + The output values as NumPy array of length :attr:`output_dims` or shape (N x :attr:`output_dims`), depending on the input. """ return values @@ -1656,11 +1643,10 @@ def transform_angles(self, angles, pts, radians=False, pushoff=1e-5): raise NotImplementedError('Only defined in 2D') angles = np.asarray(angles) pts = np.asarray(pts) - if angles.ndim != 1 or angles.shape[0] != pts.shape[0]: - raise ValueError("'angles' must be a column vector and have same " - "number of rows as 'pts'") - if pts.shape[1] != 2: - raise ValueError("'pts' must be array with 2 columns for x, y") + _api.check_shape((None, 2), pts=pts) + _api.check_shape((None,), angles=angles) + if len(angles) != len(pts): + raise ValueError("There must be as many 'angles' as 'pts'") # Convert to radians if desired if not radians: angles = np.deg2rad(angles) @@ -1713,15 +1699,8 @@ def __init__(self, child): be replaced with :meth:`set`. """ _api.check_isinstance(Transform, child=child) - self._init(child) - self.set_children(child) - - def _init(self, child): - Transform.__init__(self) - self.input_dims = child.input_dims - self.output_dims = child.output_dims - self._set(child) - self._invalid = 0 + super().__init__() + self.set(child) def __eq__(self, other): return self._child.__eq__(other) @@ -1732,8 +1711,25 @@ def frozen(self): # docstring inherited return self._child.frozen() - def _set(self, child): + def set(self, child): + """ + Replace the current child of this transform with another one. + + The new child must have the same number of input and output + dimensions as the current child. + """ + if hasattr(self, "_child"): # Absent during init. + self.invalidate() + new_dims = (child.input_dims, child.output_dims) + old_dims = (self._child.input_dims, self._child.output_dims) + if new_dims != old_dims: + raise ValueError( + f"The input and output dims of the new child {new_dims} " + f"do not match those of current child {old_dims}") + self._child._parents.pop(id(self), None) + self._child = child + self.set_children(child) self.transform = child.transform self.transform_affine = child.transform_affine @@ -1744,31 +1740,16 @@ def _set(self, child): self.get_affine = child.get_affine self.inverted = child.inverted self.get_matrix = child.get_matrix - # note we do not wrap other properties here since the transform's # child can be changed with WrappedTransform.set and so checking # is_affine and other such properties may be dangerous. - def set(self, child): - """ - Replace the current child of this transform with another one. - - The new child must have the same number of input and output - dimensions as the current child. - """ - if (child.input_dims != self.input_dims or - child.output_dims != self.output_dims): - raise ValueError( - "The new child must have the same number of input and output " - "dimensions as the current child") - - self.set_children(child) - self._set(child) - self._invalid = 0 self.invalidate() self._invalid = 0 + input_dims = property(lambda self: self._child.input_dims) + output_dims = property(lambda self: self._child.output_dims) is_affine = property(lambda self: self._child.is_affine) is_separable = property(lambda self: self._child.is_separable) has_inverse = property(lambda self: self._child.has_inverse) @@ -1874,7 +1855,7 @@ def transform_affine(self, points): # The major speed trap here is just converting to the # points to an array in the first place. If we can use # more arrays upstream, that should help here. - if not isinstance(points, (np.ma.MaskedArray, np.ndarray)): + if not isinstance(points, np.ndarray): _api.warn_external( f'A non-numpy array of type {type(points)} was passed in ' f'for transformation, which results in poor performance.') @@ -1910,7 +1891,7 @@ def __init__(self, matrix=None, **kwargs): super().__init__(**kwargs) if matrix is None: # A bit faster than np.identity(3). - matrix = IdentityTransform._mtx.copy() + matrix = IdentityTransform._mtx self._mtx = matrix.copy() self._invalid = 0 @@ -1975,6 +1956,7 @@ def set(self, other): self.invalidate() @staticmethod + @_api.deprecated("3.6", alternative="Affine2D()") def identity(): """ Return a new `Affine2D` object that is the identity transform. @@ -2003,9 +1985,16 @@ def rotate(self, theta): """ a = math.cos(theta) b = math.sin(theta) - rotate_mtx = np.array([[a, -b, 0.0], [b, a, 0.0], [0.0, 0.0, 1.0]], - float) - self._mtx = np.dot(rotate_mtx, self._mtx) + mtx = self._mtx + # Operating and assigning one scalar at a time is much faster. + (xx, xy, x0), (yx, yy, y0), _ = mtx.tolist() + # mtx = [[a -b 0], [b a 0], [0 0 1]] * mtx + mtx[0, 0] = a * xx - b * yx + mtx[0, 1] = a * xy - b * yy + mtx[0, 2] = a * x0 - b * y0 + mtx[1, 0] = b * xx + a * yx + mtx[1, 1] = b * xy + a * yy + mtx[1, 2] = b * x0 + a * y0 self.invalidate() return self @@ -2088,11 +2077,18 @@ def skew(self, xShear, yShear): calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` and :meth:`scale`. """ - rotX = math.tan(xShear) - rotY = math.tan(yShear) - skew_mtx = np.array( - [[1.0, rotX, 0.0], [rotY, 1.0, 0.0], [0.0, 0.0, 1.0]], float) - self._mtx = np.dot(skew_mtx, self._mtx) + rx = math.tan(xShear) + ry = math.tan(yShear) + mtx = self._mtx + # Operating and assigning one scalar at a time is much faster. + (xx, xy, x0), (yx, yy, y0), _ = mtx.tolist() + # mtx = [[1 rx 0], [ry 1 0], [0 0 1]] * mtx + mtx[0, 0] += rx * yx + mtx[0, 1] += rx * yy + mtx[0, 2] += rx * y0 + mtx[1, 0] += ry * xx + mtx[1, 1] += ry * xy + mtx[1, 2] += ry * x0 self.invalidate() return self @@ -2425,8 +2421,7 @@ def transform_non_affine(self, points): elif not self._a.is_affine and self._b.is_affine: return self._a.transform_non_affine(points) else: - return self._b.transform_non_affine( - self._a.transform(points)) + return self._b.transform_non_affine(self._a.transform(points)) def transform_path_non_affine(self, path): # docstring inherited @@ -2803,37 +2798,25 @@ class TransformedPatchPath(TransformedPath): `~.patches.Patch`. This cached copy is automatically updated when the non-affine part of the transform or the patch changes. """ + def __init__(self, patch): """ Parameters ---------- patch : `~.patches.Patch` """ - TransformNode.__init__(self) - - transform = patch.get_transform() + # Defer to TransformedPath.__init__. + super().__init__(patch.get_path(), patch.get_transform()) self._patch = patch - self._transform = transform - self.set_children(transform) - self._path = patch.get_path() - self._transformed_path = None - self._transformed_points = None def _revalidate(self): patch_path = self._patch.get_path() - # Only recompute if the invalidation includes the non_affine part of - # the transform, or the Patch's Path has changed. - if (self._transformed_path is None or self._path != patch_path or - (self._invalid & self.INVALID_NON_AFFINE == - self.INVALID_NON_AFFINE)): + # Force invalidation if the patch path changed; otherwise, let base + # class check invalidation. + if patch_path != self._path: self._path = patch_path - self._transformed_path = \ - self._transform.transform_path_non_affine(patch_path) - self._transformed_points = \ - Path._fast_from_codes_and_verts( - self._transform.transform_non_affine(patch_path.vertices), - None, patch_path) - self._invalid = 0 + self._transformed_path = None + super()._revalidate() def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True): diff --git a/lib/matplotlib/tri/__init__.py b/lib/matplotlib/tri/__init__.py index 7ff2b326920b..e000831d8a08 100644 --- a/lib/matplotlib/tri/__init__.py +++ b/lib/matplotlib/tri/__init__.py @@ -2,11 +2,22 @@ Unstructured triangular grid functions. """ -from .triangulation import * -from .tricontour import * -from .tritools import * -from .trifinder import * -from .triinterpolate import * -from .trirefine import * -from .tripcolor import * -from .triplot import * +from ._triangulation import Triangulation +from ._tricontour import TriContourSet, tricontour, tricontourf +from ._trifinder import TriFinder, TrapezoidMapTriFinder +from ._triinterpolate import (TriInterpolator, LinearTriInterpolator, + CubicTriInterpolator) +from ._tripcolor import tripcolor +from ._triplot import triplot +from ._trirefine import TriRefiner, UniformTriRefiner +from ._tritools import TriAnalyzer + + +__all__ = ["Triangulation", + "TriContourSet", "tricontour", "tricontourf", + "TriFinder", "TrapezoidMapTriFinder", + "TriInterpolator", "LinearTriInterpolator", "CubicTriInterpolator", + "tripcolor", + "triplot", + "TriRefiner", "UniformTriRefiner", + "TriAnalyzer"] diff --git a/lib/matplotlib/tri/_triangulation.py b/lib/matplotlib/tri/_triangulation.py new file mode 100644 index 000000000000..fa03a9c030f7 --- /dev/null +++ b/lib/matplotlib/tri/_triangulation.py @@ -0,0 +1,245 @@ +import numpy as np + +from matplotlib import _api + + +class Triangulation: + """ + An unstructured triangular grid consisting of npoints points and + ntri triangles. The triangles can either be specified by the user + or automatically generated using a Delaunay triangulation. + + Parameters + ---------- + x, y : (npoints,) array-like + Coordinates of grid points. + triangles : (ntri, 3) array-like of int, optional + For each triangle, the indices of the three points that make + up the triangle, ordered in an anticlockwise manner. If not + specified, the Delaunay triangulation is calculated. + mask : (ntri,) array-like of bool, optional + Which triangles are masked out. + + Attributes + ---------- + triangles : (ntri, 3) array of int + For each triangle, the indices of the three points that make + up the triangle, ordered in an anticlockwise manner. If you want to + take the *mask* into account, use `get_masked_triangles` instead. + mask : (ntri, 3) array of bool + Masked out triangles. + is_delaunay : bool + Whether the Triangulation is a calculated Delaunay + triangulation (where *triangles* was not specified) or not. + + Notes + ----- + For a Triangulation to be valid it must not have duplicate points, + triangles formed from colinear points, or overlapping triangles. + """ + def __init__(self, x, y, triangles=None, mask=None): + from matplotlib import _qhull + + self.x = np.asarray(x, dtype=np.float64) + self.y = np.asarray(y, dtype=np.float64) + if self.x.shape != self.y.shape or self.x.ndim != 1: + raise ValueError("x and y must be equal-length 1D arrays, but " + f"found shapes {self.x.shape!r} and " + f"{self.y.shape!r}") + + self.mask = None + self._edges = None + self._neighbors = None + self.is_delaunay = False + + if triangles is None: + # No triangulation specified, so use matplotlib._qhull to obtain + # Delaunay triangulation. + self.triangles, self._neighbors = _qhull.delaunay(x, y) + self.is_delaunay = True + else: + # Triangulation specified. Copy, since we may correct triangle + # orientation. + try: + self.triangles = np.array(triangles, dtype=np.int32, order='C') + except ValueError as e: + raise ValueError('triangles must be a (N, 3) int array, not ' + f'{triangles!r}') from e + if self.triangles.ndim != 2 or self.triangles.shape[1] != 3: + raise ValueError( + 'triangles must be a (N, 3) int array, but found shape ' + f'{self.triangles.shape!r}') + if self.triangles.max() >= len(self.x): + raise ValueError( + 'triangles are indices into the points and must be in the ' + f'range 0 <= i < {len(self.x)} but found value ' + f'{self.triangles.max()}') + if self.triangles.min() < 0: + raise ValueError( + 'triangles are indices into the points and must be in the ' + f'range 0 <= i < {len(self.x)} but found value ' + f'{self.triangles.min()}') + + # Underlying C++ object is not created until first needed. + self._cpp_triangulation = None + + # Default TriFinder not created until needed. + self._trifinder = None + + self.set_mask(mask) + + def calculate_plane_coefficients(self, z): + """ + Calculate plane equation coefficients for all unmasked triangles from + the point (x, y) coordinates and specified z-array of shape (npoints). + The returned array has shape (npoints, 3) and allows z-value at (x, y) + position in triangle tri to be calculated using + ``z = array[tri, 0] * x + array[tri, 1] * y + array[tri, 2]``. + """ + return self.get_cpp_triangulation().calculate_plane_coefficients(z) + + @property + def edges(self): + """ + Return integer array of shape (nedges, 2) containing all edges of + non-masked triangles. + + Each row defines an edge by its start point index and end point + index. Each edge appears only once, i.e. for an edge between points + *i* and *j*, there will only be either *(i, j)* or *(j, i)*. + """ + if self._edges is None: + self._edges = self.get_cpp_triangulation().get_edges() + return self._edges + + def get_cpp_triangulation(self): + """ + Return the underlying C++ Triangulation object, creating it + if necessary. + """ + from matplotlib import _tri + if self._cpp_triangulation is None: + self._cpp_triangulation = _tri.Triangulation( + # For unset arrays use empty tuple which has size of zero. + self.x, self.y, self.triangles, + self.mask if self.mask is not None else (), + self._edges if self._edges is not None else (), + self._neighbors if self._neighbors is not None else (), + not self.is_delaunay) + return self._cpp_triangulation + + def get_masked_triangles(self): + """ + Return an array of triangles taking the mask into account. + """ + if self.mask is not None: + return self.triangles[~self.mask] + else: + return self.triangles + + @staticmethod + def get_from_args_and_kwargs(*args, **kwargs): + """ + Return a Triangulation object from the args and kwargs, and + the remaining args and kwargs with the consumed values removed. + + There are two alternatives: either the first argument is a + Triangulation object, in which case it is returned, or the args + and kwargs are sufficient to create a new Triangulation to + return. In the latter case, see Triangulation.__init__ for + the possible args and kwargs. + """ + if isinstance(args[0], Triangulation): + triangulation, *args = args + if 'triangles' in kwargs: + _api.warn_external( + "Passing the keyword 'triangles' has no effect when also " + "passing a Triangulation") + if 'mask' in kwargs: + _api.warn_external( + "Passing the keyword 'mask' has no effect when also " + "passing a Triangulation") + else: + x, y, triangles, mask, args, kwargs = \ + Triangulation._extract_triangulation_params(args, kwargs) + triangulation = Triangulation(x, y, triangles, mask) + return triangulation, args, kwargs + + @staticmethod + def _extract_triangulation_params(args, kwargs): + x, y, *args = args + # Check triangles in kwargs then args. + triangles = kwargs.pop('triangles', None) + from_args = False + if triangles is None and args: + triangles = args[0] + from_args = True + if triangles is not None: + try: + triangles = np.asarray(triangles, dtype=np.int32) + except ValueError: + triangles = None + if triangles is not None and (triangles.ndim != 2 or + triangles.shape[1] != 3): + triangles = None + if triangles is not None and from_args: + args = args[1:] # Consumed first item in args. + # Check for mask in kwargs. + mask = kwargs.pop('mask', None) + return x, y, triangles, mask, args, kwargs + + def get_trifinder(self): + """ + Return the default `matplotlib.tri.TriFinder` of this + triangulation, creating it if necessary. This allows the same + TriFinder object to be easily shared. + """ + if self._trifinder is None: + # Default TriFinder class. + from matplotlib.tri._trifinder import TrapezoidMapTriFinder + self._trifinder = TrapezoidMapTriFinder(self) + return self._trifinder + + @property + def neighbors(self): + """ + Return integer array of shape (ntri, 3) containing neighbor triangles. + + For each triangle, the indices of the three triangles that + share the same edges, or -1 if there is no such neighboring + triangle. ``neighbors[i, j]`` is the triangle that is the neighbor + to the edge from point index ``triangles[i, j]`` to point index + ``triangles[i, (j+1)%3]``. + """ + if self._neighbors is None: + self._neighbors = self.get_cpp_triangulation().get_neighbors() + return self._neighbors + + def set_mask(self, mask): + """ + Set or clear the mask array. + + Parameters + ---------- + mask : None or bool array of length ntri + """ + if mask is None: + self.mask = None + else: + self.mask = np.asarray(mask, dtype=bool) + if self.mask.shape != (self.triangles.shape[0],): + raise ValueError('mask array must have same length as ' + 'triangles array') + + # Set mask in C++ Triangulation. + if self._cpp_triangulation is not None: + self._cpp_triangulation.set_mask( + self.mask if self.mask is not None else ()) + + # Clear derived fields so they are recalculated when needed. + self._edges = None + self._neighbors = None + + # Recalculate TriFinder if it exists. + if self._trifinder is not None: + self._trifinder._initialize() diff --git a/lib/matplotlib/tri/_tricontour.py b/lib/matplotlib/tri/_tricontour.py new file mode 100644 index 000000000000..5b6d745372f8 --- /dev/null +++ b/lib/matplotlib/tri/_tricontour.py @@ -0,0 +1,272 @@ +import numpy as np + +from matplotlib import _docstring +from matplotlib.contour import ContourSet +from matplotlib.tri._triangulation import Triangulation + + +@_docstring.dedent_interpd +class TriContourSet(ContourSet): + """ + Create and store a set of contour lines or filled regions for + a triangular grid. + + This class is typically not instantiated directly by the user but by + `~.Axes.tricontour` and `~.Axes.tricontourf`. + + %(contour_set_attributes)s + """ + def __init__(self, ax, *args, **kwargs): + """ + Draw triangular grid contour lines or filled regions, + depending on whether keyword arg *filled* is False + (default) or True. + + The first argument of the initializer must be an `~.axes.Axes` + object. The remaining arguments and keyword arguments + are described in the docstring of `~.Axes.tricontour`. + """ + super().__init__(ax, *args, **kwargs) + + def _process_args(self, *args, **kwargs): + """ + Process args and kwargs. + """ + if isinstance(args[0], TriContourSet): + C = args[0]._contour_generator + if self.levels is None: + self.levels = args[0].levels + self.zmin = args[0].zmin + self.zmax = args[0].zmax + self._mins = args[0]._mins + self._maxs = args[0]._maxs + else: + from matplotlib import _tri + tri, z = self._contour_args(args, kwargs) + C = _tri.TriContourGenerator(tri.get_cpp_triangulation(), z) + self._mins = [tri.x.min(), tri.y.min()] + self._maxs = [tri.x.max(), tri.y.max()] + + self._contour_generator = C + return kwargs + + def _contour_args(self, args, kwargs): + tri, args, kwargs = Triangulation.get_from_args_and_kwargs(*args, + **kwargs) + z, *args = args + z = np.ma.asarray(z) + if z.shape != tri.x.shape: + raise ValueError('z array must have same length as triangulation x' + ' and y arrays') + + # z values must be finite, only need to check points that are included + # in the triangulation. + z_check = z[np.unique(tri.get_masked_triangles())] + if np.ma.is_masked(z_check): + raise ValueError('z must not contain masked points within the ' + 'triangulation') + if not np.isfinite(z_check).all(): + raise ValueError('z array must not contain non-finite values ' + 'within the triangulation') + + z = np.ma.masked_invalid(z, copy=False) + self.zmax = float(z_check.max()) + self.zmin = float(z_check.min()) + if self.logscale and self.zmin <= 0: + func = 'contourf' if self.filled else 'contour' + raise ValueError(f'Cannot {func} log of negative values.') + self._process_contour_level_args(args, z.dtype) + return (tri, z) + + +_docstring.interpd.update(_tricontour_doc=""" +Draw contour %%(type)s on an unstructured triangular grid. + +Call signatures:: + + %%(func)s(triangulation, z, [levels], ...) + %%(func)s(x, y, z, [levels], *, [triangles=triangles], [mask=mask], ...) + +The triangular grid can be specified either by passing a `.Triangulation` +object as the first parameter, or by passing the points *x*, *y* and +optionally the *triangles* and a *mask*. See `.Triangulation` for an +explanation of these parameters. If neither of *triangulation* or +*triangles* are given, the triangulation is calculated on the fly. + +It is possible to pass *triangles* positionally, i.e. +``%%(func)s(x, y, triangles, z, ...)``. However, this is discouraged. For more +clarity, pass *triangles* via keyword argument. + +Parameters +---------- +triangulation : `.Triangulation`, optional + An already created triangular grid. + +x, y, triangles, mask + Parameters defining the triangular grid. See `.Triangulation`. + This is mutually exclusive with specifying *triangulation*. + +z : array-like + The height values over which the contour is drawn. Color-mapping is + controlled by *cmap*, *norm*, *vmin*, and *vmax*. + + .. note:: + All values in *z* must be finite. Hence, nan and inf values must + either be removed or `~.Triangulation.set_mask` be used. + +levels : int or array-like, optional + Determines the number and positions of the contour lines / regions. + + If an int *n*, use `~matplotlib.ticker.MaxNLocator`, which tries to + automatically choose no more than *n+1* "nice" contour levels between + between minimum and maximum numeric values of *Z*. + + If array-like, draw contour lines at the specified levels. The values must + be in increasing order. + +Returns +------- +`~matplotlib.tri.TriContourSet` + +Other Parameters +---------------- +colors : color string or sequence of colors, optional + The colors of the levels, i.e., the contour %%(type)s. + + The sequence is cycled for the levels in ascending order. If the sequence + is shorter than the number of levels, it is repeated. + + As a shortcut, single color strings may be used in place of one-element + lists, i.e. ``'red'`` instead of ``['red']`` to color all levels with the + same color. This shortcut does only work for color strings, not for other + ways of specifying colors. + + By default (value *None*), the colormap specified by *cmap* will be used. + +alpha : float, default: 1 + The alpha blending value, between 0 (transparent) and 1 (opaque). + +%(cmap_doc)s + + This parameter is ignored if *colors* is set. + +%(norm_doc)s + + This parameter is ignored if *colors* is set. + +%(vmin_vmax_doc)s + + If *vmin* or *vmax* are not given, the default color scaling is based on + *levels*. + + This parameter is ignored if *colors* is set. + +origin : {*None*, 'upper', 'lower', 'image'}, default: None + Determines the orientation and exact position of *z* by specifying the + position of ``z[0, 0]``. This is only relevant, if *X*, *Y* are not given. + + - *None*: ``z[0, 0]`` is at X=0, Y=0 in the lower left corner. + - 'lower': ``z[0, 0]`` is at X=0.5, Y=0.5 in the lower left corner. + - 'upper': ``z[0, 0]`` is at X=N+0.5, Y=0.5 in the upper left corner. + - 'image': Use the value from :rc:`image.origin`. + +extent : (x0, x1, y0, y1), optional + If *origin* is not *None*, then *extent* is interpreted as in `.imshow`: it + gives the outer pixel boundaries. In this case, the position of z[0, 0] is + the center of the pixel, not a corner. If *origin* is *None*, then + (*x0*, *y0*) is the position of z[0, 0], and (*x1*, *y1*) is the position + of z[-1, -1]. + + This argument is ignored if *X* and *Y* are specified in the call to + contour. + +locator : ticker.Locator subclass, optional + The locator is used to determine the contour levels if they are not given + explicitly via *levels*. + Defaults to `~.ticker.MaxNLocator`. + +extend : {'neither', 'both', 'min', 'max'}, default: 'neither' + Determines the ``%%(func)s``-coloring of values that are outside the + *levels* range. + + If 'neither', values outside the *levels* range are not colored. If 'min', + 'max' or 'both', color the values below, above or below and above the + *levels* range. + + Values below ``min(levels)`` and above ``max(levels)`` are mapped to the + under/over values of the `.Colormap`. Note that most colormaps do not have + dedicated colors for these by default, so that the over and under values + are the edge values of the colormap. You may want to set these values + explicitly using `.Colormap.set_under` and `.Colormap.set_over`. + + .. note:: + + An existing `.TriContourSet` does not get notified if properties of its + colormap are changed. Therefore, an explicit call to + `.ContourSet.changed()` is needed after modifying the colormap. The + explicit call can be left out, if a colorbar is assigned to the + `.TriContourSet` because it internally calls `.ContourSet.changed()`. + +xunits, yunits : registered units, optional + Override axis units by specifying an instance of a + :class:`matplotlib.units.ConversionInterface`. + +antialiased : bool, optional + Enable antialiasing, overriding the defaults. For + filled contours, the default is *True*. For line contours, + it is taken from :rc:`lines.antialiased`.""" % _docstring.interpd.params) + + +@_docstring.Substitution(func='tricontour', type='lines') +@_docstring.dedent_interpd +def tricontour(ax, *args, **kwargs): + """ + %(_tricontour_doc)s + + linewidths : float or array-like, default: :rc:`contour.linewidth` + The line width of the contour lines. + + If a number, all levels will be plotted with this linewidth. + + If a sequence, the levels in ascending order will be plotted with + the linewidths in the order specified. + + If None, this falls back to :rc:`lines.linewidth`. + + linestyles : {*None*, 'solid', 'dashed', 'dashdot', 'dotted'}, optional + If *linestyles* is *None*, the default is 'solid' unless the lines are + monochrome. In that case, negative contours will take their linestyle + from :rc:`contour.negative_linestyle` setting. + + *linestyles* can also be an iterable of the above strings specifying a + set of linestyles to be used. If this iterable is shorter than the + number of contour levels it will be repeated as necessary. + """ + kwargs['filled'] = False + return TriContourSet(ax, *args, **kwargs) + + +@_docstring.Substitution(func='tricontourf', type='regions') +@_docstring.dedent_interpd +def tricontourf(ax, *args, **kwargs): + """ + %(_tricontour_doc)s + + hatches : list[str], optional + A list of crosshatch patterns to use on the filled areas. + If None, no hatching will be added to the contour. + Hatching is supported in the PostScript, PDF, SVG and Agg + backends only. + + Notes + ----- + `.tricontourf` fills intervals that are closed at the top; that is, for + boundaries *z1* and *z2*, the filled region is:: + + z1 < Z <= z2 + + except for the lowest interval, which is closed on both sides (i.e. it + includes the lowest value). + """ + kwargs['filled'] = True + return TriContourSet(ax, *args, **kwargs) diff --git a/lib/matplotlib/tri/_trifinder.py b/lib/matplotlib/tri/_trifinder.py new file mode 100644 index 000000000000..e06b84c0d974 --- /dev/null +++ b/lib/matplotlib/tri/_trifinder.py @@ -0,0 +1,93 @@ +import numpy as np + +from matplotlib import _api +from matplotlib.tri import Triangulation + + +class TriFinder: + """ + Abstract base class for classes used to find the triangles of a + Triangulation in which (x, y) points lie. + + Rather than instantiate an object of a class derived from TriFinder, it is + usually better to use the function `.Triangulation.get_trifinder`. + + Derived classes implement __call__(x, y) where x and y are array-like point + coordinates of the same shape. + """ + + def __init__(self, triangulation): + _api.check_isinstance(Triangulation, triangulation=triangulation) + self._triangulation = triangulation + + +class TrapezoidMapTriFinder(TriFinder): + """ + `~matplotlib.tri.TriFinder` class implemented using the trapezoid + map algorithm from the book "Computational Geometry, Algorithms and + Applications", second edition, by M. de Berg, M. van Kreveld, M. Overmars + and O. Schwarzkopf. + + The triangulation must be valid, i.e. it must not have duplicate points, + triangles formed from colinear points, or overlapping triangles. The + algorithm has some tolerance to triangles formed from colinear points, but + this should not be relied upon. + """ + + def __init__(self, triangulation): + from matplotlib import _tri + super().__init__(triangulation) + self._cpp_trifinder = _tri.TrapezoidMapTriFinder( + triangulation.get_cpp_triangulation()) + self._initialize() + + def __call__(self, x, y): + """ + Return an array containing the indices of the triangles in which the + specified *x*, *y* points lie, or -1 for points that do not lie within + a triangle. + + *x*, *y* are array-like x and y coordinates of the same shape and any + number of dimensions. + + Returns integer array with the same shape and *x* and *y*. + """ + x = np.asarray(x, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + if x.shape != y.shape: + raise ValueError("x and y must be array-like with the same shape") + + # C++ does the heavy lifting, and expects 1D arrays. + indices = (self._cpp_trifinder.find_many(x.ravel(), y.ravel()) + .reshape(x.shape)) + return indices + + def _get_tree_stats(self): + """ + Return a python list containing the statistics about the node tree: + 0: number of nodes (tree size) + 1: number of unique nodes + 2: number of trapezoids (tree leaf nodes) + 3: number of unique trapezoids + 4: maximum parent count (max number of times a node is repeated in + tree) + 5: maximum depth of tree (one more than the maximum number of + comparisons needed to search through the tree) + 6: mean of all trapezoid depths (one more than the average number + of comparisons needed to search through the tree) + """ + return self._cpp_trifinder.get_tree_stats() + + def _initialize(self): + """ + Initialize the underlying C++ object. Can be called multiple times if, + for example, the triangulation is modified. + """ + self._cpp_trifinder.initialize() + + def _print_tree(self): + """ + Print a text representation of the node tree, which is useful for + debugging purposes. + """ + self._cpp_trifinder.print_tree() diff --git a/lib/matplotlib/tri/_triinterpolate.py b/lib/matplotlib/tri/_triinterpolate.py new file mode 100644 index 000000000000..df276d8c6447 --- /dev/null +++ b/lib/matplotlib/tri/_triinterpolate.py @@ -0,0 +1,1574 @@ +""" +Interpolation inside triangular grids. +""" + +import numpy as np + +from matplotlib import _api +from matplotlib.tri import Triangulation +from matplotlib.tri._trifinder import TriFinder +from matplotlib.tri._tritools import TriAnalyzer + +__all__ = ('TriInterpolator', 'LinearTriInterpolator', 'CubicTriInterpolator') + + +class TriInterpolator: + """ + Abstract base class for classes used to interpolate on a triangular grid. + + Derived classes implement the following methods: + + - ``__call__(x, y)``, + where x, y are array-like point coordinates of the same shape, and + that returns a masked array of the same shape containing the + interpolated z-values. + + - ``gradient(x, y)``, + where x, y are array-like point coordinates of the same + shape, and that returns a list of 2 masked arrays of the same shape + containing the 2 derivatives of the interpolator (derivatives of + interpolated z values with respect to x and y). + """ + + def __init__(self, triangulation, z, trifinder=None): + _api.check_isinstance(Triangulation, triangulation=triangulation) + self._triangulation = triangulation + + self._z = np.asarray(z) + if self._z.shape != self._triangulation.x.shape: + raise ValueError("z array must have same length as triangulation x" + " and y arrays") + + _api.check_isinstance((TriFinder, None), trifinder=trifinder) + self._trifinder = trifinder or self._triangulation.get_trifinder() + + # Default scaling factors : 1.0 (= no scaling) + # Scaling may be used for interpolations for which the order of + # magnitude of x, y has an impact on the interpolant definition. + # Please refer to :meth:`_interpolate_multikeys` for details. + self._unit_x = 1.0 + self._unit_y = 1.0 + + # Default triangle renumbering: None (= no renumbering) + # Renumbering may be used to avoid unnecessary computations + # if complex calculations are done inside the Interpolator. + # Please refer to :meth:`_interpolate_multikeys` for details. + self._tri_renum = None + + # __call__ and gradient docstrings are shared by all subclasses + # (except, if needed, relevant additions). + # However these methods are only implemented in subclasses to avoid + # confusion in the documentation. + _docstring__call__ = """ + Returns a masked array containing interpolated values at the specified + (x, y) points. + + Parameters + ---------- + x, y : array-like + x and y coordinates of the same shape and any number of + dimensions. + + Returns + ------- + np.ma.array + Masked array of the same shape as *x* and *y*; values corresponding + to (*x*, *y*) points outside of the triangulation are masked out. + + """ + + _docstringgradient = r""" + Returns a list of 2 masked arrays containing interpolated derivatives + at the specified (x, y) points. + + Parameters + ---------- + x, y : array-like + x and y coordinates of the same shape and any number of + dimensions. + + Returns + ------- + dzdx, dzdy : np.ma.array + 2 masked arrays of the same shape as *x* and *y*; values + corresponding to (x, y) points outside of the triangulation + are masked out. + The first returned array contains the values of + :math:`\frac{\partial z}{\partial x}` and the second those of + :math:`\frac{\partial z}{\partial y}`. + + """ + + def _interpolate_multikeys(self, x, y, tri_index=None, + return_keys=('z',)): + """ + Versatile (private) method defined for all TriInterpolators. + + :meth:`_interpolate_multikeys` is a wrapper around method + :meth:`_interpolate_single_key` (to be defined in the child + subclasses). + :meth:`_interpolate_single_key actually performs the interpolation, + but only for 1-dimensional inputs and at valid locations (inside + unmasked triangles of the triangulation). + + The purpose of :meth:`_interpolate_multikeys` is to implement the + following common tasks needed in all subclasses implementations: + + - calculation of containing triangles + - dealing with more than one interpolation request at the same + location (e.g., if the 2 derivatives are requested, it is + unnecessary to compute the containing triangles twice) + - scaling according to self._unit_x, self._unit_y + - dealing with points outside of the grid (with fill value np.nan) + - dealing with multi-dimensional *x*, *y* arrays: flattening for + :meth:`_interpolate_params` call and final reshaping. + + (Note that np.vectorize could do most of those things very well for + you, but it does it by function evaluations over successive tuples of + the input arrays. Therefore, this tends to be more time-consuming than + using optimized numpy functions - e.g., np.dot - which can be used + easily on the flattened inputs, in the child-subclass methods + :meth:`_interpolate_single_key`.) + + It is guaranteed that the calls to :meth:`_interpolate_single_key` + will be done with flattened (1-d) array-like input parameters *x*, *y* + and with flattened, valid `tri_index` arrays (no -1 index allowed). + + Parameters + ---------- + x, y : array-like + x and y coordinates where interpolated values are requested. + tri_index : array-like of int, optional + Array of the containing triangle indices, same shape as + *x* and *y*. Defaults to None. If None, these indices + will be computed by a TriFinder instance. + (Note: For point outside the grid, tri_index[ipt] shall be -1). + return_keys : tuple of keys from {'z', 'dzdx', 'dzdy'} + Defines the interpolation arrays to return, and in which order. + + Returns + ------- + list of arrays + Each array-like contains the expected interpolated values in the + order defined by *return_keys* parameter. + """ + # Flattening and rescaling inputs arrays x, y + # (initial shape is stored for output) + x = np.asarray(x, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + sh_ret = x.shape + if x.shape != y.shape: + raise ValueError("x and y shall have same shapes." + " Given: {0} and {1}".format(x.shape, y.shape)) + x = np.ravel(x) + y = np.ravel(y) + x_scaled = x/self._unit_x + y_scaled = y/self._unit_y + size_ret = np.size(x_scaled) + + # Computes & ravels the element indexes, extract the valid ones. + if tri_index is None: + tri_index = self._trifinder(x, y) + else: + if tri_index.shape != sh_ret: + raise ValueError( + "tri_index array is provided and shall" + " have same shape as x and y. Given: " + "{0} and {1}".format(tri_index.shape, sh_ret)) + tri_index = np.ravel(tri_index) + + mask_in = (tri_index != -1) + if self._tri_renum is None: + valid_tri_index = tri_index[mask_in] + else: + valid_tri_index = self._tri_renum[tri_index[mask_in]] + valid_x = x_scaled[mask_in] + valid_y = y_scaled[mask_in] + + ret = [] + for return_key in return_keys: + # Find the return index associated with the key. + try: + return_index = {'z': 0, 'dzdx': 1, 'dzdy': 2}[return_key] + except KeyError as err: + raise ValueError("return_keys items shall take values in" + " {'z', 'dzdx', 'dzdy'}") from err + + # Sets the scale factor for f & df components + scale = [1., 1./self._unit_x, 1./self._unit_y][return_index] + + # Computes the interpolation + ret_loc = np.empty(size_ret, dtype=np.float64) + ret_loc[~mask_in] = np.nan + ret_loc[mask_in] = self._interpolate_single_key( + return_key, valid_tri_index, valid_x, valid_y) * scale + ret += [np.ma.masked_invalid(ret_loc.reshape(sh_ret), copy=False)] + + return ret + + def _interpolate_single_key(self, return_key, tri_index, x, y): + """ + Interpolate at points belonging to the triangulation + (inside an unmasked triangles). + + Parameters + ---------- + return_key : {'z', 'dzdx', 'dzdy'} + The requested values (z or its derivatives). + tri_index : 1D int array + Valid triangle index (cannot be -1). + x, y : 1D arrays, same shape as `tri_index` + Valid locations where interpolation is requested. + + Returns + ------- + 1-d array + Returned array of the same size as *tri_index* + """ + raise NotImplementedError("TriInterpolator subclasses" + + "should implement _interpolate_single_key!") + + +class LinearTriInterpolator(TriInterpolator): + """ + Linear interpolator on a triangular grid. + + Each triangle is represented by a plane so that an interpolated value at + point (x, y) lies on the plane of the triangle containing (x, y). + Interpolated values are therefore continuous across the triangulation, but + their first derivatives are discontinuous at edges between triangles. + + Parameters + ---------- + triangulation : `~matplotlib.tri.Triangulation` + The triangulation to interpolate over. + z : (npoints,) array-like + Array of values, defined at grid points, to interpolate between. + trifinder : `~matplotlib.tri.TriFinder`, optional + If this is not specified, the Triangulation's default TriFinder will + be used by calling `.Triangulation.get_trifinder`. + + Methods + ------- + `__call__` (x, y) : Returns interpolated values at (x, y) points. + `gradient` (x, y) : Returns interpolated derivatives at (x, y) points. + + """ + def __init__(self, triangulation, z, trifinder=None): + super().__init__(triangulation, z, trifinder) + + # Store plane coefficients for fast interpolation calculations. + self._plane_coefficients = \ + self._triangulation.calculate_plane_coefficients(self._z) + + def __call__(self, x, y): + return self._interpolate_multikeys(x, y, tri_index=None, + return_keys=('z',))[0] + __call__.__doc__ = TriInterpolator._docstring__call__ + + def gradient(self, x, y): + return self._interpolate_multikeys(x, y, tri_index=None, + return_keys=('dzdx', 'dzdy')) + gradient.__doc__ = TriInterpolator._docstringgradient + + def _interpolate_single_key(self, return_key, tri_index, x, y): + _api.check_in_list(['z', 'dzdx', 'dzdy'], return_key=return_key) + if return_key == 'z': + return (self._plane_coefficients[tri_index, 0]*x + + self._plane_coefficients[tri_index, 1]*y + + self._plane_coefficients[tri_index, 2]) + elif return_key == 'dzdx': + return self._plane_coefficients[tri_index, 0] + else: # 'dzdy' + return self._plane_coefficients[tri_index, 1] + + +class CubicTriInterpolator(TriInterpolator): + r""" + Cubic interpolator on a triangular grid. + + In one-dimension - on a segment - a cubic interpolating function is + defined by the values of the function and its derivative at both ends. + This is almost the same in 2D inside a triangle, except that the values + of the function and its 2 derivatives have to be defined at each triangle + node. + + The CubicTriInterpolator takes the value of the function at each node - + provided by the user - and internally computes the value of the + derivatives, resulting in a smooth interpolation. + (As a special feature, the user can also impose the value of the + derivatives at each node, but this is not supposed to be the common + usage.) + + Parameters + ---------- + triangulation : `~matplotlib.tri.Triangulation` + The triangulation to interpolate over. + z : (npoints,) array-like + Array of values, defined at grid points, to interpolate between. + kind : {'min_E', 'geom', 'user'}, optional + Choice of the smoothing algorithm, in order to compute + the interpolant derivatives (defaults to 'min_E'): + + - if 'min_E': (default) The derivatives at each node is computed + to minimize a bending energy. + - if 'geom': The derivatives at each node is computed as a + weighted average of relevant triangle normals. To be used for + speed optimization (large grids). + - if 'user': The user provides the argument *dz*, no computation + is hence needed. + + trifinder : `~matplotlib.tri.TriFinder`, optional + If not specified, the Triangulation's default TriFinder will + be used by calling `.Triangulation.get_trifinder`. + dz : tuple of array-likes (dzdx, dzdy), optional + Used only if *kind* ='user'. In this case *dz* must be provided as + (dzdx, dzdy) where dzdx, dzdy are arrays of the same shape as *z* and + are the interpolant first derivatives at the *triangulation* points. + + Methods + ------- + `__call__` (x, y) : Returns interpolated values at (x, y) points. + `gradient` (x, y) : Returns interpolated derivatives at (x, y) points. + + Notes + ----- + This note is a bit technical and details how the cubic interpolation is + computed. + + The interpolation is based on a Clough-Tocher subdivision scheme of + the *triangulation* mesh (to make it clearer, each triangle of the + grid will be divided in 3 child-triangles, and on each child triangle + the interpolated function is a cubic polynomial of the 2 coordinates). + This technique originates from FEM (Finite Element Method) analysis; + the element used is a reduced Hsieh-Clough-Tocher (HCT) + element. Its shape functions are described in [1]_. + The assembled function is guaranteed to be C1-smooth, i.e. it is + continuous and its first derivatives are also continuous (this + is easy to show inside the triangles but is also true when crossing the + edges). + + In the default case (*kind* ='min_E'), the interpolant minimizes a + curvature energy on the functional space generated by the HCT element + shape functions - with imposed values but arbitrary derivatives at each + node. The minimized functional is the integral of the so-called total + curvature (implementation based on an algorithm from [2]_ - PCG sparse + solver): + + .. math:: + + E(z) = \frac{1}{2} \int_{\Omega} \left( + \left( \frac{\partial^2{z}}{\partial{x}^2} \right)^2 + + \left( \frac{\partial^2{z}}{\partial{y}^2} \right)^2 + + 2\left( \frac{\partial^2{z}}{\partial{y}\partial{x}} \right)^2 + \right) dx\,dy + + If the case *kind* ='geom' is chosen by the user, a simple geometric + approximation is used (weighted average of the triangle normal + vectors), which could improve speed on very large grids. + + References + ---------- + .. [1] Michel Bernadou, Kamal Hassan, "Basis functions for general + Hsieh-Clough-Tocher triangles, complete or reduced.", + International Journal for Numerical Methods in Engineering, + 17(5):784 - 789. 2.01. + .. [2] C.T. Kelley, "Iterative Methods for Optimization". + + """ + def __init__(self, triangulation, z, kind='min_E', trifinder=None, + dz=None): + super().__init__(triangulation, z, trifinder) + + # Loads the underlying c++ _triangulation. + # (During loading, reordering of triangulation._triangles may occur so + # that all final triangles are now anti-clockwise) + self._triangulation.get_cpp_triangulation() + + # To build the stiffness matrix and avoid zero-energy spurious modes + # we will only store internally the valid (unmasked) triangles and + # the necessary (used) points coordinates. + # 2 renumbering tables need to be computed and stored: + # - a triangle renum table in order to translate the result from a + # TriFinder instance into the internal stored triangle number. + # - a node renum table to overwrite the self._z values into the new + # (used) node numbering. + tri_analyzer = TriAnalyzer(self._triangulation) + (compressed_triangles, compressed_x, compressed_y, tri_renum, + node_renum) = tri_analyzer._get_compressed_triangulation() + self._triangles = compressed_triangles + self._tri_renum = tri_renum + # Taking into account the node renumbering in self._z: + valid_node = (node_renum != -1) + self._z[node_renum[valid_node]] = self._z[valid_node] + + # Computing scale factors + self._unit_x = np.ptp(compressed_x) + self._unit_y = np.ptp(compressed_y) + self._pts = np.column_stack([compressed_x / self._unit_x, + compressed_y / self._unit_y]) + # Computing triangle points + self._tris_pts = self._pts[self._triangles] + # Computing eccentricities + self._eccs = self._compute_tri_eccentricities(self._tris_pts) + # Computing dof estimations for HCT triangle shape function + _api.check_in_list(['user', 'geom', 'min_E'], kind=kind) + self._dof = self._compute_dof(kind, dz=dz) + # Loading HCT element + self._ReferenceElement = _ReducedHCT_Element() + + def __call__(self, x, y): + return self._interpolate_multikeys(x, y, tri_index=None, + return_keys=('z',))[0] + __call__.__doc__ = TriInterpolator._docstring__call__ + + def gradient(self, x, y): + return self._interpolate_multikeys(x, y, tri_index=None, + return_keys=('dzdx', 'dzdy')) + gradient.__doc__ = TriInterpolator._docstringgradient + + def _interpolate_single_key(self, return_key, tri_index, x, y): + _api.check_in_list(['z', 'dzdx', 'dzdy'], return_key=return_key) + tris_pts = self._tris_pts[tri_index] + alpha = self._get_alpha_vec(x, y, tris_pts) + ecc = self._eccs[tri_index] + dof = np.expand_dims(self._dof[tri_index], axis=1) + if return_key == 'z': + return self._ReferenceElement.get_function_values( + alpha, ecc, dof) + else: # 'dzdx', 'dzdy' + J = self._get_jacobian(tris_pts) + dzdx = self._ReferenceElement.get_function_derivatives( + alpha, J, ecc, dof) + if return_key == 'dzdx': + return dzdx[:, 0, 0] + else: + return dzdx[:, 1, 0] + + def _compute_dof(self, kind, dz=None): + """ + Compute and return nodal dofs according to kind. + + Parameters + ---------- + kind : {'min_E', 'geom', 'user'} + Choice of the _DOF_estimator subclass to estimate the gradient. + dz : tuple of array-likes (dzdx, dzdy), optional + Used only if *kind*=user; in this case passed to the + :class:`_DOF_estimator_user`. + + Returns + ------- + array-like, shape (npts, 2) + Estimation of the gradient at triangulation nodes (stored as + degree of freedoms of reduced-HCT triangle elements). + """ + if kind == 'user': + if dz is None: + raise ValueError("For a CubicTriInterpolator with " + "*kind*='user', a valid *dz* " + "argument is expected.") + TE = _DOF_estimator_user(self, dz=dz) + elif kind == 'geom': + TE = _DOF_estimator_geom(self) + else: # 'min_E', checked in __init__ + TE = _DOF_estimator_min_E(self) + return TE.compute_dof_from_df() + + @staticmethod + def _get_alpha_vec(x, y, tris_pts): + """ + Fast (vectorized) function to compute barycentric coordinates alpha. + + Parameters + ---------- + x, y : array-like of dim 1 (shape (nx,)) + Coordinates of the points whose points barycentric coordinates are + requested. + tris_pts : array like of dim 3 (shape: (nx, 3, 2)) + Coordinates of the containing triangles apexes. + + Returns + ------- + array of dim 2 (shape (nx, 3)) + Barycentric coordinates of the points inside the containing + triangles. + """ + ndim = tris_pts.ndim-2 + + a = tris_pts[:, 1, :] - tris_pts[:, 0, :] + b = tris_pts[:, 2, :] - tris_pts[:, 0, :] + abT = np.stack([a, b], axis=-1) + ab = _transpose_vectorized(abT) + OM = np.stack([x, y], axis=1) - tris_pts[:, 0, :] + + metric = ab @ abT + # Here we try to deal with the colinear cases. + # metric_inv is in this case set to the Moore-Penrose pseudo-inverse + # meaning that we will still return a set of valid barycentric + # coordinates. + metric_inv = _pseudo_inv22sym_vectorized(metric) + Covar = ab @ _transpose_vectorized(np.expand_dims(OM, ndim)) + ksi = metric_inv @ Covar + alpha = _to_matrix_vectorized([ + [1-ksi[:, 0, 0]-ksi[:, 1, 0]], [ksi[:, 0, 0]], [ksi[:, 1, 0]]]) + return alpha + + @staticmethod + def _get_jacobian(tris_pts): + """ + Fast (vectorized) function to compute triangle jacobian matrix. + + Parameters + ---------- + tris_pts : array like of dim 3 (shape: (nx, 3, 2)) + Coordinates of the containing triangles apexes. + + Returns + ------- + array of dim 3 (shape (nx, 2, 2)) + Barycentric coordinates of the points inside the containing + triangles. + J[itri, :, :] is the jacobian matrix at apex 0 of the triangle + itri, so that the following (matrix) relationship holds: + [dz/dksi] = [J] x [dz/dx] + with x: global coordinates + ksi: element parametric coordinates in triangle first apex + local basis. + """ + a = np.array(tris_pts[:, 1, :] - tris_pts[:, 0, :]) + b = np.array(tris_pts[:, 2, :] - tris_pts[:, 0, :]) + J = _to_matrix_vectorized([[a[:, 0], a[:, 1]], + [b[:, 0], b[:, 1]]]) + return J + + @staticmethod + def _compute_tri_eccentricities(tris_pts): + """ + Compute triangle eccentricities. + + Parameters + ---------- + tris_pts : array like of dim 3 (shape: (nx, 3, 2)) + Coordinates of the triangles apexes. + + Returns + ------- + array like of dim 2 (shape: (nx, 3)) + The so-called eccentricity parameters [1] needed for HCT triangular + element. + """ + a = np.expand_dims(tris_pts[:, 2, :] - tris_pts[:, 1, :], axis=2) + b = np.expand_dims(tris_pts[:, 0, :] - tris_pts[:, 2, :], axis=2) + c = np.expand_dims(tris_pts[:, 1, :] - tris_pts[:, 0, :], axis=2) + # Do not use np.squeeze, this is dangerous if only one triangle + # in the triangulation... + dot_a = (_transpose_vectorized(a) @ a)[:, 0, 0] + dot_b = (_transpose_vectorized(b) @ b)[:, 0, 0] + dot_c = (_transpose_vectorized(c) @ c)[:, 0, 0] + # Note that this line will raise a warning for dot_a, dot_b or dot_c + # zeros, but we choose not to support triangles with duplicate points. + return _to_matrix_vectorized([[(dot_c-dot_b) / dot_a], + [(dot_a-dot_c) / dot_b], + [(dot_b-dot_a) / dot_c]]) + + +# FEM element used for interpolation and for solving minimisation +# problem (Reduced HCT element) +class _ReducedHCT_Element: + """ + Implementation of reduced HCT triangular element with explicit shape + functions. + + Computes z, dz, d2z and the element stiffness matrix for bending energy: + E(f) = integral( (d2z/dx2 + d2z/dy2)**2 dA) + + *** Reference for the shape functions: *** + [1] Basis functions for general Hsieh-Clough-Tocher _triangles, complete or + reduced. + Michel Bernadou, Kamal Hassan + International Journal for Numerical Methods in Engineering. + 17(5):784 - 789. 2.01 + + *** Element description: *** + 9 dofs: z and dz given at 3 apex + C1 (conform) + + """ + # 1) Loads matrices to generate shape functions as a function of + # triangle eccentricities - based on [1] p.11 ''' + M = np.array([ + [ 0.00, 0.00, 0.00, 4.50, 4.50, 0.00, 0.00, 0.00, 0.00, 0.00], + [-0.25, 0.00, 0.00, 0.50, 1.25, 0.00, 0.00, 0.00, 0.00, 0.00], + [-0.25, 0.00, 0.00, 1.25, 0.50, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.50, 1.00, 0.00, -1.50, 0.00, 3.00, 3.00, 0.00, 0.00, 3.00], + [ 0.00, 0.00, 0.00, -0.25, 0.25, 0.00, 1.00, 0.00, 0.00, 0.50], + [ 0.25, 0.00, 0.00, -0.50, -0.25, 1.00, 0.00, 0.00, 0.00, 1.00], + [ 0.50, 0.00, 1.00, 0.00, -1.50, 0.00, 0.00, 3.00, 3.00, 3.00], + [ 0.25, 0.00, 0.00, -0.25, -0.50, 0.00, 0.00, 0.00, 1.00, 1.00], + [ 0.00, 0.00, 0.00, 0.25, -0.25, 0.00, 0.00, 1.00, 0.00, 0.50]]) + M0 = np.array([ + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [-1.00, 0.00, 0.00, 1.50, 1.50, 0.00, 0.00, 0.00, 0.00, -3.00], + [-0.50, 0.00, 0.00, 0.75, 0.75, 0.00, 0.00, 0.00, 0.00, -1.50], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 1.00, 0.00, 0.00, -1.50, -1.50, 0.00, 0.00, 0.00, 0.00, 3.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.50, 0.00, 0.00, -0.75, -0.75, 0.00, 0.00, 0.00, 0.00, 1.50]]) + M1 = np.array([ + [-0.50, 0.00, 0.00, 1.50, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [-0.25, 0.00, 0.00, 0.75, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.50, 0.00, 0.00, -1.50, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.25, 0.00, 0.00, -0.75, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]]) + M2 = np.array([ + [ 0.50, 0.00, 0.00, 0.00, -1.50, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.25, 0.00, 0.00, 0.00, -0.75, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [-0.50, 0.00, 0.00, 0.00, 1.50, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [-0.25, 0.00, 0.00, 0.00, 0.75, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], + [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]]) + + # 2) Loads matrices to rotate components of gradient & Hessian + # vectors in the reference basis of triangle first apex (a0) + rotate_dV = np.array([[ 1., 0.], [ 0., 1.], + [ 0., 1.], [-1., -1.], + [-1., -1.], [ 1., 0.]]) + + rotate_d2V = np.array([[1., 0., 0.], [0., 1., 0.], [ 0., 0., 1.], + [0., 1., 0.], [1., 1., 1.], [ 0., -2., -1.], + [1., 1., 1.], [1., 0., 0.], [-2., 0., -1.]]) + + # 3) Loads Gauss points & weights on the 3 sub-_triangles for P2 + # exact integral - 3 points on each subtriangles. + # NOTE: as the 2nd derivative is discontinuous , we really need those 9 + # points! + n_gauss = 9 + gauss_pts = np.array([[13./18., 4./18., 1./18.], + [ 4./18., 13./18., 1./18.], + [ 7./18., 7./18., 4./18.], + [ 1./18., 13./18., 4./18.], + [ 1./18., 4./18., 13./18.], + [ 4./18., 7./18., 7./18.], + [ 4./18., 1./18., 13./18.], + [13./18., 1./18., 4./18.], + [ 7./18., 4./18., 7./18.]], dtype=np.float64) + gauss_w = np.ones([9], dtype=np.float64) / 9. + + # 4) Stiffness matrix for curvature energy + E = np.array([[1., 0., 0.], [0., 1., 0.], [0., 0., 2.]]) + + # 5) Loads the matrix to compute DOF_rot from tri_J at apex 0 + J0_to_J1 = np.array([[-1., 1.], [-1., 0.]]) + J0_to_J2 = np.array([[ 0., -1.], [ 1., -1.]]) + + def get_function_values(self, alpha, ecc, dofs): + """ + Parameters + ---------- + alpha : is a (N x 3 x 1) array (array of column-matrices) of + barycentric coordinates, + ecc : is a (N x 3 x 1) array (array of column-matrices) of triangle + eccentricities, + dofs : is a (N x 1 x 9) arrays (arrays of row-matrices) of computed + degrees of freedom. + + Returns + ------- + Returns the N-array of interpolated function values. + """ + subtri = np.argmin(alpha, axis=1)[:, 0] + ksi = _roll_vectorized(alpha, -subtri, axis=0) + E = _roll_vectorized(ecc, -subtri, axis=0) + x = ksi[:, 0, 0] + y = ksi[:, 1, 0] + z = ksi[:, 2, 0] + x_sq = x*x + y_sq = y*y + z_sq = z*z + V = _to_matrix_vectorized([ + [x_sq*x], [y_sq*y], [z_sq*z], [x_sq*z], [x_sq*y], [y_sq*x], + [y_sq*z], [z_sq*y], [z_sq*x], [x*y*z]]) + prod = self.M @ V + prod += _scalar_vectorized(E[:, 0, 0], self.M0 @ V) + prod += _scalar_vectorized(E[:, 1, 0], self.M1 @ V) + prod += _scalar_vectorized(E[:, 2, 0], self.M2 @ V) + s = _roll_vectorized(prod, 3*subtri, axis=0) + return (dofs @ s)[:, 0, 0] + + def get_function_derivatives(self, alpha, J, ecc, dofs): + """ + Parameters + ---------- + *alpha* is a (N x 3 x 1) array (array of column-matrices of + barycentric coordinates) + *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at + triangle first apex) + *ecc* is a (N x 3 x 1) array (array of column-matrices of triangle + eccentricities) + *dofs* is a (N x 1 x 9) arrays (arrays of row-matrices) of computed + degrees of freedom. + + Returns + ------- + Returns the values of interpolated function derivatives [dz/dx, dz/dy] + in global coordinates at locations alpha, as a column-matrices of + shape (N x 2 x 1). + """ + subtri = np.argmin(alpha, axis=1)[:, 0] + ksi = _roll_vectorized(alpha, -subtri, axis=0) + E = _roll_vectorized(ecc, -subtri, axis=0) + x = ksi[:, 0, 0] + y = ksi[:, 1, 0] + z = ksi[:, 2, 0] + x_sq = x*x + y_sq = y*y + z_sq = z*z + dV = _to_matrix_vectorized([ + [ -3.*x_sq, -3.*x_sq], + [ 3.*y_sq, 0.], + [ 0., 3.*z_sq], + [ -2.*x*z, -2.*x*z+x_sq], + [-2.*x*y+x_sq, -2.*x*y], + [ 2.*x*y-y_sq, -y_sq], + [ 2.*y*z, y_sq], + [ z_sq, 2.*y*z], + [ -z_sq, 2.*x*z-z_sq], + [ x*z-y*z, x*y-y*z]]) + # Puts back dV in first apex basis + dV = dV @ _extract_submatrices( + self.rotate_dV, subtri, block_size=2, axis=0) + + prod = self.M @ dV + prod += _scalar_vectorized(E[:, 0, 0], self.M0 @ dV) + prod += _scalar_vectorized(E[:, 1, 0], self.M1 @ dV) + prod += _scalar_vectorized(E[:, 2, 0], self.M2 @ dV) + dsdksi = _roll_vectorized(prod, 3*subtri, axis=0) + dfdksi = dofs @ dsdksi + # In global coordinates: + # Here we try to deal with the simplest colinear cases, returning a + # null matrix. + J_inv = _safe_inv22_vectorized(J) + dfdx = J_inv @ _transpose_vectorized(dfdksi) + return dfdx + + def get_function_hessians(self, alpha, J, ecc, dofs): + """ + Parameters + ---------- + *alpha* is a (N x 3 x 1) array (array of column-matrices) of + barycentric coordinates + *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at + triangle first apex) + *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle + eccentricities + *dofs* is a (N x 1 x 9) arrays (arrays of row-matrices) of computed + degrees of freedom. + + Returns + ------- + Returns the values of interpolated function 2nd-derivatives + [d2z/dx2, d2z/dy2, d2z/dxdy] in global coordinates at locations alpha, + as a column-matrices of shape (N x 3 x 1). + """ + d2sdksi2 = self.get_d2Sidksij2(alpha, ecc) + d2fdksi2 = dofs @ d2sdksi2 + H_rot = self.get_Hrot_from_J(J) + d2fdx2 = d2fdksi2 @ H_rot + return _transpose_vectorized(d2fdx2) + + def get_d2Sidksij2(self, alpha, ecc): + """ + Parameters + ---------- + *alpha* is a (N x 3 x 1) array (array of column-matrices) of + barycentric coordinates + *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle + eccentricities + + Returns + ------- + Returns the arrays d2sdksi2 (N x 3 x 1) Hessian of shape functions + expressed in covariant coordinates in first apex basis. + """ + subtri = np.argmin(alpha, axis=1)[:, 0] + ksi = _roll_vectorized(alpha, -subtri, axis=0) + E = _roll_vectorized(ecc, -subtri, axis=0) + x = ksi[:, 0, 0] + y = ksi[:, 1, 0] + z = ksi[:, 2, 0] + d2V = _to_matrix_vectorized([ + [ 6.*x, 6.*x, 6.*x], + [ 6.*y, 0., 0.], + [ 0., 6.*z, 0.], + [ 2.*z, 2.*z-4.*x, 2.*z-2.*x], + [2.*y-4.*x, 2.*y, 2.*y-2.*x], + [2.*x-4.*y, 0., -2.*y], + [ 2.*z, 0., 2.*y], + [ 0., 2.*y, 2.*z], + [ 0., 2.*x-4.*z, -2.*z], + [ -2.*z, -2.*y, x-y-z]]) + # Puts back d2V in first apex basis + d2V = d2V @ _extract_submatrices( + self.rotate_d2V, subtri, block_size=3, axis=0) + prod = self.M @ d2V + prod += _scalar_vectorized(E[:, 0, 0], self.M0 @ d2V) + prod += _scalar_vectorized(E[:, 1, 0], self.M1 @ d2V) + prod += _scalar_vectorized(E[:, 2, 0], self.M2 @ d2V) + d2sdksi2 = _roll_vectorized(prod, 3*subtri, axis=0) + return d2sdksi2 + + def get_bending_matrices(self, J, ecc): + """ + Parameters + ---------- + *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at + triangle first apex) + *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle + eccentricities + + Returns + ------- + Returns the element K matrices for bending energy expressed in + GLOBAL nodal coordinates. + K_ij = integral [ (d2zi/dx2 + d2zi/dy2) * (d2zj/dx2 + d2zj/dy2) dA] + tri_J is needed to rotate dofs from local basis to global basis + """ + n = np.size(ecc, 0) + + # 1) matrix to rotate dofs in global coordinates + J1 = self.J0_to_J1 @ J + J2 = self.J0_to_J2 @ J + DOF_rot = np.zeros([n, 9, 9], dtype=np.float64) + DOF_rot[:, 0, 0] = 1 + DOF_rot[:, 3, 3] = 1 + DOF_rot[:, 6, 6] = 1 + DOF_rot[:, 1:3, 1:3] = J + DOF_rot[:, 4:6, 4:6] = J1 + DOF_rot[:, 7:9, 7:9] = J2 + + # 2) matrix to rotate Hessian in global coordinates. + H_rot, area = self.get_Hrot_from_J(J, return_area=True) + + # 3) Computes stiffness matrix + # Gauss quadrature. + K = np.zeros([n, 9, 9], dtype=np.float64) + weights = self.gauss_w + pts = self.gauss_pts + for igauss in range(self.n_gauss): + alpha = np.tile(pts[igauss, :], n).reshape(n, 3) + alpha = np.expand_dims(alpha, 2) + weight = weights[igauss] + d2Skdksi2 = self.get_d2Sidksij2(alpha, ecc) + d2Skdx2 = d2Skdksi2 @ H_rot + K += weight * (d2Skdx2 @ self.E @ _transpose_vectorized(d2Skdx2)) + + # 4) With nodal (not elem) dofs + K = _transpose_vectorized(DOF_rot) @ K @ DOF_rot + + # 5) Need the area to compute total element energy + return _scalar_vectorized(area, K) + + def get_Hrot_from_J(self, J, return_area=False): + """ + Parameters + ---------- + *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at + triangle first apex) + + Returns + ------- + Returns H_rot used to rotate Hessian from local basis of first apex, + to global coordinates. + if *return_area* is True, returns also the triangle area (0.5*det(J)) + """ + # Here we try to deal with the simplest colinear cases; a null + # energy and area is imposed. + J_inv = _safe_inv22_vectorized(J) + Ji00 = J_inv[:, 0, 0] + Ji11 = J_inv[:, 1, 1] + Ji10 = J_inv[:, 1, 0] + Ji01 = J_inv[:, 0, 1] + H_rot = _to_matrix_vectorized([ + [Ji00*Ji00, Ji10*Ji10, Ji00*Ji10], + [Ji01*Ji01, Ji11*Ji11, Ji01*Ji11], + [2*Ji00*Ji01, 2*Ji11*Ji10, Ji00*Ji11+Ji10*Ji01]]) + if not return_area: + return H_rot + else: + area = 0.5 * (J[:, 0, 0]*J[:, 1, 1] - J[:, 0, 1]*J[:, 1, 0]) + return H_rot, area + + def get_Kff_and_Ff(self, J, ecc, triangles, Uc): + """ + Build K and F for the following elliptic formulation: + minimization of curvature energy with value of function at node + imposed and derivatives 'free'. + + Build the global Kff matrix in cco format. + Build the full Ff vec Ff = - Kfc x Uc. + + Parameters + ---------- + *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at + triangle first apex) + *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle + eccentricities + *triangles* is a (N x 3) array of nodes indexes. + *Uc* is (N x 3) array of imposed displacements at nodes + + Returns + ------- + (Kff_rows, Kff_cols, Kff_vals) Kff matrix in coo format - Duplicate + (row, col) entries must be summed. + Ff: force vector - dim npts * 3 + """ + ntri = np.size(ecc, 0) + vec_range = np.arange(ntri, dtype=np.int32) + c_indices = np.full(ntri, -1, dtype=np.int32) # for unused dofs, -1 + f_dof = [1, 2, 4, 5, 7, 8] + c_dof = [0, 3, 6] + + # vals, rows and cols indices in global dof numbering + f_dof_indices = _to_matrix_vectorized([[ + c_indices, triangles[:, 0]*2, triangles[:, 0]*2+1, + c_indices, triangles[:, 1]*2, triangles[:, 1]*2+1, + c_indices, triangles[:, 2]*2, triangles[:, 2]*2+1]]) + + expand_indices = np.ones([ntri, 9, 1], dtype=np.int32) + f_row_indices = _transpose_vectorized(expand_indices @ f_dof_indices) + f_col_indices = expand_indices @ f_dof_indices + K_elem = self.get_bending_matrices(J, ecc) + + # Extracting sub-matrices + # Explanation & notations: + # * Subscript f denotes 'free' degrees of freedom (i.e. dz/dx, dz/dx) + # * Subscript c denotes 'condensated' (imposed) degrees of freedom + # (i.e. z at all nodes) + # * F = [Ff, Fc] is the force vector + # * U = [Uf, Uc] is the imposed dof vector + # [ Kff Kfc ] + # * K = [ ] is the laplacian stiffness matrix + # [ Kcf Kff ] + # * As F = K x U one gets straightforwardly: Ff = - Kfc x Uc + + # Computing Kff stiffness matrix in sparse coo format + Kff_vals = np.ravel(K_elem[np.ix_(vec_range, f_dof, f_dof)]) + Kff_rows = np.ravel(f_row_indices[np.ix_(vec_range, f_dof, f_dof)]) + Kff_cols = np.ravel(f_col_indices[np.ix_(vec_range, f_dof, f_dof)]) + + # Computing Ff force vector in sparse coo format + Kfc_elem = K_elem[np.ix_(vec_range, f_dof, c_dof)] + Uc_elem = np.expand_dims(Uc, axis=2) + Ff_elem = -(Kfc_elem @ Uc_elem)[:, :, 0] + Ff_indices = f_dof_indices[np.ix_(vec_range, [0], f_dof)][:, 0, :] + + # Extracting Ff force vector in dense format + # We have to sum duplicate indices - using bincount + Ff = np.bincount(np.ravel(Ff_indices), weights=np.ravel(Ff_elem)) + return Kff_rows, Kff_cols, Kff_vals, Ff + + +# :class:_DOF_estimator, _DOF_estimator_user, _DOF_estimator_geom, +# _DOF_estimator_min_E +# Private classes used to compute the degree of freedom of each triangular +# element for the TriCubicInterpolator. +class _DOF_estimator: + """ + Abstract base class for classes used to estimate a function's first + derivatives, and deduce the dofs for a CubicTriInterpolator using a + reduced HCT element formulation. + + Derived classes implement ``compute_df(self, **kwargs)``, returning + ``np.vstack([dfx, dfy]).T`` where ``dfx, dfy`` are the estimation of the 2 + gradient coordinates. + """ + def __init__(self, interpolator, **kwargs): + _api.check_isinstance(CubicTriInterpolator, interpolator=interpolator) + self._pts = interpolator._pts + self._tris_pts = interpolator._tris_pts + self.z = interpolator._z + self._triangles = interpolator._triangles + (self._unit_x, self._unit_y) = (interpolator._unit_x, + interpolator._unit_y) + self.dz = self.compute_dz(**kwargs) + self.compute_dof_from_df() + + def compute_dz(self, **kwargs): + raise NotImplementedError + + def compute_dof_from_df(self): + """ + Compute reduced-HCT elements degrees of freedom, from the gradient. + """ + J = CubicTriInterpolator._get_jacobian(self._tris_pts) + tri_z = self.z[self._triangles] + tri_dz = self.dz[self._triangles] + tri_dof = self.get_dof_vec(tri_z, tri_dz, J) + return tri_dof + + @staticmethod + def get_dof_vec(tri_z, tri_dz, J): + """ + Compute the dof vector of a triangle, from the value of f, df and + of the local Jacobian at each node. + + Parameters + ---------- + tri_z : shape (3,) array + f nodal values. + tri_dz : shape (3, 2) array + df/dx, df/dy nodal values. + J + Jacobian matrix in local basis of apex 0. + + Returns + ------- + dof : shape (9,) array + For each apex ``iapex``:: + + dof[iapex*3+0] = f(Ai) + dof[iapex*3+1] = df(Ai).(AiAi+) + dof[iapex*3+2] = df(Ai).(AiAi-) + """ + npt = tri_z.shape[0] + dof = np.zeros([npt, 9], dtype=np.float64) + J1 = _ReducedHCT_Element.J0_to_J1 @ J + J2 = _ReducedHCT_Element.J0_to_J2 @ J + + col0 = J @ np.expand_dims(tri_dz[:, 0, :], axis=2) + col1 = J1 @ np.expand_dims(tri_dz[:, 1, :], axis=2) + col2 = J2 @ np.expand_dims(tri_dz[:, 2, :], axis=2) + + dfdksi = _to_matrix_vectorized([ + [col0[:, 0, 0], col1[:, 0, 0], col2[:, 0, 0]], + [col0[:, 1, 0], col1[:, 1, 0], col2[:, 1, 0]]]) + dof[:, 0:7:3] = tri_z + dof[:, 1:8:3] = dfdksi[:, 0] + dof[:, 2:9:3] = dfdksi[:, 1] + return dof + + +class _DOF_estimator_user(_DOF_estimator): + """dz is imposed by user; accounts for scaling if any.""" + + def compute_dz(self, dz): + (dzdx, dzdy) = dz + dzdx = dzdx * self._unit_x + dzdy = dzdy * self._unit_y + return np.vstack([dzdx, dzdy]).T + + +class _DOF_estimator_geom(_DOF_estimator): + """Fast 'geometric' approximation, recommended for large arrays.""" + + def compute_dz(self): + """ + self.df is computed as weighted average of _triangles sharing a common + node. On each triangle itri f is first assumed linear (= ~f), which + allows to compute d~f[itri] + Then the following approximation of df nodal values is then proposed: + f[ipt] = SUM ( w[itri] x d~f[itri] , for itri sharing apex ipt) + The weighted coeff. w[itri] are proportional to the angle of the + triangle itri at apex ipt + """ + el_geom_w = self.compute_geom_weights() + el_geom_grad = self.compute_geom_grads() + + # Sum of weights coeffs + w_node_sum = np.bincount(np.ravel(self._triangles), + weights=np.ravel(el_geom_w)) + + # Sum of weighted df = (dfx, dfy) + dfx_el_w = np.empty_like(el_geom_w) + dfy_el_w = np.empty_like(el_geom_w) + for iapex in range(3): + dfx_el_w[:, iapex] = el_geom_w[:, iapex]*el_geom_grad[:, 0] + dfy_el_w[:, iapex] = el_geom_w[:, iapex]*el_geom_grad[:, 1] + dfx_node_sum = np.bincount(np.ravel(self._triangles), + weights=np.ravel(dfx_el_w)) + dfy_node_sum = np.bincount(np.ravel(self._triangles), + weights=np.ravel(dfy_el_w)) + + # Estimation of df + dfx_estim = dfx_node_sum/w_node_sum + dfy_estim = dfy_node_sum/w_node_sum + return np.vstack([dfx_estim, dfy_estim]).T + + def compute_geom_weights(self): + """ + Build the (nelems, 3) weights coeffs of _triangles angles, + renormalized so that np.sum(weights, axis=1) == np.ones(nelems) + """ + weights = np.zeros([np.size(self._triangles, 0), 3]) + tris_pts = self._tris_pts + for ipt in range(3): + p0 = tris_pts[:, ipt % 3, :] + p1 = tris_pts[:, (ipt+1) % 3, :] + p2 = tris_pts[:, (ipt-1) % 3, :] + alpha1 = np.arctan2(p1[:, 1]-p0[:, 1], p1[:, 0]-p0[:, 0]) + alpha2 = np.arctan2(p2[:, 1]-p0[:, 1], p2[:, 0]-p0[:, 0]) + # In the below formula we could take modulo 2. but + # modulo 1. is safer regarding round-off errors (flat triangles). + angle = np.abs(((alpha2-alpha1) / np.pi) % 1) + # Weight proportional to angle up np.pi/2; null weight for + # degenerated cases 0 and np.pi (note that *angle* is normalized + # by np.pi). + weights[:, ipt] = 0.5 - np.abs(angle-0.5) + return weights + + def compute_geom_grads(self): + """ + Compute the (global) gradient component of f assumed linear (~f). + returns array df of shape (nelems, 2) + df[ielem].dM[ielem] = dz[ielem] i.e. df = dz x dM = dM.T^-1 x dz + """ + tris_pts = self._tris_pts + tris_f = self.z[self._triangles] + + dM1 = tris_pts[:, 1, :] - tris_pts[:, 0, :] + dM2 = tris_pts[:, 2, :] - tris_pts[:, 0, :] + dM = np.dstack([dM1, dM2]) + # Here we try to deal with the simplest colinear cases: a null + # gradient is assumed in this case. + dM_inv = _safe_inv22_vectorized(dM) + + dZ1 = tris_f[:, 1] - tris_f[:, 0] + dZ2 = tris_f[:, 2] - tris_f[:, 0] + dZ = np.vstack([dZ1, dZ2]).T + df = np.empty_like(dZ) + + # With np.einsum: could be ej,eji -> ej + df[:, 0] = dZ[:, 0]*dM_inv[:, 0, 0] + dZ[:, 1]*dM_inv[:, 1, 0] + df[:, 1] = dZ[:, 0]*dM_inv[:, 0, 1] + dZ[:, 1]*dM_inv[:, 1, 1] + return df + + +class _DOF_estimator_min_E(_DOF_estimator_geom): + """ + The 'smoothest' approximation, df is computed through global minimization + of the bending energy: + E(f) = integral[(d2z/dx2 + d2z/dy2 + 2 d2z/dxdy)**2 dA] + """ + def __init__(self, Interpolator): + self._eccs = Interpolator._eccs + super().__init__(Interpolator) + + def compute_dz(self): + """ + Elliptic solver for bending energy minimization. + Uses a dedicated 'toy' sparse Jacobi PCG solver. + """ + # Initial guess for iterative PCG solver. + dz_init = super().compute_dz() + Uf0 = np.ravel(dz_init) + + reference_element = _ReducedHCT_Element() + J = CubicTriInterpolator._get_jacobian(self._tris_pts) + eccs = self._eccs + triangles = self._triangles + Uc = self.z[self._triangles] + + # Building stiffness matrix and force vector in coo format + Kff_rows, Kff_cols, Kff_vals, Ff = reference_element.get_Kff_and_Ff( + J, eccs, triangles, Uc) + + # Building sparse matrix and solving minimization problem + # We could use scipy.sparse direct solver; however to avoid this + # external dependency an implementation of a simple PCG solver with + # a simple diagonal Jacobi preconditioner is implemented. + tol = 1.e-10 + n_dof = Ff.shape[0] + Kff_coo = _Sparse_Matrix_coo(Kff_vals, Kff_rows, Kff_cols, + shape=(n_dof, n_dof)) + Kff_coo.compress_csc() + Uf, err = _cg(A=Kff_coo, b=Ff, x0=Uf0, tol=tol) + # If the PCG did not converge, we return the best guess between Uf0 + # and Uf. + err0 = np.linalg.norm(Kff_coo.dot(Uf0) - Ff) + if err0 < err: + # Maybe a good occasion to raise a warning here ? + _api.warn_external("In TriCubicInterpolator initialization, " + "PCG sparse solver did not converge after " + "1000 iterations. `geom` approximation is " + "used instead of `min_E`") + Uf = Uf0 + + # Building dz from Uf + dz = np.empty([self._pts.shape[0], 2], dtype=np.float64) + dz[:, 0] = Uf[::2] + dz[:, 1] = Uf[1::2] + return dz + + +# The following private :class:_Sparse_Matrix_coo and :func:_cg provide +# a PCG sparse solver for (symmetric) elliptic problems. +class _Sparse_Matrix_coo: + def __init__(self, vals, rows, cols, shape): + """ + Create a sparse matrix in coo format. + *vals*: arrays of values of non-null entries of the matrix + *rows*: int arrays of rows of non-null entries of the matrix + *cols*: int arrays of cols of non-null entries of the matrix + *shape*: 2-tuple (n, m) of matrix shape + """ + self.n, self.m = shape + self.vals = np.asarray(vals, dtype=np.float64) + self.rows = np.asarray(rows, dtype=np.int32) + self.cols = np.asarray(cols, dtype=np.int32) + + def dot(self, V): + """ + Dot product of self by a vector *V* in sparse-dense to dense format + *V* dense vector of shape (self.m,). + """ + assert V.shape == (self.m,) + return np.bincount(self.rows, + weights=self.vals*V[self.cols], + minlength=self.m) + + def compress_csc(self): + """ + Compress rows, cols, vals / summing duplicates. Sort for csc format. + """ + _, unique, indices = np.unique( + self.rows + self.n*self.cols, + return_index=True, return_inverse=True) + self.rows = self.rows[unique] + self.cols = self.cols[unique] + self.vals = np.bincount(indices, weights=self.vals) + + def compress_csr(self): + """ + Compress rows, cols, vals / summing duplicates. Sort for csr format. + """ + _, unique, indices = np.unique( + self.m*self.rows + self.cols, + return_index=True, return_inverse=True) + self.rows = self.rows[unique] + self.cols = self.cols[unique] + self.vals = np.bincount(indices, weights=self.vals) + + def to_dense(self): + """ + Return a dense matrix representing self, mainly for debugging purposes. + """ + ret = np.zeros([self.n, self.m], dtype=np.float64) + nvals = self.vals.size + for i in range(nvals): + ret[self.rows[i], self.cols[i]] += self.vals[i] + return ret + + def __str__(self): + return self.to_dense().__str__() + + @property + def diag(self): + """Return the (dense) vector of the diagonal elements.""" + in_diag = (self.rows == self.cols) + diag = np.zeros(min(self.n, self.n), dtype=np.float64) # default 0. + diag[self.rows[in_diag]] = self.vals[in_diag] + return diag + + +def _cg(A, b, x0=None, tol=1.e-10, maxiter=1000): + """ + Use Preconditioned Conjugate Gradient iteration to solve A x = b + A simple Jacobi (diagonal) preconditioner is used. + + Parameters + ---------- + A : _Sparse_Matrix_coo + *A* must have been compressed before by compress_csc or + compress_csr method. + b : array + Right hand side of the linear system. + x0 : array, optional + Starting guess for the solution. Defaults to the zero vector. + tol : float, optional + Tolerance to achieve. The algorithm terminates when the relative + residual is below tol. Default is 1e-10. + maxiter : int, optional + Maximum number of iterations. Iteration will stop after *maxiter* + steps even if the specified tolerance has not been achieved. Defaults + to 1000. + + Returns + ------- + x : array + The converged solution. + err : float + The absolute error np.linalg.norm(A.dot(x) - b) + """ + n = b.size + assert A.n == n + assert A.m == n + b_norm = np.linalg.norm(b) + + # Jacobi pre-conditioner + kvec = A.diag + # For diag elem < 1e-6 we keep 1e-6. + kvec = np.maximum(kvec, 1e-6) + + # Initial guess + if x0 is None: + x = np.zeros(n) + else: + x = x0 + + r = b - A.dot(x) + w = r/kvec + + p = np.zeros(n) + beta = 0.0 + rho = np.dot(r, w) + k = 0 + + # Following C. T. Kelley + while (np.sqrt(abs(rho)) > tol*b_norm) and (k < maxiter): + p = w + beta*p + z = A.dot(p) + alpha = rho/np.dot(p, z) + r = r - alpha*z + w = r/kvec + rhoold = rho + rho = np.dot(r, w) + x = x + alpha*p + beta = rho/rhoold + # err = np.linalg.norm(A.dot(x) - b) # absolute accuracy - not used + k += 1 + err = np.linalg.norm(A.dot(x) - b) + return x, err + + +# The following private functions: +# :func:`_safe_inv22_vectorized` +# :func:`_pseudo_inv22sym_vectorized` +# :func:`_scalar_vectorized` +# :func:`_transpose_vectorized` +# :func:`_roll_vectorized` +# :func:`_to_matrix_vectorized` +# :func:`_extract_submatrices` +# provide fast numpy implementation of some standard operations on arrays of +# matrices - stored as (:, n_rows, n_cols)-shaped np.arrays. + +# Development note: Dealing with pathologic 'flat' triangles in the +# CubicTriInterpolator code and impact on (2, 2)-matrix inversion functions +# :func:`_safe_inv22_vectorized` and :func:`_pseudo_inv22sym_vectorized`. +# +# Goals: +# 1) The CubicTriInterpolator should be able to handle flat or almost flat +# triangles without raising an error, +# 2) These degenerated triangles should have no impact on the automatic dof +# calculation (associated with null weight for the _DOF_estimator_geom and +# with null energy for the _DOF_estimator_min_E), +# 3) Linear patch test should be passed exactly on degenerated meshes, +# 4) Interpolation (with :meth:`_interpolate_single_key` or +# :meth:`_interpolate_multi_key`) shall be correctly handled even *inside* +# the pathologic triangles, to interact correctly with a TriRefiner class. +# +# Difficulties: +# Flat triangles have rank-deficient *J* (so-called jacobian matrix) and +# *metric* (the metric tensor = J x J.T). Computation of the local +# tangent plane is also problematic. +# +# Implementation: +# Most of the time, when computing the inverse of a rank-deficient matrix it +# is safe to simply return the null matrix (which is the implementation in +# :func:`_safe_inv22_vectorized`). This is because of point 2), itself +# enforced by: +# - null area hence null energy in :class:`_DOF_estimator_min_E` +# - angles close or equal to 0 or np.pi hence null weight in +# :class:`_DOF_estimator_geom`. +# Note that the function angle -> weight is continuous and maximum for an +# angle np.pi/2 (refer to :meth:`compute_geom_weights`) +# The exception is the computation of barycentric coordinates, which is done +# by inversion of the *metric* matrix. In this case, we need to compute a set +# of valid coordinates (1 among numerous possibilities), to ensure point 4). +# We benefit here from the symmetry of metric = J x J.T, which makes it easier +# to compute a pseudo-inverse in :func:`_pseudo_inv22sym_vectorized` +def _safe_inv22_vectorized(M): + """ + Inversion of arrays of (2, 2) matrices, returns 0 for rank-deficient + matrices. + + *M* : array of (2, 2) matrices to inverse, shape (n, 2, 2) + """ + _api.check_shape((None, 2, 2), M=M) + M_inv = np.empty_like(M) + prod1 = M[:, 0, 0]*M[:, 1, 1] + delta = prod1 - M[:, 0, 1]*M[:, 1, 0] + + # We set delta_inv to 0. in case of a rank deficient matrix; a + # rank-deficient input matrix *M* will lead to a null matrix in output + rank2 = (np.abs(delta) > 1e-8*np.abs(prod1)) + if np.all(rank2): + # Normal 'optimized' flow. + delta_inv = 1./delta + else: + # 'Pathologic' flow. + delta_inv = np.zeros(M.shape[0]) + delta_inv[rank2] = 1./delta[rank2] + + M_inv[:, 0, 0] = M[:, 1, 1]*delta_inv + M_inv[:, 0, 1] = -M[:, 0, 1]*delta_inv + M_inv[:, 1, 0] = -M[:, 1, 0]*delta_inv + M_inv[:, 1, 1] = M[:, 0, 0]*delta_inv + return M_inv + + +def _pseudo_inv22sym_vectorized(M): + """ + Inversion of arrays of (2, 2) SYMMETRIC matrices; returns the + (Moore-Penrose) pseudo-inverse for rank-deficient matrices. + + In case M is of rank 1, we have M = trace(M) x P where P is the orthogonal + projection on Im(M), and we return trace(M)^-1 x P == M / trace(M)**2 + In case M is of rank 0, we return the null matrix. + + *M* : array of (2, 2) matrices to inverse, shape (n, 2, 2) + """ + _api.check_shape((None, 2, 2), M=M) + M_inv = np.empty_like(M) + prod1 = M[:, 0, 0]*M[:, 1, 1] + delta = prod1 - M[:, 0, 1]*M[:, 1, 0] + rank2 = (np.abs(delta) > 1e-8*np.abs(prod1)) + + if np.all(rank2): + # Normal 'optimized' flow. + M_inv[:, 0, 0] = M[:, 1, 1] / delta + M_inv[:, 0, 1] = -M[:, 0, 1] / delta + M_inv[:, 1, 0] = -M[:, 1, 0] / delta + M_inv[:, 1, 1] = M[:, 0, 0] / delta + else: + # 'Pathologic' flow. + # Here we have to deal with 2 sub-cases + # 1) First sub-case: matrices of rank 2: + delta = delta[rank2] + M_inv[rank2, 0, 0] = M[rank2, 1, 1] / delta + M_inv[rank2, 0, 1] = -M[rank2, 0, 1] / delta + M_inv[rank2, 1, 0] = -M[rank2, 1, 0] / delta + M_inv[rank2, 1, 1] = M[rank2, 0, 0] / delta + # 2) Second sub-case: rank-deficient matrices of rank 0 and 1: + rank01 = ~rank2 + tr = M[rank01, 0, 0] + M[rank01, 1, 1] + tr_zeros = (np.abs(tr) < 1.e-8) + sq_tr_inv = (1.-tr_zeros) / (tr**2+tr_zeros) + # sq_tr_inv = 1. / tr**2 + M_inv[rank01, 0, 0] = M[rank01, 0, 0] * sq_tr_inv + M_inv[rank01, 0, 1] = M[rank01, 0, 1] * sq_tr_inv + M_inv[rank01, 1, 0] = M[rank01, 1, 0] * sq_tr_inv + M_inv[rank01, 1, 1] = M[rank01, 1, 1] * sq_tr_inv + + return M_inv + + +def _scalar_vectorized(scalar, M): + """ + Scalar product between scalars and matrices. + """ + return scalar[:, np.newaxis, np.newaxis]*M + + +def _transpose_vectorized(M): + """ + Transposition of an array of matrices *M*. + """ + return np.transpose(M, [0, 2, 1]) + + +def _roll_vectorized(M, roll_indices, axis): + """ + Roll an array of matrices along *axis* (0: rows, 1: columns) according to + an array of indices *roll_indices*. + """ + assert axis in [0, 1] + ndim = M.ndim + assert ndim == 3 + ndim_roll = roll_indices.ndim + assert ndim_roll == 1 + sh = M.shape + r, c = sh[-2:] + assert sh[0] == roll_indices.shape[0] + vec_indices = np.arange(sh[0], dtype=np.int32) + + # Builds the rolled matrix + M_roll = np.empty_like(M) + if axis == 0: + for ir in range(r): + for ic in range(c): + M_roll[:, ir, ic] = M[vec_indices, (-roll_indices+ir) % r, ic] + else: # 1 + for ir in range(r): + for ic in range(c): + M_roll[:, ir, ic] = M[vec_indices, ir, (-roll_indices+ic) % c] + return M_roll + + +def _to_matrix_vectorized(M): + """ + Build an array of matrices from individuals np.arrays of identical shapes. + + Parameters + ---------- + M + ncols-list of nrows-lists of shape sh. + + Returns + ------- + M_res : np.array of shape (sh, nrow, ncols) + *M_res* satisfies ``M_res[..., i, j] = M[i][j]``. + """ + assert isinstance(M, (tuple, list)) + assert all(isinstance(item, (tuple, list)) for item in M) + c_vec = np.asarray([len(item) for item in M]) + assert np.all(c_vec-c_vec[0] == 0) + r = len(M) + c = c_vec[0] + M00 = np.asarray(M[0][0]) + dt = M00.dtype + sh = [M00.shape[0], r, c] + M_ret = np.empty(sh, dtype=dt) + for irow in range(r): + for icol in range(c): + M_ret[:, irow, icol] = np.asarray(M[irow][icol]) + return M_ret + + +def _extract_submatrices(M, block_indices, block_size, axis): + """ + Extract selected blocks of a matrices *M* depending on parameters + *block_indices* and *block_size*. + + Returns the array of extracted matrices *Mres* so that :: + + M_res[..., ir, :] = M[(block_indices*block_size+ir), :] + """ + assert block_indices.ndim == 1 + assert axis in [0, 1] + + r, c = M.shape + if axis == 0: + sh = [block_indices.shape[0], block_size, c] + else: # 1 + sh = [block_indices.shape[0], r, block_size] + + dt = M.dtype + M_res = np.empty(sh, dtype=dt) + if axis == 0: + for ir in range(block_size): + M_res[:, ir, :] = M[(block_indices*block_size+ir), :] + else: # 1 + for ic in range(block_size): + M_res[:, :, ic] = M[:, (block_indices*block_size+ic)] + + return M_res diff --git a/lib/matplotlib/tri/_tripcolor.py b/lib/matplotlib/tri/_tripcolor.py new file mode 100644 index 000000000000..3c252cdbc31b --- /dev/null +++ b/lib/matplotlib/tri/_tripcolor.py @@ -0,0 +1,154 @@ +import numpy as np + +from matplotlib import _api +from matplotlib.collections import PolyCollection, TriMesh +from matplotlib.colors import Normalize +from matplotlib.tri._triangulation import Triangulation + + +def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None, + vmax=None, shading='flat', facecolors=None, **kwargs): + """ + Create a pseudocolor plot of an unstructured triangular grid. + + Call signatures:: + + tripcolor(triangulation, c, *, ...) + tripcolor(x, y, c, *, [triangles=triangles], [mask=mask], ...) + + The triangular grid can be specified either by passing a `.Triangulation` + object as the first parameter, or by passing the points *x*, *y* and + optionally the *triangles* and a *mask*. See `.Triangulation` for an + explanation of these parameters. + + It is possible to pass the triangles positionally, i.e. + ``tripcolor(x, y, triangles, c, ...)``. However, this is discouraged. + For more clarity, pass *triangles* via keyword argument. + + If neither of *triangulation* or *triangles* are given, the triangulation + is calculated on the fly. In this case, it does not make sense to provide + colors at the triangle faces via *c* or *facecolors* because there are + multiple possible triangulations for a group of points and you don't know + which triangles will be constructed. + + Parameters + ---------- + triangulation : `.Triangulation` + An already created triangular grid. + x, y, triangles, mask + Parameters defining the triangular grid. See `.Triangulation`. + This is mutually exclusive with specifying *triangulation*. + c : array-like + The color values, either for the points or for the triangles. Which one + is automatically inferred from the length of *c*, i.e. does it match + the number of points or the number of triangles. If there are the same + number of points and triangles in the triangulation it is assumed that + color values are defined at points; to force the use of color values at + triangles use the keyword argument ``facecolors=c`` instead of just + ``c``. + This parameter is position-only. + facecolors : array-like, optional + Can be used alternatively to *c* to specify colors at the triangle + faces. This parameter takes precedence over *c*. + shading : {'flat', 'gouraud'}, default: 'flat' + If 'flat' and the color values *c* are defined at points, the color + values used for each triangle are from the mean c of the triangle's + three points. If *shading* is 'gouraud' then color values must be + defined at points. + other_parameters + All other parameters are the same as for `~.Axes.pcolor`. + """ + _api.check_in_list(['flat', 'gouraud'], shading=shading) + + tri, args, kwargs = Triangulation.get_from_args_and_kwargs(*args, **kwargs) + + # Parse the color to be in one of (the other variable will be None): + # - facecolors: if specified at the triangle faces + # - point_colors: if specified at the points + if facecolors is not None: + if args: + _api.warn_external( + "Positional parameter c has no effect when the keyword " + "facecolors is given") + point_colors = None + if len(facecolors) != len(tri.triangles): + raise ValueError("The length of facecolors must match the number " + "of triangles") + else: + # Color from positional parameter c + if not args: + raise TypeError( + "tripcolor() missing 1 required positional argument: 'c'; or " + "1 required keyword-only argument: 'facecolors'") + elif len(args) > 1: + _api.warn_deprecated( + "3.6", message=f"Additional positional parameters " + f"{args[1:]!r} are ignored; support for them is deprecated " + f"since %(since)s and will be removed %(removal)s") + c = np.asarray(args[0]) + if len(c) == len(tri.x): + # having this before the len(tri.triangles) comparison gives + # precedence to nodes if there are as many nodes as triangles + point_colors = c + facecolors = None + elif len(c) == len(tri.triangles): + point_colors = None + facecolors = c + else: + raise ValueError('The length of c must match either the number ' + 'of points or the number of triangles') + + # Handling of linewidths, shading, edgecolors and antialiased as + # in Axes.pcolor + linewidths = (0.25,) + if 'linewidth' in kwargs: + kwargs['linewidths'] = kwargs.pop('linewidth') + kwargs.setdefault('linewidths', linewidths) + + edgecolors = 'none' + if 'edgecolor' in kwargs: + kwargs['edgecolors'] = kwargs.pop('edgecolor') + ec = kwargs.setdefault('edgecolors', edgecolors) + + if 'antialiased' in kwargs: + kwargs['antialiaseds'] = kwargs.pop('antialiased') + if 'antialiaseds' not in kwargs and ec.lower() == "none": + kwargs['antialiaseds'] = False + + _api.check_isinstance((Normalize, None), norm=norm) + if shading == 'gouraud': + if facecolors is not None: + raise ValueError( + "shading='gouraud' can only be used when the colors " + "are specified at the points, not at the faces.") + collection = TriMesh(tri, alpha=alpha, array=point_colors, + cmap=cmap, norm=norm, **kwargs) + else: # 'flat' + # Vertices of triangles. + maskedTris = tri.get_masked_triangles() + verts = np.stack((tri.x[maskedTris], tri.y[maskedTris]), axis=-1) + + # Color values. + if facecolors is None: + # One color per triangle, the mean of the 3 vertex color values. + colors = point_colors[maskedTris].mean(axis=1) + elif tri.mask is not None: + # Remove color values of masked triangles. + colors = facecolors[~tri.mask] + else: + colors = facecolors + collection = PolyCollection(verts, alpha=alpha, array=colors, + cmap=cmap, norm=norm, **kwargs) + + collection._scale_norm(norm, vmin, vmax) + ax.grid(False) + + minx = tri.x.min() + maxx = tri.x.max() + miny = tri.y.min() + maxy = tri.y.max() + corners = (minx, miny), (maxx, maxy) + ax.update_datalim(corners) + ax.autoscale_view() + ax.add_collection(collection) + return collection diff --git a/lib/matplotlib/tri/_triplot.py b/lib/matplotlib/tri/_triplot.py new file mode 100644 index 000000000000..6168946b1531 --- /dev/null +++ b/lib/matplotlib/tri/_triplot.py @@ -0,0 +1,86 @@ +import numpy as np +from matplotlib.tri._triangulation import Triangulation +import matplotlib.cbook as cbook +import matplotlib.lines as mlines + + +def triplot(ax, *args, **kwargs): + """ + Draw an unstructured triangular grid as lines and/or markers. + + Call signatures:: + + triplot(triangulation, ...) + triplot(x, y, [triangles], *, [mask=mask], ...) + + The triangular grid can be specified either by passing a `.Triangulation` + object as the first parameter, or by passing the points *x*, *y* and + optionally the *triangles* and a *mask*. If neither of *triangulation* or + *triangles* are given, the triangulation is calculated on the fly. + + Parameters + ---------- + triangulation : `.Triangulation` + An already created triangular grid. + x, y, triangles, mask + Parameters defining the triangular grid. See `.Triangulation`. + This is mutually exclusive with specifying *triangulation*. + other_parameters + All other args and kwargs are forwarded to `~.Axes.plot`. + + Returns + ------- + lines : `~matplotlib.lines.Line2D` + The drawn triangles edges. + markers : `~matplotlib.lines.Line2D` + The drawn marker nodes. + """ + import matplotlib.axes + + tri, args, kwargs = Triangulation.get_from_args_and_kwargs(*args, **kwargs) + x, y, edges = (tri.x, tri.y, tri.edges) + + # Decode plot format string, e.g., 'ro-' + fmt = args[0] if args else "" + linestyle, marker, color = matplotlib.axes._base._process_plot_format(fmt) + + # Insert plot format string into a copy of kwargs (kwargs values prevail). + kw = cbook.normalize_kwargs(kwargs, mlines.Line2D) + for key, val in zip(('linestyle', 'marker', 'color'), + (linestyle, marker, color)): + if val is not None: + kw.setdefault(key, val) + + # Draw lines without markers. + # Note 1: If we drew markers here, most markers would be drawn more than + # once as they belong to several edges. + # Note 2: We insert nan values in the flattened edges arrays rather than + # plotting directly (triang.x[edges].T, triang.y[edges].T) + # as it considerably speeds-up code execution. + linestyle = kw['linestyle'] + kw_lines = { + **kw, + 'marker': 'None', # No marker to draw. + 'zorder': kw.get('zorder', 1), # Path default zorder is used. + } + if linestyle not in [None, 'None', '', ' ']: + tri_lines_x = np.insert(x[edges], 2, np.nan, axis=1) + tri_lines_y = np.insert(y[edges], 2, np.nan, axis=1) + tri_lines = ax.plot(tri_lines_x.ravel(), tri_lines_y.ravel(), + **kw_lines) + else: + tri_lines = ax.plot([], [], **kw_lines) + + # Draw markers separately. + marker = kw['marker'] + kw_markers = { + **kw, + 'linestyle': 'None', # No line to draw. + } + kw_markers.pop('label', None) + if marker not in [None, 'None', '', ' ']: + tri_markers = ax.plot(x, y, **kw_markers) + else: + tri_markers = ax.plot([], [], **kw_markers) + + return tri_lines + tri_markers diff --git a/lib/matplotlib/tri/_trirefine.py b/lib/matplotlib/tri/_trirefine.py new file mode 100644 index 000000000000..a0a57935fb99 --- /dev/null +++ b/lib/matplotlib/tri/_trirefine.py @@ -0,0 +1,307 @@ +""" +Mesh refinement for triangular grids. +""" + +import numpy as np + +from matplotlib import _api +from matplotlib.tri._triangulation import Triangulation +import matplotlib.tri._triinterpolate + + +class TriRefiner: + """ + Abstract base class for classes implementing mesh refinement. + + A TriRefiner encapsulates a Triangulation object and provides tools for + mesh refinement and interpolation. + + Derived classes must implement: + + - ``refine_triangulation(return_tri_index=False, **kwargs)`` , where + the optional keyword arguments *kwargs* are defined in each + TriRefiner concrete implementation, and which returns: + + - a refined triangulation, + - optionally (depending on *return_tri_index*), for each + point of the refined triangulation: the index of + the initial triangulation triangle to which it belongs. + + - ``refine_field(z, triinterpolator=None, **kwargs)``, where: + + - *z* array of field values (to refine) defined at the base + triangulation nodes, + - *triinterpolator* is an optional `~matplotlib.tri.TriInterpolator`, + - the other optional keyword arguments *kwargs* are defined in + each TriRefiner concrete implementation; + + and which returns (as a tuple) a refined triangular mesh and the + interpolated values of the field at the refined triangulation nodes. + """ + + def __init__(self, triangulation): + _api.check_isinstance(Triangulation, triangulation=triangulation) + self._triangulation = triangulation + + +class UniformTriRefiner(TriRefiner): + """ + Uniform mesh refinement by recursive subdivisions. + + Parameters + ---------- + triangulation : `~matplotlib.tri.Triangulation` + The encapsulated triangulation (to be refined) + """ +# See Also +# -------- +# :class:`~matplotlib.tri.CubicTriInterpolator` and +# :class:`~matplotlib.tri.TriAnalyzer`. +# """ + def __init__(self, triangulation): + super().__init__(triangulation) + + def refine_triangulation(self, return_tri_index=False, subdiv=3): + """ + Compute a uniformly refined triangulation *refi_triangulation* of + the encapsulated :attr:`triangulation`. + + This function refines the encapsulated triangulation by splitting each + father triangle into 4 child sub-triangles built on the edges midside + nodes, recursing *subdiv* times. In the end, each triangle is hence + divided into ``4**subdiv`` child triangles. + + Parameters + ---------- + return_tri_index : bool, default: False + Whether an index table indicating the father triangle index of each + point is returned. + subdiv : int, default: 3 + Recursion level for the subdivision. + Each triangle is divided into ``4**subdiv`` child triangles; + hence, the default results in 64 refined subtriangles for each + triangle of the initial triangulation. + + Returns + ------- + refi_triangulation : `~matplotlib.tri.Triangulation` + The refined triangulation. + found_index : int array + Index of the initial triangulation containing triangle, for each + point of *refi_triangulation*. + Returned only if *return_tri_index* is set to True. + """ + refi_triangulation = self._triangulation + ntri = refi_triangulation.triangles.shape[0] + + # Computes the triangulation ancestors numbers in the reference + # triangulation. + ancestors = np.arange(ntri, dtype=np.int32) + for _ in range(subdiv): + refi_triangulation, ancestors = self._refine_triangulation_once( + refi_triangulation, ancestors) + refi_npts = refi_triangulation.x.shape[0] + refi_triangles = refi_triangulation.triangles + + # Now we compute found_index table if needed + if return_tri_index: + # We have to initialize found_index with -1 because some nodes + # may very well belong to no triangle at all, e.g., in case of + # Delaunay Triangulation with DuplicatePointWarning. + found_index = np.full(refi_npts, -1, dtype=np.int32) + tri_mask = self._triangulation.mask + if tri_mask is None: + found_index[refi_triangles] = np.repeat(ancestors, + 3).reshape(-1, 3) + else: + # There is a subtlety here: we want to avoid whenever possible + # that refined points container is a masked triangle (which + # would result in artifacts in plots). + # So we impose the numbering from masked ancestors first, + # then overwrite it with unmasked ancestor numbers. + ancestor_mask = tri_mask[ancestors] + found_index[refi_triangles[ancestor_mask, :] + ] = np.repeat(ancestors[ancestor_mask], + 3).reshape(-1, 3) + found_index[refi_triangles[~ancestor_mask, :] + ] = np.repeat(ancestors[~ancestor_mask], + 3).reshape(-1, 3) + return refi_triangulation, found_index + else: + return refi_triangulation + + def refine_field(self, z, triinterpolator=None, subdiv=3): + """ + Refine a field defined on the encapsulated triangulation. + + Parameters + ---------- + z : (npoints,) array-like + Values of the field to refine, defined at the nodes of the + encapsulated triangulation. (``n_points`` is the number of points + in the initial triangulation) + triinterpolator : `~matplotlib.tri.TriInterpolator`, optional + Interpolator used for field interpolation. If not specified, + a `~matplotlib.tri.CubicTriInterpolator` will be used. + subdiv : int, default: 3 + Recursion level for the subdivision. + Each triangle is divided into ``4**subdiv`` child triangles. + + Returns + ------- + refi_tri : `~matplotlib.tri.Triangulation` + The returned refined triangulation. + refi_z : 1D array of length: *refi_tri* node count. + The returned interpolated field (at *refi_tri* nodes). + """ + if triinterpolator is None: + interp = matplotlib.tri.CubicTriInterpolator( + self._triangulation, z) + else: + _api.check_isinstance(matplotlib.tri.TriInterpolator, + triinterpolator=triinterpolator) + interp = triinterpolator + + refi_tri, found_index = self.refine_triangulation( + subdiv=subdiv, return_tri_index=True) + refi_z = interp._interpolate_multikeys( + refi_tri.x, refi_tri.y, tri_index=found_index)[0] + return refi_tri, refi_z + + @staticmethod + def _refine_triangulation_once(triangulation, ancestors=None): + """ + Refine a `.Triangulation` by splitting each triangle into 4 + child-masked_triangles built on the edges midside nodes. + + Masked triangles, if present, are also split, but their children + returned masked. + + If *ancestors* is not provided, returns only a new triangulation: + child_triangulation. + + If the array-like key table *ancestor* is given, it shall be of shape + (ntri,) where ntri is the number of *triangulation* masked_triangles. + In this case, the function returns + (child_triangulation, child_ancestors) + child_ancestors is defined so that the 4 child masked_triangles share + the same index as their father: child_ancestors.shape = (4 * ntri,). + """ + + x = triangulation.x + y = triangulation.y + + # According to tri.triangulation doc: + # neighbors[i, j] is the triangle that is the neighbor + # to the edge from point index masked_triangles[i, j] to point + # index masked_triangles[i, (j+1)%3]. + neighbors = triangulation.neighbors + triangles = triangulation.triangles + npts = np.shape(x)[0] + ntri = np.shape(triangles)[0] + if ancestors is not None: + ancestors = np.asarray(ancestors) + if np.shape(ancestors) != (ntri,): + raise ValueError( + "Incompatible shapes provide for triangulation" + ".masked_triangles and ancestors: {0} and {1}".format( + np.shape(triangles), np.shape(ancestors))) + + # Initiating tables refi_x and refi_y of the refined triangulation + # points + # hint: each apex is shared by 2 masked_triangles except the borders. + borders = np.sum(neighbors == -1) + added_pts = (3*ntri + borders) // 2 + refi_npts = npts + added_pts + refi_x = np.zeros(refi_npts) + refi_y = np.zeros(refi_npts) + + # First part of refi_x, refi_y is just the initial points + refi_x[:npts] = x + refi_y[:npts] = y + + # Second part contains the edge midside nodes. + # Each edge belongs to 1 triangle (if border edge) or is shared by 2 + # masked_triangles (interior edge). + # We first build 2 * ntri arrays of edge starting nodes (edge_elems, + # edge_apexes); we then extract only the masters to avoid overlaps. + # The so-called 'master' is the triangle with biggest index + # The 'slave' is the triangle with lower index + # (can be -1 if border edge) + # For slave and master we will identify the apex pointing to the edge + # start + edge_elems = np.tile(np.arange(ntri, dtype=np.int32), 3) + edge_apexes = np.repeat(np.arange(3, dtype=np.int32), ntri) + edge_neighbors = neighbors[edge_elems, edge_apexes] + mask_masters = (edge_elems > edge_neighbors) + + # Identifying the "masters" and adding to refi_x, refi_y vec + masters = edge_elems[mask_masters] + apex_masters = edge_apexes[mask_masters] + x_add = (x[triangles[masters, apex_masters]] + + x[triangles[masters, (apex_masters+1) % 3]]) * 0.5 + y_add = (y[triangles[masters, apex_masters]] + + y[triangles[masters, (apex_masters+1) % 3]]) * 0.5 + refi_x[npts:] = x_add + refi_y[npts:] = y_add + + # Building the new masked_triangles; each old masked_triangles hosts + # 4 new masked_triangles + # there are 6 pts to identify per 'old' triangle, 3 new_pt_corner and + # 3 new_pt_midside + new_pt_corner = triangles + + # What is the index in refi_x, refi_y of point at middle of apex iapex + # of elem ielem ? + # If ielem is the apex master: simple count, given the way refi_x was + # built. + # If ielem is the apex slave: yet we do not know; but we will soon + # using the neighbors table. + new_pt_midside = np.empty([ntri, 3], dtype=np.int32) + cum_sum = npts + for imid in range(3): + mask_st_loc = (imid == apex_masters) + n_masters_loc = np.sum(mask_st_loc) + elem_masters_loc = masters[mask_st_loc] + new_pt_midside[:, imid][elem_masters_loc] = np.arange( + n_masters_loc, dtype=np.int32) + cum_sum + cum_sum += n_masters_loc + + # Now dealing with slave elems. + # for each slave element we identify the master and then the inode + # once slave_masters is identified, slave_masters_apex is such that: + # neighbors[slaves_masters, slave_masters_apex] == slaves + mask_slaves = np.logical_not(mask_masters) + slaves = edge_elems[mask_slaves] + slaves_masters = edge_neighbors[mask_slaves] + diff_table = np.abs(neighbors[slaves_masters, :] - + np.outer(slaves, np.ones(3, dtype=np.int32))) + slave_masters_apex = np.argmin(diff_table, axis=1) + slaves_apex = edge_apexes[mask_slaves] + new_pt_midside[slaves, slaves_apex] = new_pt_midside[ + slaves_masters, slave_masters_apex] + + # Builds the 4 child masked_triangles + child_triangles = np.empty([ntri*4, 3], dtype=np.int32) + child_triangles[0::4, :] = np.vstack([ + new_pt_corner[:, 0], new_pt_midside[:, 0], + new_pt_midside[:, 2]]).T + child_triangles[1::4, :] = np.vstack([ + new_pt_corner[:, 1], new_pt_midside[:, 1], + new_pt_midside[:, 0]]).T + child_triangles[2::4, :] = np.vstack([ + new_pt_corner[:, 2], new_pt_midside[:, 2], + new_pt_midside[:, 1]]).T + child_triangles[3::4, :] = np.vstack([ + new_pt_midside[:, 0], new_pt_midside[:, 1], + new_pt_midside[:, 2]]).T + child_triangulation = Triangulation(refi_x, refi_y, child_triangles) + + # Builds the child mask + if triangulation.mask is not None: + child_triangulation.set_mask(np.repeat(triangulation.mask, 4)) + + if ancestors is None: + return child_triangulation + else: + return child_triangulation, np.repeat(ancestors, 4) diff --git a/lib/matplotlib/tri/_tritools.py b/lib/matplotlib/tri/_tritools.py new file mode 100644 index 000000000000..9837309f7e81 --- /dev/null +++ b/lib/matplotlib/tri/_tritools.py @@ -0,0 +1,263 @@ +""" +Tools for triangular grids. +""" + +import numpy as np + +from matplotlib import _api +from matplotlib.tri import Triangulation + + +class TriAnalyzer: + """ + Define basic tools for triangular mesh analysis and improvement. + + A TriAnalyzer encapsulates a `.Triangulation` object and provides basic + tools for mesh analysis and mesh improvement. + + Attributes + ---------- + scale_factors + + Parameters + ---------- + triangulation : `~matplotlib.tri.Triangulation` + The encapsulated triangulation to analyze. + """ + + def __init__(self, triangulation): + _api.check_isinstance(Triangulation, triangulation=triangulation) + self._triangulation = triangulation + + @property + def scale_factors(self): + """ + Factors to rescale the triangulation into a unit square. + + Returns + ------- + (float, float) + Scaling factors (kx, ky) so that the triangulation + ``[triangulation.x * kx, triangulation.y * ky]`` + fits exactly inside a unit square. + """ + compressed_triangles = self._triangulation.get_masked_triangles() + node_used = (np.bincount(np.ravel(compressed_triangles), + minlength=self._triangulation.x.size) != 0) + return (1 / np.ptp(self._triangulation.x[node_used]), + 1 / np.ptp(self._triangulation.y[node_used])) + + def circle_ratios(self, rescale=True): + """ + Return a measure of the triangulation triangles flatness. + + The ratio of the incircle radius over the circumcircle radius is a + widely used indicator of a triangle flatness. + It is always ``<= 0.5`` and ``== 0.5`` only for equilateral + triangles. Circle ratios below 0.01 denote very flat triangles. + + To avoid unduly low values due to a difference of scale between the 2 + axis, the triangular mesh can first be rescaled to fit inside a unit + square with `scale_factors` (Only if *rescale* is True, which is + its default value). + + Parameters + ---------- + rescale : bool, default: True + If True, internally rescale (based on `scale_factors`), so that the + (unmasked) triangles fit exactly inside a unit square mesh. + + Returns + ------- + masked array + Ratio of the incircle radius over the circumcircle radius, for + each 'rescaled' triangle of the encapsulated triangulation. + Values corresponding to masked triangles are masked out. + + """ + # Coords rescaling + if rescale: + (kx, ky) = self.scale_factors + else: + (kx, ky) = (1.0, 1.0) + pts = np.vstack([self._triangulation.x*kx, + self._triangulation.y*ky]).T + tri_pts = pts[self._triangulation.triangles] + # Computes the 3 side lengths + a = tri_pts[:, 1, :] - tri_pts[:, 0, :] + b = tri_pts[:, 2, :] - tri_pts[:, 1, :] + c = tri_pts[:, 0, :] - tri_pts[:, 2, :] + a = np.hypot(a[:, 0], a[:, 1]) + b = np.hypot(b[:, 0], b[:, 1]) + c = np.hypot(c[:, 0], c[:, 1]) + # circumcircle and incircle radii + s = (a+b+c)*0.5 + prod = s*(a+b-s)*(a+c-s)*(b+c-s) + # We have to deal with flat triangles with infinite circum_radius + bool_flat = (prod == 0.) + if np.any(bool_flat): + # Pathologic flow + ntri = tri_pts.shape[0] + circum_radius = np.empty(ntri, dtype=np.float64) + circum_radius[bool_flat] = np.inf + abc = a*b*c + circum_radius[~bool_flat] = abc[~bool_flat] / ( + 4.0*np.sqrt(prod[~bool_flat])) + else: + # Normal optimized flow + circum_radius = (a*b*c) / (4.0*np.sqrt(prod)) + in_radius = (a*b*c) / (4.0*circum_radius*s) + circle_ratio = in_radius/circum_radius + mask = self._triangulation.mask + if mask is None: + return circle_ratio + else: + return np.ma.array(circle_ratio, mask=mask) + + def get_flat_tri_mask(self, min_circle_ratio=0.01, rescale=True): + """ + Eliminate excessively flat border triangles from the triangulation. + + Returns a mask *new_mask* which allows to clean the encapsulated + triangulation from its border-located flat triangles + (according to their :meth:`circle_ratios`). + This mask is meant to be subsequently applied to the triangulation + using `.Triangulation.set_mask`. + *new_mask* is an extension of the initial triangulation mask + in the sense that an initially masked triangle will remain masked. + + The *new_mask* array is computed recursively; at each step flat + triangles are removed only if they share a side with the current mesh + border. Thus, no new holes in the triangulated domain will be created. + + Parameters + ---------- + min_circle_ratio : float, default: 0.01 + Border triangles with incircle/circumcircle radii ratio r/R will + be removed if r/R < *min_circle_ratio*. + rescale : bool, default: True + If True, first, internally rescale (based on `scale_factors`) so + that the (unmasked) triangles fit exactly inside a unit square + mesh. This rescaling accounts for the difference of scale which + might exist between the 2 axis. + + Returns + ------- + array of bool + Mask to apply to encapsulated triangulation. + All the initially masked triangles remain masked in the + *new_mask*. + + Notes + ----- + The rationale behind this function is that a Delaunay + triangulation - of an unstructured set of points - sometimes contains + almost flat triangles at its border, leading to artifacts in plots + (especially for high-resolution contouring). + Masked with computed *new_mask*, the encapsulated + triangulation would contain no more unmasked border triangles + with a circle ratio below *min_circle_ratio*, thus improving the + mesh quality for subsequent plots or interpolation. + """ + # Recursively computes the mask_current_borders, true if a triangle is + # at the border of the mesh OR touching the border through a chain of + # invalid aspect ratio masked_triangles. + ntri = self._triangulation.triangles.shape[0] + mask_bad_ratio = self.circle_ratios(rescale) < min_circle_ratio + + current_mask = self._triangulation.mask + if current_mask is None: + current_mask = np.zeros(ntri, dtype=bool) + valid_neighbors = np.copy(self._triangulation.neighbors) + renum_neighbors = np.arange(ntri, dtype=np.int32) + nadd = -1 + while nadd != 0: + # The active wavefront is the triangles from the border (unmasked + # but with a least 1 neighbor equal to -1 + wavefront = (np.min(valid_neighbors, axis=1) == -1) & ~current_mask + # The element from the active wavefront will be masked if their + # circle ratio is bad. + added_mask = wavefront & mask_bad_ratio + current_mask = added_mask | current_mask + nadd = np.sum(added_mask) + + # now we have to update the tables valid_neighbors + valid_neighbors[added_mask, :] = -1 + renum_neighbors[added_mask] = -1 + valid_neighbors = np.where(valid_neighbors == -1, -1, + renum_neighbors[valid_neighbors]) + + return np.ma.filled(current_mask, True) + + def _get_compressed_triangulation(self): + """ + Compress (if masked) the encapsulated triangulation. + + Returns minimal-length triangles array (*compressed_triangles*) and + coordinates arrays (*compressed_x*, *compressed_y*) that can still + describe the unmasked triangles of the encapsulated triangulation. + + Returns + ------- + compressed_triangles : array-like + the returned compressed triangulation triangles + compressed_x : array-like + the returned compressed triangulation 1st coordinate + compressed_y : array-like + the returned compressed triangulation 2nd coordinate + tri_renum : int array + renumbering table to translate the triangle numbers from the + encapsulated triangulation into the new (compressed) renumbering. + -1 for masked triangles (deleted from *compressed_triangles*). + node_renum : int array + renumbering table to translate the point numbers from the + encapsulated triangulation into the new (compressed) renumbering. + -1 for unused points (i.e. those deleted from *compressed_x* and + *compressed_y*). + + """ + # Valid triangles and renumbering + tri_mask = self._triangulation.mask + compressed_triangles = self._triangulation.get_masked_triangles() + ntri = self._triangulation.triangles.shape[0] + if tri_mask is not None: + tri_renum = self._total_to_compress_renum(~tri_mask) + else: + tri_renum = np.arange(ntri, dtype=np.int32) + + # Valid nodes and renumbering + valid_node = (np.bincount(np.ravel(compressed_triangles), + minlength=self._triangulation.x.size) != 0) + compressed_x = self._triangulation.x[valid_node] + compressed_y = self._triangulation.y[valid_node] + node_renum = self._total_to_compress_renum(valid_node) + + # Now renumbering the valid triangles nodes + compressed_triangles = node_renum[compressed_triangles] + + return (compressed_triangles, compressed_x, compressed_y, tri_renum, + node_renum) + + @staticmethod + def _total_to_compress_renum(valid): + """ + Parameters + ---------- + valid : 1D bool array + Validity mask. + + Returns + ------- + int array + Array so that (`valid_array` being a compressed array + based on a `masked_array` with mask ~*valid*): + + - For all i with valid[i] = True: + valid_array[renum[i]] = masked_array[i] + - For all i with valid[i] = False: + renum[i] = -1 (invalid value) + """ + renum = np.full(np.size(valid), -1, dtype=np.int32) + n_valid = np.sum(valid) + renum[valid] = np.arange(n_valid, dtype=np.int32) + return renum diff --git a/lib/matplotlib/tri/triangulation.py b/lib/matplotlib/tri/triangulation.py index 24e99634a466..c48b09b280ff 100644 --- a/lib/matplotlib/tri/triangulation.py +++ b/lib/matplotlib/tri/triangulation.py @@ -1,220 +1,9 @@ -import numpy as np +from ._triangulation import * # noqa: F401, F403 +from matplotlib import _api -class Triangulation: - """ - An unstructured triangular grid consisting of npoints points and - ntri triangles. The triangles can either be specified by the user - or automatically generated using a Delaunay triangulation. - - Parameters - ---------- - x, y : (npoints,) array-like - Coordinates of grid points. - triangles : (ntri, 3) array-like of int, optional - For each triangle, the indices of the three points that make - up the triangle, ordered in an anticlockwise manner. If not - specified, the Delaunay triangulation is calculated. - mask : (ntri,) array-like of bool, optional - Which triangles are masked out. - - Attributes - ---------- - triangles : (ntri, 3) array of int - For each triangle, the indices of the three points that make - up the triangle, ordered in an anticlockwise manner. If you want to - take the *mask* into account, use `get_masked_triangles` instead. - mask : (ntri, 3) array of bool - Masked out triangles. - is_delaunay : bool - Whether the Triangulation is a calculated Delaunay - triangulation (where *triangles* was not specified) or not. - - Notes - ----- - For a Triangulation to be valid it must not have duplicate points, - triangles formed from colinear points, or overlapping triangles. - """ - def __init__(self, x, y, triangles=None, mask=None): - from matplotlib import _qhull - - self.x = np.asarray(x, dtype=np.float64) - self.y = np.asarray(y, dtype=np.float64) - if self.x.shape != self.y.shape or self.x.ndim != 1: - raise ValueError("x and y must be equal-length 1D arrays") - - self.mask = None - self._edges = None - self._neighbors = None - self.is_delaunay = False - - if triangles is None: - # No triangulation specified, so use matplotlib._qhull to obtain - # Delaunay triangulation. - self.triangles, self._neighbors = _qhull.delaunay(x, y) - self.is_delaunay = True - else: - # Triangulation specified. Copy, since we may correct triangle - # orientation. - self.triangles = np.array(triangles, dtype=np.int32, order='C') - if self.triangles.ndim != 2 or self.triangles.shape[1] != 3: - raise ValueError('triangles must be a (?, 3) array') - if self.triangles.max() >= len(self.x): - raise ValueError('triangles max element is out of bounds') - if self.triangles.min() < 0: - raise ValueError('triangles min element is out of bounds') - - if mask is not None: - self.mask = np.asarray(mask, dtype=bool) - if self.mask.shape != (self.triangles.shape[0],): - raise ValueError('mask array must have same length as ' - 'triangles array') - - # Underlying C++ object is not created until first needed. - self._cpp_triangulation = None - - # Default TriFinder not created until needed. - self._trifinder = None - - def calculate_plane_coefficients(self, z): - """ - Calculate plane equation coefficients for all unmasked triangles from - the point (x, y) coordinates and specified z-array of shape (npoints). - The returned array has shape (npoints, 3) and allows z-value at (x, y) - position in triangle tri to be calculated using - ``z = array[tri, 0] * x + array[tri, 1] * y + array[tri, 2]``. - """ - return self.get_cpp_triangulation().calculate_plane_coefficients(z) - - @property - def edges(self): - """ - Return integer array of shape (nedges, 2) containing all edges of - non-masked triangles. - - Each row defines an edge by it's start point index and end point - index. Each edge appears only once, i.e. for an edge between points - *i* and *j*, there will only be either *(i, j)* or *(j, i)*. - """ - if self._edges is None: - self._edges = self.get_cpp_triangulation().get_edges() - return self._edges - - def get_cpp_triangulation(self): - """ - Return the underlying C++ Triangulation object, creating it - if necessary. - """ - from matplotlib import _tri - if self._cpp_triangulation is None: - self._cpp_triangulation = _tri.Triangulation( - self.x, self.y, self.triangles, self.mask, self._edges, - self._neighbors, not self.is_delaunay) - return self._cpp_triangulation - - def get_masked_triangles(self): - """ - Return an array of triangles that are not masked. - """ - if self.mask is not None: - return self.triangles[~self.mask] - else: - return self.triangles - - @staticmethod - def get_from_args_and_kwargs(*args, **kwargs): - """ - Return a Triangulation object from the args and kwargs, and - the remaining args and kwargs with the consumed values removed. - - There are two alternatives: either the first argument is a - Triangulation object, in which case it is returned, or the args - and kwargs are sufficient to create a new Triangulation to - return. In the latter case, see Triangulation.__init__ for - the possible args and kwargs. - """ - if isinstance(args[0], Triangulation): - triangulation, *args = args - else: - x, y, *args = args - - # Check triangles in kwargs then args. - triangles = kwargs.pop('triangles', None) - from_args = False - if triangles is None and args: - triangles = args[0] - from_args = True - - if triangles is not None: - try: - triangles = np.asarray(triangles, dtype=np.int32) - except ValueError: - triangles = None - - if triangles is not None and (triangles.ndim != 2 or - triangles.shape[1] != 3): - triangles = None - - if triangles is not None and from_args: - args = args[1:] # Consumed first item in args. - - # Check for mask in kwargs. - mask = kwargs.pop('mask', None) - - triangulation = Triangulation(x, y, triangles, mask) - return triangulation, args, kwargs - - def get_trifinder(self): - """ - Return the default `matplotlib.tri.TriFinder` of this - triangulation, creating it if necessary. This allows the same - TriFinder object to be easily shared. - """ - if self._trifinder is None: - # Default TriFinder class. - from matplotlib.tri.trifinder import TrapezoidMapTriFinder - self._trifinder = TrapezoidMapTriFinder(self) - return self._trifinder - - @property - def neighbors(self): - """ - Return integer array of shape (ntri, 3) containing neighbor triangles. - - For each triangle, the indices of the three triangles that - share the same edges, or -1 if there is no such neighboring - triangle. ``neighbors[i, j]`` is the triangle that is the neighbor - to the edge from point index ``triangles[i, j]`` to point index - ``triangles[i, (j+1)%3]``. - """ - if self._neighbors is None: - self._neighbors = self.get_cpp_triangulation().get_neighbors() - return self._neighbors - - def set_mask(self, mask): - """ - Set or clear the mask array. - - Parameters - ---------- - mask : None or bool array of length ntri - """ - if mask is None: - self.mask = None - else: - self.mask = np.asarray(mask, dtype=bool) - if self.mask.shape != (self.triangles.shape[0],): - raise ValueError('mask array must have same length as ' - 'triangles array') - - # Set mask in C++ Triangulation. - if self._cpp_triangulation is not None: - self._cpp_triangulation.set_mask(self.mask) - - # Clear derived fields so they are recalculated when needed. - self._edges = None - self._neighbors = None - - # Recalculate TriFinder if it exists. - if self._trifinder is not None: - self._trifinder._initialize() +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/tricontour.py b/lib/matplotlib/tri/tricontour.py index dd7e569e641f..37406451d376 100644 --- a/lib/matplotlib/tri/tricontour.py +++ b/lib/matplotlib/tri/tricontour.py @@ -1,307 +1,9 @@ -import numpy as np +from ._tricontour import * # noqa: F401, F403 +from matplotlib import _api -from matplotlib import docstring -from matplotlib.contour import ContourSet -from matplotlib.tri.triangulation import Triangulation - -@docstring.dedent_interpd -class TriContourSet(ContourSet): - """ - Create and store a set of contour lines or filled regions for - a triangular grid. - - This class is typically not instantiated directly by the user but by - `~.Axes.tricontour` and `~.Axes.tricontourf`. - - %(contour_set_attributes)s - """ - def __init__(self, ax, *args, **kwargs): - """ - Draw triangular grid contour lines or filled regions, - depending on whether keyword arg 'filled' is False - (default) or True. - - The first argument of the initializer must be an axes - object. The remaining arguments and keyword arguments - are described in the docstring of `~.Axes.tricontour`. - """ - super().__init__(ax, *args, **kwargs) - - def _process_args(self, *args, **kwargs): - """ - Process args and kwargs. - """ - if isinstance(args[0], TriContourSet): - C = args[0]._contour_generator - if self.levels is None: - self.levels = args[0].levels - self.zmin = args[0].zmin - self.zmax = args[0].zmax - self._mins = args[0]._mins - self._maxs = args[0]._maxs - else: - from matplotlib import _tri - tri, z = self._contour_args(args, kwargs) - C = _tri.TriContourGenerator(tri.get_cpp_triangulation(), z) - self._mins = [tri.x.min(), tri.y.min()] - self._maxs = [tri.x.max(), tri.y.max()] - - self._contour_generator = C - return kwargs - - def _contour_args(self, args, kwargs): - if self.filled: - fn = 'contourf' - else: - fn = 'contour' - tri, args, kwargs = Triangulation.get_from_args_and_kwargs(*args, - **kwargs) - z = np.ma.asarray(args[0]) - if z.shape != tri.x.shape: - raise ValueError('z array must have same length as triangulation x' - ' and y arrays') - - # z values must be finite, only need to check points that are included - # in the triangulation. - z_check = z[np.unique(tri.get_masked_triangles())] - if np.ma.is_masked(z_check): - raise ValueError('z must not contain masked points within the ' - 'triangulation') - if not np.isfinite(z_check).all(): - raise ValueError('z array must not contain non-finite values ' - 'within the triangulation') - - z = np.ma.masked_invalid(z, copy=False) - self.zmax = float(z_check.max()) - self.zmin = float(z_check.min()) - if self.logscale and self.zmin <= 0: - raise ValueError('Cannot %s log of negative values.' % fn) - self._process_contour_level_args(args[1:]) - return (tri, z) - - -docstring.interpd.update(_tricontour_doc=""" -Draw contour %(type)s on an unstructured triangular grid. - -The triangulation can be specified in one of two ways; either :: - - %(func)s(triangulation, ...) - -where *triangulation* is a `.Triangulation` object, or :: - - %(func)s(x, y, ...) - %(func)s(x, y, triangles, ...) - %(func)s(x, y, triangles=triangles, ...) - %(func)s(x, y, mask=mask, ...) - %(func)s(x, y, triangles, mask=mask, ...) - -in which case a `.Triangulation` object will be created. See that class' -docstring for an explanation of these cases. - -The remaining arguments may be:: - - %(func)s(..., Z) - -where *Z* is the array of values to contour, one per point in the -triangulation. The level values are chosen automatically. - -:: - - %(func)s(..., Z, levels) - -contour up to *levels+1* automatically chosen contour levels (*levels* -intervals). - -:: - - %(func)s(..., Z, levels) - -draw contour %(type)s at the values specified in sequence *levels*, which must -be in increasing order. - -:: - - %(func)s(Z, **kwargs) - -Use keyword arguments to control colors, linewidth, origin, cmap ... see below -for more details. - -Parameters ----------- -triangulation : `.Triangulation`, optional - The unstructured triangular grid. - - If specified, then *x*, *y*, *triangles*, and *mask* are not accepted. - -x, y : array-like, optional - The coordinates of the values in *Z*. - -triangles : (ntri, 3) array-like of int, optional - For each triangle, the indices of the three points that make up the - triangle, ordered in an anticlockwise manner. If not specified, the - Delaunay triangulation is calculated. - -mask : (ntri,) array-like of bool, optional - Which triangles are masked out. - -Z : 2D array-like - The height values over which the contour is drawn. - -levels : int or array-like, optional - Determines the number and positions of the contour lines / regions. - - If an int *n*, use `~matplotlib.ticker.MaxNLocator`, which tries to - automatically choose no more than *n+1* "nice" contour levels between - *vmin* and *vmax*. - - If array-like, draw contour lines at the specified levels. The values must - be in increasing order. - -Returns -------- -`~matplotlib.tri.TriContourSet` - -Other Parameters ----------------- -colors : color string or sequence of colors, optional - The colors of the levels, i.e., the contour %(type)s. - - The sequence is cycled for the levels in ascending order. If the sequence - is shorter than the number of levels, it's repeated. - - As a shortcut, single color strings may be used in place of one-element - lists, i.e. ``'red'`` instead of ``['red']`` to color all levels with the - same color. This shortcut does only work for color strings, not for other - ways of specifying colors. - - By default (value *None*), the colormap specified by *cmap* will be used. - -alpha : float, default: 1 - The alpha blending value, between 0 (transparent) and 1 (opaque). - -cmap : str or `.Colormap`, default: :rc:`image.cmap` - A `.Colormap` instance or registered colormap name. The colormap maps the - level values to colors. - - If both *colors* and *cmap* are given, an error is raised. - -norm : `~matplotlib.colors.Normalize`, optional - If a colormap is used, the `.Normalize` instance scales the level values to - the canonical colormap range [0, 1] for mapping to colors. If not given, - the default linear scaling is used. - -vmin, vmax : float, optional - If not *None*, either or both of these values will be supplied to - the `.Normalize` instance, overriding the default color scaling - based on *levels*. - -origin : {*None*, 'upper', 'lower', 'image'}, default: None - Determines the orientation and exact position of *Z* by specifying the - position of ``Z[0, 0]``. This is only relevant, if *X*, *Y* are not given. - - - *None*: ``Z[0, 0]`` is at X=0, Y=0 in the lower left corner. - - 'lower': ``Z[0, 0]`` is at X=0.5, Y=0.5 in the lower left corner. - - 'upper': ``Z[0, 0]`` is at X=N+0.5, Y=0.5 in the upper left corner. - - 'image': Use the value from :rc:`image.origin`. - -extent : (x0, x1, y0, y1), optional - If *origin* is not *None*, then *extent* is interpreted as in `.imshow`: it - gives the outer pixel boundaries. In this case, the position of Z[0, 0] is - the center of the pixel, not a corner. If *origin* is *None*, then - (*x0*, *y0*) is the position of Z[0, 0], and (*x1*, *y1*) is the position - of Z[-1, -1]. - - This argument is ignored if *X* and *Y* are specified in the call to - contour. - -locator : ticker.Locator subclass, optional - The locator is used to determine the contour levels if they are not given - explicitly via *levels*. - Defaults to `~.ticker.MaxNLocator`. - -extend : {'neither', 'both', 'min', 'max'}, default: 'neither' - Determines the ``%(func)s``-coloring of values that are outside the - *levels* range. - - If 'neither', values outside the *levels* range are not colored. If 'min', - 'max' or 'both', color the values below, above or below and above the - *levels* range. - - Values below ``min(levels)`` and above ``max(levels)`` are mapped to the - under/over values of the `.Colormap`. Note that most colormaps do not have - dedicated colors for these by default, so that the over and under values - are the edge values of the colormap. You may want to set these values - explicitly using `.Colormap.set_under` and `.Colormap.set_over`. - - .. note:: - - An existing `.TriContourSet` does not get notified if properties of its - colormap are changed. Therefore, an explicit call to - `.ContourSet.changed()` is needed after modifying the colormap. The - explicit call can be left out, if a colorbar is assigned to the - `.TriContourSet` because it internally calls `.ContourSet.changed()`. - -xunits, yunits : registered units, optional - Override axis units by specifying an instance of a - :class:`matplotlib.units.ConversionInterface`. - -antialiased : bool, optional - Enable antialiasing, overriding the defaults. For - filled contours, the default is *True*. For line contours, - it is taken from :rc:`lines.antialiased`.""") - - -@docstring.Substitution(func='tricontour', type='lines') -@docstring.dedent_interpd -def tricontour(ax, *args, **kwargs): - """ - %(_tricontour_doc)s - - linewidths : float or array-like, default: :rc:`contour.linewidth` - The line width of the contour lines. - - If a number, all levels will be plotted with this linewidth. - - If a sequence, the levels in ascending order will be plotted with - the linewidths in the order specified. - - If None, this falls back to :rc:`lines.linewidth`. - - linestyles : {*None*, 'solid', 'dashed', 'dashdot', 'dotted'}, optional - If *linestyles* is *None*, the default is 'solid' unless the lines are - monochrome. In that case, negative contours will take their linestyle - from :rc:`contour.negative_linestyle` setting. - - *linestyles* can also be an iterable of the above strings specifying a - set of linestyles to be used. If this iterable is shorter than the - number of contour levels it will be repeated as necessary. - """ - kwargs['filled'] = False - return TriContourSet(ax, *args, **kwargs) - - -@docstring.Substitution(func='tricontourf', type='regions') -@docstring.dedent_interpd -def tricontourf(ax, *args, **kwargs): - """ - %(_tricontour_doc)s - - hatches : list[str], optional - A list of cross hatch patterns to use on the filled areas. - If None, no hatching will be added to the contour. - Hatching is supported in the PostScript, PDF, SVG and Agg - backends only. - - Notes - ----- - `.tricontourf` fills intervals that are closed at the top; that is, for - boundaries *z1* and *z2*, the filled region is:: - - z1 < Z <= z2 - - except for the lowest interval, which is closed on both sides (i.e. it - includes the lowest value). - """ - kwargs['filled'] = True - return TriContourSet(ax, *args, **kwargs) +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/trifinder.py b/lib/matplotlib/tri/trifinder.py index e06b84c0d974..1aff5c9d3280 100644 --- a/lib/matplotlib/tri/trifinder.py +++ b/lib/matplotlib/tri/trifinder.py @@ -1,93 +1,9 @@ -import numpy as np - +from ._trifinder import * # noqa: F401, F403 from matplotlib import _api -from matplotlib.tri import Triangulation - - -class TriFinder: - """ - Abstract base class for classes used to find the triangles of a - Triangulation in which (x, y) points lie. - - Rather than instantiate an object of a class derived from TriFinder, it is - usually better to use the function `.Triangulation.get_trifinder`. - - Derived classes implement __call__(x, y) where x and y are array-like point - coordinates of the same shape. - """ - - def __init__(self, triangulation): - _api.check_isinstance(Triangulation, triangulation=triangulation) - self._triangulation = triangulation - - -class TrapezoidMapTriFinder(TriFinder): - """ - `~matplotlib.tri.TriFinder` class implemented using the trapezoid - map algorithm from the book "Computational Geometry, Algorithms and - Applications", second edition, by M. de Berg, M. van Kreveld, M. Overmars - and O. Schwarzkopf. - - The triangulation must be valid, i.e. it must not have duplicate points, - triangles formed from colinear points, or overlapping triangles. The - algorithm has some tolerance to triangles formed from colinear points, but - this should not be relied upon. - """ - - def __init__(self, triangulation): - from matplotlib import _tri - super().__init__(triangulation) - self._cpp_trifinder = _tri.TrapezoidMapTriFinder( - triangulation.get_cpp_triangulation()) - self._initialize() - - def __call__(self, x, y): - """ - Return an array containing the indices of the triangles in which the - specified *x*, *y* points lie, or -1 for points that do not lie within - a triangle. - - *x*, *y* are array-like x and y coordinates of the same shape and any - number of dimensions. - - Returns integer array with the same shape and *x* and *y*. - """ - x = np.asarray(x, dtype=np.float64) - y = np.asarray(y, dtype=np.float64) - if x.shape != y.shape: - raise ValueError("x and y must be array-like with the same shape") - - # C++ does the heavy lifting, and expects 1D arrays. - indices = (self._cpp_trifinder.find_many(x.ravel(), y.ravel()) - .reshape(x.shape)) - return indices - - def _get_tree_stats(self): - """ - Return a python list containing the statistics about the node tree: - 0: number of nodes (tree size) - 1: number of unique nodes - 2: number of trapezoids (tree leaf nodes) - 3: number of unique trapezoids - 4: maximum parent count (max number of times a node is repeated in - tree) - 5: maximum depth of tree (one more than the maximum number of - comparisons needed to search through the tree) - 6: mean of all trapezoid depths (one more than the average number - of comparisons needed to search through the tree) - """ - return self._cpp_trifinder.get_tree_stats() - def _initialize(self): - """ - Initialize the underlying C++ object. Can be called multiple times if, - for example, the triangulation is modified. - """ - self._cpp_trifinder.initialize() - def _print_tree(self): - """ - Print a text representation of the node tree, which is useful for - debugging purposes. - """ - self._cpp_trifinder.print_tree() +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/triinterpolate.py b/lib/matplotlib/tri/triinterpolate.py index 581477619fc1..3112bd38e6c6 100644 --- a/lib/matplotlib/tri/triinterpolate.py +++ b/lib/matplotlib/tri/triinterpolate.py @@ -1,1579 +1,9 @@ -""" -Interpolation inside triangular grids. -""" - -import numpy as np - +from ._triinterpolate import * # noqa: F401, F403 from matplotlib import _api -from matplotlib.tri import Triangulation -from matplotlib.tri.trifinder import TriFinder -from matplotlib.tri.tritools import TriAnalyzer - -__all__ = ('TriInterpolator', 'LinearTriInterpolator', 'CubicTriInterpolator') - - -class TriInterpolator: - """ - Abstract base class for classes used to interpolate on a triangular grid. - - Derived classes implement the following methods: - - - ``__call__(x, y)``, - where x, y are array-like point coordinates of the same shape, and - that returns a masked array of the same shape containing the - interpolated z-values. - - - ``gradient(x, y)``, - where x, y are array-like point coordinates of the same - shape, and that returns a list of 2 masked arrays of the same shape - containing the 2 derivatives of the interpolator (derivatives of - interpolated z values with respect to x and y). - """ - - def __init__(self, triangulation, z, trifinder=None): - _api.check_isinstance(Triangulation, triangulation=triangulation) - self._triangulation = triangulation - - self._z = np.asarray(z) - if self._z.shape != self._triangulation.x.shape: - raise ValueError("z array must have same length as triangulation x" - " and y arrays") - - _api.check_isinstance((TriFinder, None), trifinder=trifinder) - self._trifinder = trifinder or self._triangulation.get_trifinder() - - # Default scaling factors : 1.0 (= no scaling) - # Scaling may be used for interpolations for which the order of - # magnitude of x, y has an impact on the interpolant definition. - # Please refer to :meth:`_interpolate_multikeys` for details. - self._unit_x = 1.0 - self._unit_y = 1.0 - - # Default triangle renumbering: None (= no renumbering) - # Renumbering may be used to avoid unnecessary computations - # if complex calculations are done inside the Interpolator. - # Please refer to :meth:`_interpolate_multikeys` for details. - self._tri_renum = None - - # __call__ and gradient docstrings are shared by all subclasses - # (except, if needed, relevant additions). - # However these methods are only implemented in subclasses to avoid - # confusion in the documentation. - _docstring__call__ = """ - Returns a masked array containing interpolated values at the specified - (x, y) points. - - Parameters - ---------- - x, y : array-like - x and y coordinates of the same shape and any number of - dimensions. - - Returns - ------- - np.ma.array - Masked array of the same shape as *x* and *y*; values corresponding - to (*x*, *y*) points outside of the triangulation are masked out. - - """ - - _docstringgradient = r""" - Returns a list of 2 masked arrays containing interpolated derivatives - at the specified (x, y) points. - - Parameters - ---------- - x, y : array-like - x and y coordinates of the same shape and any number of - dimensions. - - Returns - ------- - dzdx, dzdy : np.ma.array - 2 masked arrays of the same shape as *x* and *y*; values - corresponding to (x, y) points outside of the triangulation - are masked out. - The first returned array contains the values of - :math:`\frac{\partial z}{\partial x}` and the second those of - :math:`\frac{\partial z}{\partial y}`. - - """ - - def _interpolate_multikeys(self, x, y, tri_index=None, - return_keys=('z',)): - """ - Versatile (private) method defined for all TriInterpolators. - - :meth:`_interpolate_multikeys` is a wrapper around method - :meth:`_interpolate_single_key` (to be defined in the child - subclasses). - :meth:`_interpolate_single_key actually performs the interpolation, - but only for 1-dimensional inputs and at valid locations (inside - unmasked triangles of the triangulation). - - The purpose of :meth:`_interpolate_multikeys` is to implement the - following common tasks needed in all subclasses implementations: - - - calculation of containing triangles - - dealing with more than one interpolation request at the same - location (e.g., if the 2 derivatives are requested, it is - unnecessary to compute the containing triangles twice) - - scaling according to self._unit_x, self._unit_y - - dealing with points outside of the grid (with fill value np.nan) - - dealing with multi-dimensional *x*, *y* arrays: flattening for - :meth:`_interpolate_params` call and final reshaping. - - (Note that np.vectorize could do most of those things very well for - you, but it does it by function evaluations over successive tuples of - the input arrays. Therefore, this tends to be more time consuming than - using optimized numpy functions - e.g., np.dot - which can be used - easily on the flattened inputs, in the child-subclass methods - :meth:`_interpolate_single_key`.) - - It is guaranteed that the calls to :meth:`_interpolate_single_key` - will be done with flattened (1-d) array-like input parameters *x*, *y* - and with flattened, valid `tri_index` arrays (no -1 index allowed). - - Parameters - ---------- - x, y : array-like - x and y coordinates where interpolated values are requested. - tri_index : array-like of int, optional - Array of the containing triangle indices, same shape as - *x* and *y*. Defaults to None. If None, these indices - will be computed by a TriFinder instance. - (Note: For point outside the grid, tri_index[ipt] shall be -1). - return_keys : tuple of keys from {'z', 'dzdx', 'dzdy'} - Defines the interpolation arrays to return, and in which order. - - Returns - ------- - list of arrays - Each array-like contains the expected interpolated values in the - order defined by *return_keys* parameter. - """ - # Flattening and rescaling inputs arrays x, y - # (initial shape is stored for output) - x = np.asarray(x, dtype=np.float64) - y = np.asarray(y, dtype=np.float64) - sh_ret = x.shape - if x.shape != y.shape: - raise ValueError("x and y shall have same shapes." - " Given: {0} and {1}".format(x.shape, y.shape)) - x = np.ravel(x) - y = np.ravel(y) - x_scaled = x/self._unit_x - y_scaled = y/self._unit_y - size_ret = np.size(x_scaled) - - # Computes & ravels the element indexes, extract the valid ones. - if tri_index is None: - tri_index = self._trifinder(x, y) - else: - if tri_index.shape != sh_ret: - raise ValueError( - "tri_index array is provided and shall" - " have same shape as x and y. Given: " - "{0} and {1}".format(tri_index.shape, sh_ret)) - tri_index = np.ravel(tri_index) - - mask_in = (tri_index != -1) - if self._tri_renum is None: - valid_tri_index = tri_index[mask_in] - else: - valid_tri_index = self._tri_renum[tri_index[mask_in]] - valid_x = x_scaled[mask_in] - valid_y = y_scaled[mask_in] - - ret = [] - for return_key in return_keys: - # Find the return index associated with the key. - try: - return_index = {'z': 0, 'dzdx': 1, 'dzdy': 2}[return_key] - except KeyError as err: - raise ValueError("return_keys items shall take values in" - " {'z', 'dzdx', 'dzdy'}") from err - - # Sets the scale factor for f & df components - scale = [1., 1./self._unit_x, 1./self._unit_y][return_index] - - # Computes the interpolation - ret_loc = np.empty(size_ret, dtype=np.float64) - ret_loc[~mask_in] = np.nan - ret_loc[mask_in] = self._interpolate_single_key( - return_key, valid_tri_index, valid_x, valid_y) * scale - ret += [np.ma.masked_invalid(ret_loc.reshape(sh_ret), copy=False)] - - return ret - - def _interpolate_single_key(self, return_key, tri_index, x, y): - """ - Interpolate at points belonging to the triangulation - (inside an unmasked triangles). - - Parameters - ---------- - return_key : {'z', 'dzdx', 'dzdy'} - The requested values (z or its derivatives). - tri_index : 1D int array - Valid triangle index (cannot be -1). - x, y : 1D arrays, same shape as `tri_index` - Valid locations where interpolation is requested. - - Returns - ------- - 1-d array - Returned array of the same size as *tri_index* - """ - raise NotImplementedError("TriInterpolator subclasses" + - "should implement _interpolate_single_key!") - - -class LinearTriInterpolator(TriInterpolator): - """ - Linear interpolator on a triangular grid. - - Each triangle is represented by a plane so that an interpolated value at - point (x, y) lies on the plane of the triangle containing (x, y). - Interpolated values are therefore continuous across the triangulation, but - their first derivatives are discontinuous at edges between triangles. - - Parameters - ---------- - triangulation : `~matplotlib.tri.Triangulation` - The triangulation to interpolate over. - z : (npoints,) array-like - Array of values, defined at grid points, to interpolate between. - trifinder : `~matplotlib.tri.TriFinder`, optional - If this is not specified, the Triangulation's default TriFinder will - be used by calling `.Triangulation.get_trifinder`. - - Methods - ------- - `__call__` (x, y) : Returns interpolated values at (x, y) points. - `gradient` (x, y) : Returns interpolated derivatives at (x, y) points. - - """ - def __init__(self, triangulation, z, trifinder=None): - super().__init__(triangulation, z, trifinder) - - # Store plane coefficients for fast interpolation calculations. - self._plane_coefficients = \ - self._triangulation.calculate_plane_coefficients(self._z) - - def __call__(self, x, y): - return self._interpolate_multikeys(x, y, tri_index=None, - return_keys=('z',))[0] - __call__.__doc__ = TriInterpolator._docstring__call__ - - def gradient(self, x, y): - return self._interpolate_multikeys(x, y, tri_index=None, - return_keys=('dzdx', 'dzdy')) - gradient.__doc__ = TriInterpolator._docstringgradient - - def _interpolate_single_key(self, return_key, tri_index, x, y): - if return_key == 'z': - return (self._plane_coefficients[tri_index, 0]*x + - self._plane_coefficients[tri_index, 1]*y + - self._plane_coefficients[tri_index, 2]) - elif return_key == 'dzdx': - return self._plane_coefficients[tri_index, 0] - elif return_key == 'dzdy': - return self._plane_coefficients[tri_index, 1] - else: - raise ValueError("Invalid return_key: " + return_key) - - -class CubicTriInterpolator(TriInterpolator): - r""" - Cubic interpolator on a triangular grid. - - In one-dimension - on a segment - a cubic interpolating function is - defined by the values of the function and its derivative at both ends. - This is almost the same in 2D inside a triangle, except that the values - of the function and its 2 derivatives have to be defined at each triangle - node. - - The CubicTriInterpolator takes the value of the function at each node - - provided by the user - and internally computes the value of the - derivatives, resulting in a smooth interpolation. - (As a special feature, the user can also impose the value of the - derivatives at each node, but this is not supposed to be the common - usage.) - - Parameters - ---------- - triangulation : `~matplotlib.tri.Triangulation` - The triangulation to interpolate over. - z : (npoints,) array-like - Array of values, defined at grid points, to interpolate between. - kind : {'min_E', 'geom', 'user'}, optional - Choice of the smoothing algorithm, in order to compute - the interpolant derivatives (defaults to 'min_E'): - - - if 'min_E': (default) The derivatives at each node is computed - to minimize a bending energy. - - if 'geom': The derivatives at each node is computed as a - weighted average of relevant triangle normals. To be used for - speed optimization (large grids). - - if 'user': The user provides the argument *dz*, no computation - is hence needed. - - trifinder : `~matplotlib.tri.TriFinder`, optional - If not specified, the Triangulation's default TriFinder will - be used by calling `.Triangulation.get_trifinder`. - dz : tuple of array-likes (dzdx, dzdy), optional - Used only if *kind* ='user'. In this case *dz* must be provided as - (dzdx, dzdy) where dzdx, dzdy are arrays of the same shape as *z* and - are the interpolant first derivatives at the *triangulation* points. - - Methods - ------- - `__call__` (x, y) : Returns interpolated values at (x, y) points. - `gradient` (x, y) : Returns interpolated derivatives at (x, y) points. - - Notes - ----- - This note is a bit technical and details how the cubic interpolation is - computed. - - The interpolation is based on a Clough-Tocher subdivision scheme of - the *triangulation* mesh (to make it clearer, each triangle of the - grid will be divided in 3 child-triangles, and on each child triangle - the interpolated function is a cubic polynomial of the 2 coordinates). - This technique originates from FEM (Finite Element Method) analysis; - the element used is a reduced Hsieh-Clough-Tocher (HCT) - element. Its shape functions are described in [1]_. - The assembled function is guaranteed to be C1-smooth, i.e. it is - continuous and its first derivatives are also continuous (this - is easy to show inside the triangles but is also true when crossing the - edges). - - In the default case (*kind* ='min_E'), the interpolant minimizes a - curvature energy on the functional space generated by the HCT element - shape functions - with imposed values but arbitrary derivatives at each - node. The minimized functional is the integral of the so-called total - curvature (implementation based on an algorithm from [2]_ - PCG sparse - solver): - - .. math:: - - E(z) = \frac{1}{2} \int_{\Omega} \left( - \left( \frac{\partial^2{z}}{\partial{x}^2} \right)^2 + - \left( \frac{\partial^2{z}}{\partial{y}^2} \right)^2 + - 2\left( \frac{\partial^2{z}}{\partial{y}\partial{x}} \right)^2 - \right) dx\,dy - - If the case *kind* ='geom' is chosen by the user, a simple geometric - approximation is used (weighted average of the triangle normal - vectors), which could improve speed on very large grids. - - References - ---------- - .. [1] Michel Bernadou, Kamal Hassan, "Basis functions for general - Hsieh-Clough-Tocher triangles, complete or reduced.", - International Journal for Numerical Methods in Engineering, - 17(5):784 - 789. 2.01. - .. [2] C.T. Kelley, "Iterative Methods for Optimization". - - """ - def __init__(self, triangulation, z, kind='min_E', trifinder=None, - dz=None): - super().__init__(triangulation, z, trifinder) - - # Loads the underlying c++ _triangulation. - # (During loading, reordering of triangulation._triangles may occur so - # that all final triangles are now anti-clockwise) - self._triangulation.get_cpp_triangulation() - - # To build the stiffness matrix and avoid zero-energy spurious modes - # we will only store internally the valid (unmasked) triangles and - # the necessary (used) points coordinates. - # 2 renumbering tables need to be computed and stored: - # - a triangle renum table in order to translate the result from a - # TriFinder instance into the internal stored triangle number. - # - a node renum table to overwrite the self._z values into the new - # (used) node numbering. - tri_analyzer = TriAnalyzer(self._triangulation) - (compressed_triangles, compressed_x, compressed_y, tri_renum, - node_renum) = tri_analyzer._get_compressed_triangulation() - self._triangles = compressed_triangles - self._tri_renum = tri_renum - # Taking into account the node renumbering in self._z: - valid_node = (node_renum != -1) - self._z[node_renum[valid_node]] = self._z[valid_node] - - # Computing scale factors - self._unit_x = np.ptp(compressed_x) - self._unit_y = np.ptp(compressed_y) - self._pts = np.column_stack([compressed_x / self._unit_x, - compressed_y / self._unit_y]) - # Computing triangle points - self._tris_pts = self._pts[self._triangles] - # Computing eccentricities - self._eccs = self._compute_tri_eccentricities(self._tris_pts) - # Computing dof estimations for HCT triangle shape function - self._dof = self._compute_dof(kind, dz=dz) - # Loading HCT element - self._ReferenceElement = _ReducedHCT_Element() - - def __call__(self, x, y): - return self._interpolate_multikeys(x, y, tri_index=None, - return_keys=('z',))[0] - __call__.__doc__ = TriInterpolator._docstring__call__ - - def gradient(self, x, y): - return self._interpolate_multikeys(x, y, tri_index=None, - return_keys=('dzdx', 'dzdy')) - gradient.__doc__ = TriInterpolator._docstringgradient - - def _interpolate_single_key(self, return_key, tri_index, x, y): - tris_pts = self._tris_pts[tri_index] - alpha = self._get_alpha_vec(x, y, tris_pts) - ecc = self._eccs[tri_index] - dof = np.expand_dims(self._dof[tri_index], axis=1) - if return_key == 'z': - return self._ReferenceElement.get_function_values( - alpha, ecc, dof) - elif return_key in ['dzdx', 'dzdy']: - J = self._get_jacobian(tris_pts) - dzdx = self._ReferenceElement.get_function_derivatives( - alpha, J, ecc, dof) - if return_key == 'dzdx': - return dzdx[:, 0, 0] - else: - return dzdx[:, 1, 0] - else: - raise ValueError("Invalid return_key: " + return_key) - - def _compute_dof(self, kind, dz=None): - """ - Compute and return nodal dofs according to kind. - - Parameters - ---------- - kind : {'min_E', 'geom', 'user'} - Choice of the _DOF_estimator subclass to estimate the gradient. - dz : tuple of array-likes (dzdx, dzdy), optional - Used only if *kind*=user; in this case passed to the - :class:`_DOF_estimator_user`. - - Returns - ------- - array-like, shape (npts, 2) - Estimation of the gradient at triangulation nodes (stored as - degree of freedoms of reduced-HCT triangle elements). - """ - if kind == 'user': - if dz is None: - raise ValueError("For a CubicTriInterpolator with " - "*kind*='user', a valid *dz* " - "argument is expected.") - TE = _DOF_estimator_user(self, dz=dz) - elif kind == 'geom': - TE = _DOF_estimator_geom(self) - elif kind == 'min_E': - TE = _DOF_estimator_min_E(self) - else: - _api.check_in_list(['user', 'geom', 'min_E'], kind=kind) - return TE.compute_dof_from_df() - - @staticmethod - def _get_alpha_vec(x, y, tris_pts): - """ - Fast (vectorized) function to compute barycentric coordinates alpha. - - Parameters - ---------- - x, y : array-like of dim 1 (shape (nx,)) - Coordinates of the points whose points barycentric coordinates are - requested. - tris_pts : array like of dim 3 (shape: (nx, 3, 2)) - Coordinates of the containing triangles apexes. - - Returns - ------- - array of dim 2 (shape (nx, 3)) - Barycentric coordinates of the points inside the containing - triangles. - """ - ndim = tris_pts.ndim-2 - - a = tris_pts[:, 1, :] - tris_pts[:, 0, :] - b = tris_pts[:, 2, :] - tris_pts[:, 0, :] - abT = np.stack([a, b], axis=-1) - ab = _transpose_vectorized(abT) - OM = np.stack([x, y], axis=1) - tris_pts[:, 0, :] - - metric = ab @ abT - # Here we try to deal with the colinear cases. - # metric_inv is in this case set to the Moore-Penrose pseudo-inverse - # meaning that we will still return a set of valid barycentric - # coordinates. - metric_inv = _pseudo_inv22sym_vectorized(metric) - Covar = ab @ _transpose_vectorized(np.expand_dims(OM, ndim)) - ksi = metric_inv @ Covar - alpha = _to_matrix_vectorized([ - [1-ksi[:, 0, 0]-ksi[:, 1, 0]], [ksi[:, 0, 0]], [ksi[:, 1, 0]]]) - return alpha - - @staticmethod - def _get_jacobian(tris_pts): - """ - Fast (vectorized) function to compute triangle jacobian matrix. - - Parameters - ---------- - tris_pts : array like of dim 3 (shape: (nx, 3, 2)) - Coordinates of the containing triangles apexes. - - Returns - ------- - array of dim 3 (shape (nx, 2, 2)) - Barycentric coordinates of the points inside the containing - triangles. - J[itri, :, :] is the jacobian matrix at apex 0 of the triangle - itri, so that the following (matrix) relationship holds: - [dz/dksi] = [J] x [dz/dx] - with x: global coordinates - ksi: element parametric coordinates in triangle first apex - local basis. - """ - a = np.array(tris_pts[:, 1, :] - tris_pts[:, 0, :]) - b = np.array(tris_pts[:, 2, :] - tris_pts[:, 0, :]) - J = _to_matrix_vectorized([[a[:, 0], a[:, 1]], - [b[:, 0], b[:, 1]]]) - return J - - @staticmethod - def _compute_tri_eccentricities(tris_pts): - """ - Compute triangle eccentricities. - - Parameters - ---------- - tris_pts : array like of dim 3 (shape: (nx, 3, 2)) - Coordinates of the triangles apexes. - - Returns - ------- - array like of dim 2 (shape: (nx, 3)) - The so-called eccentricity parameters [1] needed for HCT triangular - element. - """ - a = np.expand_dims(tris_pts[:, 2, :] - tris_pts[:, 1, :], axis=2) - b = np.expand_dims(tris_pts[:, 0, :] - tris_pts[:, 2, :], axis=2) - c = np.expand_dims(tris_pts[:, 1, :] - tris_pts[:, 0, :], axis=2) - # Do not use np.squeeze, this is dangerous if only one triangle - # in the triangulation... - dot_a = (_transpose_vectorized(a) @ a)[:, 0, 0] - dot_b = (_transpose_vectorized(b) @ b)[:, 0, 0] - dot_c = (_transpose_vectorized(c) @ c)[:, 0, 0] - # Note that this line will raise a warning for dot_a, dot_b or dot_c - # zeros, but we choose not to support triangles with duplicate points. - return _to_matrix_vectorized([[(dot_c-dot_b) / dot_a], - [(dot_a-dot_c) / dot_b], - [(dot_b-dot_a) / dot_c]]) - - -# FEM element used for interpolation and for solving minimisation -# problem (Reduced HCT element) -class _ReducedHCT_Element: - """ - Implementation of reduced HCT triangular element with explicit shape - functions. - - Computes z, dz, d2z and the element stiffness matrix for bending energy: - E(f) = integral( (d2z/dx2 + d2z/dy2)**2 dA) - - *** Reference for the shape functions: *** - [1] Basis functions for general Hsieh-Clough-Tocher _triangles, complete or - reduced. - Michel Bernadou, Kamal Hassan - International Journal for Numerical Methods in Engineering. - 17(5):784 - 789. 2.01 - - *** Element description: *** - 9 dofs: z and dz given at 3 apex - C1 (conform) - - """ - # 1) Loads matrices to generate shape functions as a function of - # triangle eccentricities - based on [1] p.11 ''' - M = np.array([ - [ 0.00, 0.00, 0.00, 4.50, 4.50, 0.00, 0.00, 0.00, 0.00, 0.00], - [-0.25, 0.00, 0.00, 0.50, 1.25, 0.00, 0.00, 0.00, 0.00, 0.00], - [-0.25, 0.00, 0.00, 1.25, 0.50, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.50, 1.00, 0.00, -1.50, 0.00, 3.00, 3.00, 0.00, 0.00, 3.00], - [ 0.00, 0.00, 0.00, -0.25, 0.25, 0.00, 1.00, 0.00, 0.00, 0.50], - [ 0.25, 0.00, 0.00, -0.50, -0.25, 1.00, 0.00, 0.00, 0.00, 1.00], - [ 0.50, 0.00, 1.00, 0.00, -1.50, 0.00, 0.00, 3.00, 3.00, 3.00], - [ 0.25, 0.00, 0.00, -0.25, -0.50, 0.00, 0.00, 0.00, 1.00, 1.00], - [ 0.00, 0.00, 0.00, 0.25, -0.25, 0.00, 0.00, 1.00, 0.00, 0.50]]) - M0 = np.array([ - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [-1.00, 0.00, 0.00, 1.50, 1.50, 0.00, 0.00, 0.00, 0.00, -3.00], - [-0.50, 0.00, 0.00, 0.75, 0.75, 0.00, 0.00, 0.00, 0.00, -1.50], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 1.00, 0.00, 0.00, -1.50, -1.50, 0.00, 0.00, 0.00, 0.00, 3.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.50, 0.00, 0.00, -0.75, -0.75, 0.00, 0.00, 0.00, 0.00, 1.50]]) - M1 = np.array([ - [-0.50, 0.00, 0.00, 1.50, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [-0.25, 0.00, 0.00, 0.75, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.50, 0.00, 0.00, -1.50, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.25, 0.00, 0.00, -0.75, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]]) - M2 = np.array([ - [ 0.50, 0.00, 0.00, 0.00, -1.50, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.25, 0.00, 0.00, 0.00, -0.75, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [-0.50, 0.00, 0.00, 0.00, 1.50, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [-0.25, 0.00, 0.00, 0.00, 0.75, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00], - [ 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]]) - - # 2) Loads matrices to rotate components of gradient & Hessian - # vectors in the reference basis of triangle first apex (a0) - rotate_dV = np.array([[ 1., 0.], [ 0., 1.], - [ 0., 1.], [-1., -1.], - [-1., -1.], [ 1., 0.]]) - - rotate_d2V = np.array([[1., 0., 0.], [0., 1., 0.], [ 0., 0., 1.], - [0., 1., 0.], [1., 1., 1.], [ 0., -2., -1.], - [1., 1., 1.], [1., 0., 0.], [-2., 0., -1.]]) - - # 3) Loads Gauss points & weights on the 3 sub-_triangles for P2 - # exact integral - 3 points on each subtriangles. - # NOTE: as the 2nd derivative is discontinuous , we really need those 9 - # points! - n_gauss = 9 - gauss_pts = np.array([[13./18., 4./18., 1./18.], - [ 4./18., 13./18., 1./18.], - [ 7./18., 7./18., 4./18.], - [ 1./18., 13./18., 4./18.], - [ 1./18., 4./18., 13./18.], - [ 4./18., 7./18., 7./18.], - [ 4./18., 1./18., 13./18.], - [13./18., 1./18., 4./18.], - [ 7./18., 4./18., 7./18.]], dtype=np.float64) - gauss_w = np.ones([9], dtype=np.float64) / 9. - - # 4) Stiffness matrix for curvature energy - E = np.array([[1., 0., 0.], [0., 1., 0.], [0., 0., 2.]]) - - # 5) Loads the matrix to compute DOF_rot from tri_J at apex 0 - J0_to_J1 = np.array([[-1., 1.], [-1., 0.]]) - J0_to_J2 = np.array([[ 0., -1.], [ 1., -1.]]) - - def get_function_values(self, alpha, ecc, dofs): - """ - Parameters - ---------- - alpha : is a (N x 3 x 1) array (array of column-matrices) of - barycentric coordinates, - ecc : is a (N x 3 x 1) array (array of column-matrices) of triangle - eccentricities, - dofs : is a (N x 1 x 9) arrays (arrays of row-matrices) of computed - degrees of freedom. - - Returns - ------- - Returns the N-array of interpolated function values. - """ - subtri = np.argmin(alpha, axis=1)[:, 0] - ksi = _roll_vectorized(alpha, -subtri, axis=0) - E = _roll_vectorized(ecc, -subtri, axis=0) - x = ksi[:, 0, 0] - y = ksi[:, 1, 0] - z = ksi[:, 2, 0] - x_sq = x*x - y_sq = y*y - z_sq = z*z - V = _to_matrix_vectorized([ - [x_sq*x], [y_sq*y], [z_sq*z], [x_sq*z], [x_sq*y], [y_sq*x], - [y_sq*z], [z_sq*y], [z_sq*x], [x*y*z]]) - prod = self.M @ V - prod += _scalar_vectorized(E[:, 0, 0], self.M0 @ V) - prod += _scalar_vectorized(E[:, 1, 0], self.M1 @ V) - prod += _scalar_vectorized(E[:, 2, 0], self.M2 @ V) - s = _roll_vectorized(prod, 3*subtri, axis=0) - return (dofs @ s)[:, 0, 0] - - def get_function_derivatives(self, alpha, J, ecc, dofs): - """ - Parameters - ---------- - *alpha* is a (N x 3 x 1) array (array of column-matrices of - barycentric coordinates) - *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at - triangle first apex) - *ecc* is a (N x 3 x 1) array (array of column-matrices of triangle - eccentricities) - *dofs* is a (N x 1 x 9) arrays (arrays of row-matrices) of computed - degrees of freedom. - - Returns - ------- - Returns the values of interpolated function derivatives [dz/dx, dz/dy] - in global coordinates at locations alpha, as a column-matrices of - shape (N x 2 x 1). - """ - subtri = np.argmin(alpha, axis=1)[:, 0] - ksi = _roll_vectorized(alpha, -subtri, axis=0) - E = _roll_vectorized(ecc, -subtri, axis=0) - x = ksi[:, 0, 0] - y = ksi[:, 1, 0] - z = ksi[:, 2, 0] - x_sq = x*x - y_sq = y*y - z_sq = z*z - dV = _to_matrix_vectorized([ - [ -3.*x_sq, -3.*x_sq], - [ 3.*y_sq, 0.], - [ 0., 3.*z_sq], - [ -2.*x*z, -2.*x*z+x_sq], - [-2.*x*y+x_sq, -2.*x*y], - [ 2.*x*y-y_sq, -y_sq], - [ 2.*y*z, y_sq], - [ z_sq, 2.*y*z], - [ -z_sq, 2.*x*z-z_sq], - [ x*z-y*z, x*y-y*z]]) - # Puts back dV in first apex basis - dV = dV @ _extract_submatrices( - self.rotate_dV, subtri, block_size=2, axis=0) - - prod = self.M @ dV - prod += _scalar_vectorized(E[:, 0, 0], self.M0 @ dV) - prod += _scalar_vectorized(E[:, 1, 0], self.M1 @ dV) - prod += _scalar_vectorized(E[:, 2, 0], self.M2 @ dV) - dsdksi = _roll_vectorized(prod, 3*subtri, axis=0) - dfdksi = dofs @ dsdksi - # In global coordinates: - # Here we try to deal with the simplest colinear cases, returning a - # null matrix. - J_inv = _safe_inv22_vectorized(J) - dfdx = J_inv @ _transpose_vectorized(dfdksi) - return dfdx - - def get_function_hessians(self, alpha, J, ecc, dofs): - """ - Parameters - ---------- - *alpha* is a (N x 3 x 1) array (array of column-matrices) of - barycentric coordinates - *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at - triangle first apex) - *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle - eccentricities - *dofs* is a (N x 1 x 9) arrays (arrays of row-matrices) of computed - degrees of freedom. - - Returns - ------- - Returns the values of interpolated function 2nd-derivatives - [d2z/dx2, d2z/dy2, d2z/dxdy] in global coordinates at locations alpha, - as a column-matrices of shape (N x 3 x 1). - """ - d2sdksi2 = self.get_d2Sidksij2(alpha, ecc) - d2fdksi2 = dofs @ d2sdksi2 - H_rot = self.get_Hrot_from_J(J) - d2fdx2 = d2fdksi2 @ H_rot - return _transpose_vectorized(d2fdx2) - - def get_d2Sidksij2(self, alpha, ecc): - """ - Parameters - ---------- - *alpha* is a (N x 3 x 1) array (array of column-matrices) of - barycentric coordinates - *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle - eccentricities - - Returns - ------- - Returns the arrays d2sdksi2 (N x 3 x 1) Hessian of shape functions - expressed in covariant coordinates in first apex basis. - """ - subtri = np.argmin(alpha, axis=1)[:, 0] - ksi = _roll_vectorized(alpha, -subtri, axis=0) - E = _roll_vectorized(ecc, -subtri, axis=0) - x = ksi[:, 0, 0] - y = ksi[:, 1, 0] - z = ksi[:, 2, 0] - d2V = _to_matrix_vectorized([ - [ 6.*x, 6.*x, 6.*x], - [ 6.*y, 0., 0.], - [ 0., 6.*z, 0.], - [ 2.*z, 2.*z-4.*x, 2.*z-2.*x], - [2.*y-4.*x, 2.*y, 2.*y-2.*x], - [2.*x-4.*y, 0., -2.*y], - [ 2.*z, 0., 2.*y], - [ 0., 2.*y, 2.*z], - [ 0., 2.*x-4.*z, -2.*z], - [ -2.*z, -2.*y, x-y-z]]) - # Puts back d2V in first apex basis - d2V = d2V @ _extract_submatrices( - self.rotate_d2V, subtri, block_size=3, axis=0) - prod = self.M @ d2V - prod += _scalar_vectorized(E[:, 0, 0], self.M0 @ d2V) - prod += _scalar_vectorized(E[:, 1, 0], self.M1 @ d2V) - prod += _scalar_vectorized(E[:, 2, 0], self.M2 @ d2V) - d2sdksi2 = _roll_vectorized(prod, 3*subtri, axis=0) - return d2sdksi2 - - def get_bending_matrices(self, J, ecc): - """ - Parameters - ---------- - *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at - triangle first apex) - *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle - eccentricities - - Returns - ------- - Returns the element K matrices for bending energy expressed in - GLOBAL nodal coordinates. - K_ij = integral [ (d2zi/dx2 + d2zi/dy2) * (d2zj/dx2 + d2zj/dy2) dA] - tri_J is needed to rotate dofs from local basis to global basis - """ - n = np.size(ecc, 0) - - # 1) matrix to rotate dofs in global coordinates - J1 = self.J0_to_J1 @ J - J2 = self.J0_to_J2 @ J - DOF_rot = np.zeros([n, 9, 9], dtype=np.float64) - DOF_rot[:, 0, 0] = 1 - DOF_rot[:, 3, 3] = 1 - DOF_rot[:, 6, 6] = 1 - DOF_rot[:, 1:3, 1:3] = J - DOF_rot[:, 4:6, 4:6] = J1 - DOF_rot[:, 7:9, 7:9] = J2 - - # 2) matrix to rotate Hessian in global coordinates. - H_rot, area = self.get_Hrot_from_J(J, return_area=True) - - # 3) Computes stiffness matrix - # Gauss quadrature. - K = np.zeros([n, 9, 9], dtype=np.float64) - weights = self.gauss_w - pts = self.gauss_pts - for igauss in range(self.n_gauss): - alpha = np.tile(pts[igauss, :], n).reshape(n, 3) - alpha = np.expand_dims(alpha, 2) - weight = weights[igauss] - d2Skdksi2 = self.get_d2Sidksij2(alpha, ecc) - d2Skdx2 = d2Skdksi2 @ H_rot - K += weight * (d2Skdx2 @ self.E @ _transpose_vectorized(d2Skdx2)) - - # 4) With nodal (not elem) dofs - K = _transpose_vectorized(DOF_rot) @ K @ DOF_rot - - # 5) Need the area to compute total element energy - return _scalar_vectorized(area, K) - - def get_Hrot_from_J(self, J, return_area=False): - """ - Parameters - ---------- - *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at - triangle first apex) - - Returns - ------- - Returns H_rot used to rotate Hessian from local basis of first apex, - to global coordinates. - if *return_area* is True, returns also the triangle area (0.5*det(J)) - """ - # Here we try to deal with the simplest colinear cases; a null - # energy and area is imposed. - J_inv = _safe_inv22_vectorized(J) - Ji00 = J_inv[:, 0, 0] - Ji11 = J_inv[:, 1, 1] - Ji10 = J_inv[:, 1, 0] - Ji01 = J_inv[:, 0, 1] - H_rot = _to_matrix_vectorized([ - [Ji00*Ji00, Ji10*Ji10, Ji00*Ji10], - [Ji01*Ji01, Ji11*Ji11, Ji01*Ji11], - [2*Ji00*Ji01, 2*Ji11*Ji10, Ji00*Ji11+Ji10*Ji01]]) - if not return_area: - return H_rot - else: - area = 0.5 * (J[:, 0, 0]*J[:, 1, 1] - J[:, 0, 1]*J[:, 1, 0]) - return H_rot, area - - def get_Kff_and_Ff(self, J, ecc, triangles, Uc): - """ - Build K and F for the following elliptic formulation: - minimization of curvature energy with value of function at node - imposed and derivatives 'free'. - - Build the global Kff matrix in cco format. - Build the full Ff vec Ff = - Kfc x Uc. - - Parameters - ---------- - *J* is a (N x 2 x 2) array of jacobian matrices (jacobian matrix at - triangle first apex) - *ecc* is a (N x 3 x 1) array (array of column-matrices) of triangle - eccentricities - *triangles* is a (N x 3) array of nodes indexes. - *Uc* is (N x 3) array of imposed displacements at nodes - - Returns - ------- - (Kff_rows, Kff_cols, Kff_vals) Kff matrix in coo format - Duplicate - (row, col) entries must be summed. - Ff: force vector - dim npts * 3 - """ - ntri = np.size(ecc, 0) - vec_range = np.arange(ntri, dtype=np.int32) - c_indices = np.full(ntri, -1, dtype=np.int32) # for unused dofs, -1 - f_dof = [1, 2, 4, 5, 7, 8] - c_dof = [0, 3, 6] - - # vals, rows and cols indices in global dof numbering - f_dof_indices = _to_matrix_vectorized([[ - c_indices, triangles[:, 0]*2, triangles[:, 0]*2+1, - c_indices, triangles[:, 1]*2, triangles[:, 1]*2+1, - c_indices, triangles[:, 2]*2, triangles[:, 2]*2+1]]) - - expand_indices = np.ones([ntri, 9, 1], dtype=np.int32) - f_row_indices = _transpose_vectorized(expand_indices @ f_dof_indices) - f_col_indices = expand_indices @ f_dof_indices - K_elem = self.get_bending_matrices(J, ecc) - - # Extracting sub-matrices - # Explanation & notations: - # * Subscript f denotes 'free' degrees of freedom (i.e. dz/dx, dz/dx) - # * Subscript c denotes 'condensated' (imposed) degrees of freedom - # (i.e. z at all nodes) - # * F = [Ff, Fc] is the force vector - # * U = [Uf, Uc] is the imposed dof vector - # [ Kff Kfc ] - # * K = [ ] is the laplacian stiffness matrix - # [ Kcf Kff ] - # * As F = K x U one gets straightforwardly: Ff = - Kfc x Uc - - # Computing Kff stiffness matrix in sparse coo format - Kff_vals = np.ravel(K_elem[np.ix_(vec_range, f_dof, f_dof)]) - Kff_rows = np.ravel(f_row_indices[np.ix_(vec_range, f_dof, f_dof)]) - Kff_cols = np.ravel(f_col_indices[np.ix_(vec_range, f_dof, f_dof)]) - - # Computing Ff force vector in sparse coo format - Kfc_elem = K_elem[np.ix_(vec_range, f_dof, c_dof)] - Uc_elem = np.expand_dims(Uc, axis=2) - Ff_elem = -(Kfc_elem @ Uc_elem)[:, :, 0] - Ff_indices = f_dof_indices[np.ix_(vec_range, [0], f_dof)][:, 0, :] - - # Extracting Ff force vector in dense format - # We have to sum duplicate indices - using bincount - Ff = np.bincount(np.ravel(Ff_indices), weights=np.ravel(Ff_elem)) - return Kff_rows, Kff_cols, Kff_vals, Ff - - -# :class:_DOF_estimator, _DOF_estimator_user, _DOF_estimator_geom, -# _DOF_estimator_min_E -# Private classes used to compute the degree of freedom of each triangular -# element for the TriCubicInterpolator. -class _DOF_estimator: - """ - Abstract base class for classes used to estimate a function's first - derivatives, and deduce the dofs for a CubicTriInterpolator using a - reduced HCT element formulation. - - Derived classes implement ``compute_df(self, **kwargs)``, returning - ``np.vstack([dfx, dfy]).T`` where ``dfx, dfy`` are the estimation of the 2 - gradient coordinates. - """ - def __init__(self, interpolator, **kwargs): - _api.check_isinstance(CubicTriInterpolator, interpolator=interpolator) - self._pts = interpolator._pts - self._tris_pts = interpolator._tris_pts - self.z = interpolator._z - self._triangles = interpolator._triangles - (self._unit_x, self._unit_y) = (interpolator._unit_x, - interpolator._unit_y) - self.dz = self.compute_dz(**kwargs) - self.compute_dof_from_df() - - def compute_dz(self, **kwargs): - raise NotImplementedError - - def compute_dof_from_df(self): - """ - Compute reduced-HCT elements degrees of freedom, from the gradient. - """ - J = CubicTriInterpolator._get_jacobian(self._tris_pts) - tri_z = self.z[self._triangles] - tri_dz = self.dz[self._triangles] - tri_dof = self.get_dof_vec(tri_z, tri_dz, J) - return tri_dof - - @staticmethod - def get_dof_vec(tri_z, tri_dz, J): - """ - Compute the dof vector of a triangle, from the value of f, df and - of the local Jacobian at each node. - - Parameters - ---------- - tri_z : shape (3,) array - f nodal values. - tri_dz : shape (3, 2) array - df/dx, df/dy nodal values. - J - Jacobian matrix in local basis of apex 0. - - Returns - ------- - dof : shape (9,) array - For each apex ``iapex``:: - - dof[iapex*3+0] = f(Ai) - dof[iapex*3+1] = df(Ai).(AiAi+) - dof[iapex*3+2] = df(Ai).(AiAi-) - """ - npt = tri_z.shape[0] - dof = np.zeros([npt, 9], dtype=np.float64) - J1 = _ReducedHCT_Element.J0_to_J1 @ J - J2 = _ReducedHCT_Element.J0_to_J2 @ J - - col0 = J @ np.expand_dims(tri_dz[:, 0, :], axis=2) - col1 = J1 @ np.expand_dims(tri_dz[:, 1, :], axis=2) - col2 = J2 @ np.expand_dims(tri_dz[:, 2, :], axis=2) - - dfdksi = _to_matrix_vectorized([ - [col0[:, 0, 0], col1[:, 0, 0], col2[:, 0, 0]], - [col0[:, 1, 0], col1[:, 1, 0], col2[:, 1, 0]]]) - dof[:, 0:7:3] = tri_z - dof[:, 1:8:3] = dfdksi[:, 0] - dof[:, 2:9:3] = dfdksi[:, 1] - return dof - - -class _DOF_estimator_user(_DOF_estimator): - """dz is imposed by user; accounts for scaling if any.""" - - def compute_dz(self, dz): - (dzdx, dzdy) = dz - dzdx = dzdx * self._unit_x - dzdy = dzdy * self._unit_y - return np.vstack([dzdx, dzdy]).T - - -class _DOF_estimator_geom(_DOF_estimator): - """Fast 'geometric' approximation, recommended for large arrays.""" - - def compute_dz(self): - """ - self.df is computed as weighted average of _triangles sharing a common - node. On each triangle itri f is first assumed linear (= ~f), which - allows to compute d~f[itri] - Then the following approximation of df nodal values is then proposed: - f[ipt] = SUM ( w[itri] x d~f[itri] , for itri sharing apex ipt) - The weighted coeff. w[itri] are proportional to the angle of the - triangle itri at apex ipt - """ - el_geom_w = self.compute_geom_weights() - el_geom_grad = self.compute_geom_grads() - - # Sum of weights coeffs - w_node_sum = np.bincount(np.ravel(self._triangles), - weights=np.ravel(el_geom_w)) - - # Sum of weighted df = (dfx, dfy) - dfx_el_w = np.empty_like(el_geom_w) - dfy_el_w = np.empty_like(el_geom_w) - for iapex in range(3): - dfx_el_w[:, iapex] = el_geom_w[:, iapex]*el_geom_grad[:, 0] - dfy_el_w[:, iapex] = el_geom_w[:, iapex]*el_geom_grad[:, 1] - dfx_node_sum = np.bincount(np.ravel(self._triangles), - weights=np.ravel(dfx_el_w)) - dfy_node_sum = np.bincount(np.ravel(self._triangles), - weights=np.ravel(dfy_el_w)) - - # Estimation of df - dfx_estim = dfx_node_sum/w_node_sum - dfy_estim = dfy_node_sum/w_node_sum - return np.vstack([dfx_estim, dfy_estim]).T - - def compute_geom_weights(self): - """ - Build the (nelems, 3) weights coeffs of _triangles angles, - renormalized so that np.sum(weights, axis=1) == np.ones(nelems) - """ - weights = np.zeros([np.size(self._triangles, 0), 3]) - tris_pts = self._tris_pts - for ipt in range(3): - p0 = tris_pts[:, ipt % 3, :] - p1 = tris_pts[:, (ipt+1) % 3, :] - p2 = tris_pts[:, (ipt-1) % 3, :] - alpha1 = np.arctan2(p1[:, 1]-p0[:, 1], p1[:, 0]-p0[:, 0]) - alpha2 = np.arctan2(p2[:, 1]-p0[:, 1], p2[:, 0]-p0[:, 0]) - # In the below formula we could take modulo 2. but - # modulo 1. is safer regarding round-off errors (flat triangles). - angle = np.abs(((alpha2-alpha1) / np.pi) % 1) - # Weight proportional to angle up np.pi/2; null weight for - # degenerated cases 0 and np.pi (note that *angle* is normalized - # by np.pi). - weights[:, ipt] = 0.5 - np.abs(angle-0.5) - return weights - - def compute_geom_grads(self): - """ - Compute the (global) gradient component of f assumed linear (~f). - returns array df of shape (nelems, 2) - df[ielem].dM[ielem] = dz[ielem] i.e. df = dz x dM = dM.T^-1 x dz - """ - tris_pts = self._tris_pts - tris_f = self.z[self._triangles] - - dM1 = tris_pts[:, 1, :] - tris_pts[:, 0, :] - dM2 = tris_pts[:, 2, :] - tris_pts[:, 0, :] - dM = np.dstack([dM1, dM2]) - # Here we try to deal with the simplest colinear cases: a null - # gradient is assumed in this case. - dM_inv = _safe_inv22_vectorized(dM) - - dZ1 = tris_f[:, 1] - tris_f[:, 0] - dZ2 = tris_f[:, 2] - tris_f[:, 0] - dZ = np.vstack([dZ1, dZ2]).T - df = np.empty_like(dZ) - - # With np.einsum: could be ej,eji -> ej - df[:, 0] = dZ[:, 0]*dM_inv[:, 0, 0] + dZ[:, 1]*dM_inv[:, 1, 0] - df[:, 1] = dZ[:, 0]*dM_inv[:, 0, 1] + dZ[:, 1]*dM_inv[:, 1, 1] - return df - - -class _DOF_estimator_min_E(_DOF_estimator_geom): - """ - The 'smoothest' approximation, df is computed through global minimization - of the bending energy: - E(f) = integral[(d2z/dx2 + d2z/dy2 + 2 d2z/dxdy)**2 dA] - """ - def __init__(self, Interpolator): - self._eccs = Interpolator._eccs - super().__init__(Interpolator) - - def compute_dz(self): - """ - Elliptic solver for bending energy minimization. - Uses a dedicated 'toy' sparse Jacobi PCG solver. - """ - # Initial guess for iterative PCG solver. - dz_init = super().compute_dz() - Uf0 = np.ravel(dz_init) - - reference_element = _ReducedHCT_Element() - J = CubicTriInterpolator._get_jacobian(self._tris_pts) - eccs = self._eccs - triangles = self._triangles - Uc = self.z[self._triangles] - - # Building stiffness matrix and force vector in coo format - Kff_rows, Kff_cols, Kff_vals, Ff = reference_element.get_Kff_and_Ff( - J, eccs, triangles, Uc) - - # Building sparse matrix and solving minimization problem - # We could use scipy.sparse direct solver; however to avoid this - # external dependency an implementation of a simple PCG solver with - # a simple diagonal Jacobi preconditioner is implemented. - tol = 1.e-10 - n_dof = Ff.shape[0] - Kff_coo = _Sparse_Matrix_coo(Kff_vals, Kff_rows, Kff_cols, - shape=(n_dof, n_dof)) - Kff_coo.compress_csc() - Uf, err = _cg(A=Kff_coo, b=Ff, x0=Uf0, tol=tol) - # If the PCG did not converge, we return the best guess between Uf0 - # and Uf. - err0 = np.linalg.norm(Kff_coo.dot(Uf0) - Ff) - if err0 < err: - # Maybe a good occasion to raise a warning here ? - _api.warn_external("In TriCubicInterpolator initialization, " - "PCG sparse solver did not converge after " - "1000 iterations. `geom` approximation is " - "used instead of `min_E`") - Uf = Uf0 - - # Building dz from Uf - dz = np.empty([self._pts.shape[0], 2], dtype=np.float64) - dz[:, 0] = Uf[::2] - dz[:, 1] = Uf[1::2] - return dz - - -# The following private :class:_Sparse_Matrix_coo and :func:_cg provide -# a PCG sparse solver for (symmetric) elliptic problems. -class _Sparse_Matrix_coo: - def __init__(self, vals, rows, cols, shape): - """ - Create a sparse matrix in coo format. - *vals*: arrays of values of non-null entries of the matrix - *rows*: int arrays of rows of non-null entries of the matrix - *cols*: int arrays of cols of non-null entries of the matrix - *shape*: 2-tuple (n, m) of matrix shape - """ - self.n, self.m = shape - self.vals = np.asarray(vals, dtype=np.float64) - self.rows = np.asarray(rows, dtype=np.int32) - self.cols = np.asarray(cols, dtype=np.int32) - - def dot(self, V): - """ - Dot product of self by a vector *V* in sparse-dense to dense format - *V* dense vector of shape (self.m,). - """ - assert V.shape == (self.m,) - return np.bincount(self.rows, - weights=self.vals*V[self.cols], - minlength=self.m) - - def compress_csc(self): - """ - Compress rows, cols, vals / summing duplicates. Sort for csc format. - """ - _, unique, indices = np.unique( - self.rows + self.n*self.cols, - return_index=True, return_inverse=True) - self.rows = self.rows[unique] - self.cols = self.cols[unique] - self.vals = np.bincount(indices, weights=self.vals) - - def compress_csr(self): - """ - Compress rows, cols, vals / summing duplicates. Sort for csr format. - """ - _, unique, indices = np.unique( - self.m*self.rows + self.cols, - return_index=True, return_inverse=True) - self.rows = self.rows[unique] - self.cols = self.cols[unique] - self.vals = np.bincount(indices, weights=self.vals) - - def to_dense(self): - """ - Return a dense matrix representing self, mainly for debugging purposes. - """ - ret = np.zeros([self.n, self.m], dtype=np.float64) - nvals = self.vals.size - for i in range(nvals): - ret[self.rows[i], self.cols[i]] += self.vals[i] - return ret - - def __str__(self): - return self.to_dense().__str__() - - @property - def diag(self): - """Return the (dense) vector of the diagonal elements.""" - in_diag = (self.rows == self.cols) - diag = np.zeros(min(self.n, self.n), dtype=np.float64) # default 0. - diag[self.rows[in_diag]] = self.vals[in_diag] - return diag - - -def _cg(A, b, x0=None, tol=1.e-10, maxiter=1000): - """ - Use Preconditioned Conjugate Gradient iteration to solve A x = b - A simple Jacobi (diagonal) preconditionner is used. - - Parameters - ---------- - A : _Sparse_Matrix_coo - *A* must have been compressed before by compress_csc or - compress_csr method. - b : array - Right hand side of the linear system. - x0 : array, optional - Starting guess for the solution. Defaults to the zero vector. - tol : float, optional - Tolerance to achieve. The algorithm terminates when the relative - residual is below tol. Default is 1e-10. - maxiter : int, optional - Maximum number of iterations. Iteration will stop after *maxiter* - steps even if the specified tolerance has not been achieved. Defaults - to 1000. - - Returns - ------- - x : array - The converged solution. - err : float - The absolute error np.linalg.norm(A.dot(x) - b) - """ - n = b.size - assert A.n == n - assert A.m == n - b_norm = np.linalg.norm(b) - - # Jacobi pre-conditioner - kvec = A.diag - # For diag elem < 1e-6 we keep 1e-6. - kvec = np.maximum(kvec, 1e-6) - - # Initial guess - if x0 is None: - x = np.zeros(n) - else: - x = x0 - - r = b - A.dot(x) - w = r/kvec - - p = np.zeros(n) - beta = 0.0 - rho = np.dot(r, w) - k = 0 - - # Following C. T. Kelley - while (np.sqrt(abs(rho)) > tol*b_norm) and (k < maxiter): - p = w + beta*p - z = A.dot(p) - alpha = rho/np.dot(p, z) - r = r - alpha*z - w = r/kvec - rhoold = rho - rho = np.dot(r, w) - x = x + alpha*p - beta = rho/rhoold - # err = np.linalg.norm(A.dot(x) - b) # absolute accuracy - not used - k += 1 - err = np.linalg.norm(A.dot(x) - b) - return x, err - - -# The following private functions: -# :func:`_safe_inv22_vectorized` -# :func:`_pseudo_inv22sym_vectorized` -# :func:`_scalar_vectorized` -# :func:`_transpose_vectorized` -# :func:`_roll_vectorized` -# :func:`_to_matrix_vectorized` -# :func:`_extract_submatrices` -# provide fast numpy implementation of some standard operations on arrays of -# matrices - stored as (:, n_rows, n_cols)-shaped np.arrays. - -# Development note: Dealing with pathologic 'flat' triangles in the -# CubicTriInterpolator code and impact on (2, 2)-matrix inversion functions -# :func:`_safe_inv22_vectorized` and :func:`_pseudo_inv22sym_vectorized`. -# -# Goals: -# 1) The CubicTriInterpolator should be able to handle flat or almost flat -# triangles without raising an error, -# 2) These degenerated triangles should have no impact on the automatic dof -# calculation (associated with null weight for the _DOF_estimator_geom and -# with null energy for the _DOF_estimator_min_E), -# 3) Linear patch test should be passed exactly on degenerated meshes, -# 4) Interpolation (with :meth:`_interpolate_single_key` or -# :meth:`_interpolate_multi_key`) shall be correctly handled even *inside* -# the pathologic triangles, to interact correctly with a TriRefiner class. -# -# Difficulties: -# Flat triangles have rank-deficient *J* (so-called jacobian matrix) and -# *metric* (the metric tensor = J x J.T). Computation of the local -# tangent plane is also problematic. -# -# Implementation: -# Most of the time, when computing the inverse of a rank-deficient matrix it -# is safe to simply return the null matrix (which is the implementation in -# :func:`_safe_inv22_vectorized`). This is because of point 2), itself -# enforced by: -# - null area hence null energy in :class:`_DOF_estimator_min_E` -# - angles close or equal to 0 or np.pi hence null weight in -# :class:`_DOF_estimator_geom`. -# Note that the function angle -> weight is continuous and maximum for an -# angle np.pi/2 (refer to :meth:`compute_geom_weights`) -# The exception is the computation of barycentric coordinates, which is done -# by inversion of the *metric* matrix. In this case, we need to compute a set -# of valid coordinates (1 among numerous possibilities), to ensure point 4). -# We benefit here from the symmetry of metric = J x J.T, which makes it easier -# to compute a pseudo-inverse in :func:`_pseudo_inv22sym_vectorized` -def _safe_inv22_vectorized(M): - """ - Inversion of arrays of (2, 2) matrices, returns 0 for rank-deficient - matrices. - - *M* : array of (2, 2) matrices to inverse, shape (n, 2, 2) - """ - assert M.ndim == 3 - assert M.shape[-2:] == (2, 2) - M_inv = np.empty_like(M) - prod1 = M[:, 0, 0]*M[:, 1, 1] - delta = prod1 - M[:, 0, 1]*M[:, 1, 0] - - # We set delta_inv to 0. in case of a rank deficient matrix; a - # rank-deficient input matrix *M* will lead to a null matrix in output - rank2 = (np.abs(delta) > 1e-8*np.abs(prod1)) - if np.all(rank2): - # Normal 'optimized' flow. - delta_inv = 1./delta - else: - # 'Pathologic' flow. - delta_inv = np.zeros(M.shape[0]) - delta_inv[rank2] = 1./delta[rank2] - - M_inv[:, 0, 0] = M[:, 1, 1]*delta_inv - M_inv[:, 0, 1] = -M[:, 0, 1]*delta_inv - M_inv[:, 1, 0] = -M[:, 1, 0]*delta_inv - M_inv[:, 1, 1] = M[:, 0, 0]*delta_inv - return M_inv - - -def _pseudo_inv22sym_vectorized(M): - """ - Inversion of arrays of (2, 2) SYMMETRIC matrices; returns the - (Moore-Penrose) pseudo-inverse for rank-deficient matrices. - - In case M is of rank 1, we have M = trace(M) x P where P is the orthogonal - projection on Im(M), and we return trace(M)^-1 x P == M / trace(M)**2 - In case M is of rank 0, we return the null matrix. - - *M* : array of (2, 2) matrices to inverse, shape (n, 2, 2) - """ - assert M.ndim == 3 - assert M.shape[-2:] == (2, 2) - M_inv = np.empty_like(M) - prod1 = M[:, 0, 0]*M[:, 1, 1] - delta = prod1 - M[:, 0, 1]*M[:, 1, 0] - rank2 = (np.abs(delta) > 1e-8*np.abs(prod1)) - - if np.all(rank2): - # Normal 'optimized' flow. - M_inv[:, 0, 0] = M[:, 1, 1] / delta - M_inv[:, 0, 1] = -M[:, 0, 1] / delta - M_inv[:, 1, 0] = -M[:, 1, 0] / delta - M_inv[:, 1, 1] = M[:, 0, 0] / delta - else: - # 'Pathologic' flow. - # Here we have to deal with 2 sub-cases - # 1) First sub-case: matrices of rank 2: - delta = delta[rank2] - M_inv[rank2, 0, 0] = M[rank2, 1, 1] / delta - M_inv[rank2, 0, 1] = -M[rank2, 0, 1] / delta - M_inv[rank2, 1, 0] = -M[rank2, 1, 0] / delta - M_inv[rank2, 1, 1] = M[rank2, 0, 0] / delta - # 2) Second sub-case: rank-deficient matrices of rank 0 and 1: - rank01 = ~rank2 - tr = M[rank01, 0, 0] + M[rank01, 1, 1] - tr_zeros = (np.abs(tr) < 1.e-8) - sq_tr_inv = (1.-tr_zeros) / (tr**2+tr_zeros) - # sq_tr_inv = 1. / tr**2 - M_inv[rank01, 0, 0] = M[rank01, 0, 0] * sq_tr_inv - M_inv[rank01, 0, 1] = M[rank01, 0, 1] * sq_tr_inv - M_inv[rank01, 1, 0] = M[rank01, 1, 0] * sq_tr_inv - M_inv[rank01, 1, 1] = M[rank01, 1, 1] * sq_tr_inv - - return M_inv - - -def _scalar_vectorized(scalar, M): - """ - Scalar product between scalars and matrices. - """ - return scalar[:, np.newaxis, np.newaxis]*M - - -def _transpose_vectorized(M): - """ - Transposition of an array of matrices *M*. - """ - return np.transpose(M, [0, 2, 1]) - - -def _roll_vectorized(M, roll_indices, axis): - """ - Roll an array of matrices along *axis* (0: rows, 1: columns) according to - an array of indices *roll_indices*. - """ - assert axis in [0, 1] - ndim = M.ndim - assert ndim == 3 - ndim_roll = roll_indices.ndim - assert ndim_roll == 1 - sh = M.shape - r, c = sh[-2:] - assert sh[0] == roll_indices.shape[0] - vec_indices = np.arange(sh[0], dtype=np.int32) - - # Builds the rolled matrix - M_roll = np.empty_like(M) - if axis == 0: - for ir in range(r): - for ic in range(c): - M_roll[:, ir, ic] = M[vec_indices, (-roll_indices+ir) % r, ic] - elif axis == 1: - for ir in range(r): - for ic in range(c): - M_roll[:, ir, ic] = M[vec_indices, ir, (-roll_indices+ic) % c] - return M_roll - - -def _to_matrix_vectorized(M): - """ - Build an array of matrices from individuals np.arrays of identical shapes. - - Parameters - ---------- - M - ncols-list of nrows-lists of shape sh. - - Returns - ------- - M_res : np.array of shape (sh, nrow, ncols) - *M_res* satisfies ``M_res[..., i, j] = M[i][j]``. - """ - assert isinstance(M, (tuple, list)) - assert all(isinstance(item, (tuple, list)) for item in M) - c_vec = np.asarray([len(item) for item in M]) - assert np.all(c_vec-c_vec[0] == 0) - r = len(M) - c = c_vec[0] - M00 = np.asarray(M[0][0]) - dt = M00.dtype - sh = [M00.shape[0], r, c] - M_ret = np.empty(sh, dtype=dt) - for irow in range(r): - for icol in range(c): - M_ret[:, irow, icol] = np.asarray(M[irow][icol]) - return M_ret - - -def _extract_submatrices(M, block_indices, block_size, axis): - """ - Extract selected blocks of a matrices *M* depending on parameters - *block_indices* and *block_size*. - - Returns the array of extracted matrices *Mres* so that :: - - M_res[..., ir, :] = M[(block_indices*block_size+ir), :] - """ - assert block_indices.ndim == 1 - assert axis in [0, 1] - - r, c = M.shape - if axis == 0: - sh = [block_indices.shape[0], block_size, c] - elif axis == 1: - sh = [block_indices.shape[0], r, block_size] - dt = M.dtype - M_res = np.empty(sh, dtype=dt) - if axis == 0: - for ir in range(block_size): - M_res[:, ir, :] = M[(block_indices*block_size+ir), :] - elif axis == 1: - for ic in range(block_size): - M_res[:, :, ic] = M[:, (block_indices*block_size+ic)] - return M_res +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/tripcolor.py b/lib/matplotlib/tri/tripcolor.py index f1f7de2285dc..0da87891810d 100644 --- a/lib/matplotlib/tri/tripcolor.py +++ b/lib/matplotlib/tri/tripcolor.py @@ -1,131 +1,9 @@ -import numpy as np - +from ._tripcolor import * # noqa: F401, F403 from matplotlib import _api -from matplotlib.collections import PolyCollection, TriMesh -from matplotlib.colors import Normalize -from matplotlib.tri.triangulation import Triangulation - - -def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None, - vmax=None, shading='flat', facecolors=None, **kwargs): - """ - Create a pseudocolor plot of an unstructured triangular grid. - - The triangulation can be specified in one of two ways; either:: - - tripcolor(triangulation, ...) - - where triangulation is a `.Triangulation` object, or - - :: - - tripcolor(x, y, ...) - tripcolor(x, y, triangles, ...) - tripcolor(x, y, triangles=triangles, ...) - tripcolor(x, y, mask=mask, ...) - tripcolor(x, y, triangles, mask=mask, ...) - - in which case a Triangulation object will be created. See `.Triangulation` - for a explanation of these possibilities. - - The next argument must be *C*, the array of color values, either - one per point in the triangulation if color values are defined at - points, or one per triangle in the triangulation if color values - are defined at triangles. If there are the same number of points - and triangles in the triangulation it is assumed that color - values are defined at points; to force the use of color values at - triangles use the kwarg ``facecolors=C`` instead of just ``C``. - - *shading* may be 'flat' (the default) or 'gouraud'. If *shading* - is 'flat' and C values are defined at points, the color values - used for each triangle are from the mean C of the triangle's - three points. If *shading* is 'gouraud' then color values must be - defined at points. - - The remaining kwargs are the same as for `~.Axes.pcolor`. - """ - _api.check_in_list(['flat', 'gouraud'], shading=shading) - - tri, args, kwargs = Triangulation.get_from_args_and_kwargs(*args, **kwargs) - - # C is the colors array defined at either points or faces (i.e. triangles). - # If facecolors is None, C are defined at points. - # If facecolors is not None, C are defined at faces. - if facecolors is not None: - C = facecolors - else: - C = np.asarray(args[0]) - - # If there are a different number of points and triangles in the - # triangulation, can omit facecolors kwarg as it is obvious from - # length of C whether it refers to points or faces. - # Do not do this for gouraud shading. - if (facecolors is None and len(C) == len(tri.triangles) and - len(C) != len(tri.x) and shading != 'gouraud'): - facecolors = C - - # Check length of C is OK. - if ((facecolors is None and len(C) != len(tri.x)) or - (facecolors is not None and len(C) != len(tri.triangles))): - raise ValueError('Length of color values array must be the same ' - 'as either the number of triangulation points ' - 'or triangles') - - # Handling of linewidths, shading, edgecolors and antialiased as - # in Axes.pcolor - linewidths = (0.25,) - if 'linewidth' in kwargs: - kwargs['linewidths'] = kwargs.pop('linewidth') - kwargs.setdefault('linewidths', linewidths) - - edgecolors = 'none' - if 'edgecolor' in kwargs: - kwargs['edgecolors'] = kwargs.pop('edgecolor') - ec = kwargs.setdefault('edgecolors', edgecolors) - - if 'antialiased' in kwargs: - kwargs['antialiaseds'] = kwargs.pop('antialiased') - if 'antialiaseds' not in kwargs and ec.lower() == "none": - kwargs['antialiaseds'] = False - - if shading == 'gouraud': - if facecolors is not None: - raise ValueError('Gouraud shading does not support the use ' - 'of facecolors kwarg') - if len(C) != len(tri.x): - raise ValueError('For gouraud shading, the length of color ' - 'values array must be the same as the ' - 'number of triangulation points') - collection = TriMesh(tri, **kwargs) - else: - # Vertices of triangles. - maskedTris = tri.get_masked_triangles() - verts = np.stack((tri.x[maskedTris], tri.y[maskedTris]), axis=-1) - - # Color values. - if facecolors is None: - # One color per triangle, the mean of the 3 vertex color values. - C = C[maskedTris].mean(axis=1) - elif tri.mask is not None: - # Remove color values of masked triangles. - C = C[~tri.mask] - - collection = PolyCollection(verts, **kwargs) - collection.set_alpha(alpha) - collection.set_array(C) - _api.check_isinstance((Normalize, None), norm=norm) - collection.set_cmap(cmap) - collection.set_norm(norm) - collection._scale_norm(norm, vmin, vmax) - ax.grid(False) - minx = tri.x.min() - maxx = tri.x.max() - miny = tri.y.min() - maxy = tri.y.max() - corners = (minx, miny), (maxx, maxy) - ax.update_datalim(corners) - ax.autoscale_view() - ax.add_collection(collection) - return collection +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/triplot.py b/lib/matplotlib/tri/triplot.py index 97e3725464e7..7c012b1a59e7 100644 --- a/lib/matplotlib/tri/triplot.py +++ b/lib/matplotlib/tri/triplot.py @@ -1,82 +1,9 @@ -import numpy as np -from matplotlib.tri.triangulation import Triangulation +from ._triplot import * # noqa: F401, F403 +from matplotlib import _api -def triplot(ax, *args, **kwargs): - """ - Draw a unstructured triangular grid as lines and/or markers. - - The triangulation to plot can be specified in one of two ways; either:: - - triplot(triangulation, ...) - - where triangulation is a `.Triangulation` object, or - - :: - - triplot(x, y, ...) - triplot(x, y, triangles, ...) - triplot(x, y, triangles=triangles, ...) - triplot(x, y, mask=mask, ...) - triplot(x, y, triangles, mask=mask, ...) - - in which case a Triangulation object will be created. See `.Triangulation` - for a explanation of these possibilities. - - The remaining args and kwargs are the same as for `~.Axes.plot`. - - Returns - ------- - lines : `~matplotlib.lines.Line2D` - The drawn triangles edges. - markers : `~matplotlib.lines.Line2D` - The drawn marker nodes. - """ - import matplotlib.axes - - tri, args, kwargs = Triangulation.get_from_args_and_kwargs(*args, **kwargs) - x, y, edges = (tri.x, tri.y, tri.edges) - - # Decode plot format string, e.g., 'ro-' - fmt = args[0] if args else "" - linestyle, marker, color = matplotlib.axes._base._process_plot_format(fmt) - - # Insert plot format string into a copy of kwargs (kwargs values prevail). - kw = kwargs.copy() - for key, val in zip(('linestyle', 'marker', 'color'), - (linestyle, marker, color)): - if val is not None: - kw[key] = kwargs.get(key, val) - - # Draw lines without markers. - # Note 1: If we drew markers here, most markers would be drawn more than - # once as they belong to several edges. - # Note 2: We insert nan values in the flattened edges arrays rather than - # plotting directly (triang.x[edges].T, triang.y[edges].T) - # as it considerably speeds-up code execution. - linestyle = kw['linestyle'] - kw_lines = { - **kw, - 'marker': 'None', # No marker to draw. - 'zorder': kw.get('zorder', 1), # Path default zorder is used. - } - if linestyle not in [None, 'None', '', ' ']: - tri_lines_x = np.insert(x[edges], 2, np.nan, axis=1) - tri_lines_y = np.insert(y[edges], 2, np.nan, axis=1) - tri_lines = ax.plot(tri_lines_x.ravel(), tri_lines_y.ravel(), - **kw_lines) - else: - tri_lines = ax.plot([], [], **kw_lines) - - # Draw markers separately. - marker = kw['marker'] - kw_markers = { - **kw, - 'linestyle': 'None', # No line to draw. - } - if marker not in [None, 'None', '', ' ']: - tri_markers = ax.plot(x, y, **kw_markers) - else: - tri_markers = ax.plot([], [], **kw_markers) - - return tri_lines + tri_markers +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/trirefine.py b/lib/matplotlib/tri/trirefine.py index 674ee211cf46..6f22f9e8d203 100644 --- a/lib/matplotlib/tri/trirefine.py +++ b/lib/matplotlib/tri/trirefine.py @@ -1,307 +1,9 @@ -""" -Mesh refinement for triangular grids. -""" - -import numpy as np - +from ._trirefine import * # noqa: F401, F403 from matplotlib import _api -from matplotlib.tri.triangulation import Triangulation -import matplotlib.tri.triinterpolate - - -class TriRefiner: - """ - Abstract base class for classes implementing mesh refinement. - - A TriRefiner encapsulates a Triangulation object and provides tools for - mesh refinement and interpolation. - - Derived classes must implement: - - - ``refine_triangulation(return_tri_index=False, **kwargs)`` , where - the optional keyword arguments *kwargs* are defined in each - TriRefiner concrete implementation, and which returns: - - - a refined triangulation, - - optionally (depending on *return_tri_index*), for each - point of the refined triangulation: the index of - the initial triangulation triangle to which it belongs. - - - ``refine_field(z, triinterpolator=None, **kwargs)``, where: - - - *z* array of field values (to refine) defined at the base - triangulation nodes, - - *triinterpolator* is an optional `~matplotlib.tri.TriInterpolator`, - - the other optional keyword arguments *kwargs* are defined in - each TriRefiner concrete implementation; - - and which returns (as a tuple) a refined triangular mesh and the - interpolated values of the field at the refined triangulation nodes. - """ - - def __init__(self, triangulation): - _api.check_isinstance(Triangulation, triangulation=triangulation) - self._triangulation = triangulation - - -class UniformTriRefiner(TriRefiner): - """ - Uniform mesh refinement by recursive subdivisions. - - Parameters - ---------- - triangulation : `~matplotlib.tri.Triangulation` - The encapsulated triangulation (to be refined) - """ -# See Also -# -------- -# :class:`~matplotlib.tri.CubicTriInterpolator` and -# :class:`~matplotlib.tri.TriAnalyzer`. -# """ - def __init__(self, triangulation): - super().__init__(triangulation) - - def refine_triangulation(self, return_tri_index=False, subdiv=3): - """ - Compute an uniformly refined triangulation *refi_triangulation* of - the encapsulated :attr:`triangulation`. - - This function refines the encapsulated triangulation by splitting each - father triangle into 4 child sub-triangles built on the edges midside - nodes, recursing *subdiv* times. In the end, each triangle is hence - divided into ``4**subdiv`` child triangles. - - Parameters - ---------- - return_tri_index : bool, default: False - Whether an index table indicating the father triangle index of each - point is returned. - subdiv : int, default: 3 - Recursion level for the subdivision. - Each triangle is divided into ``4**subdiv`` child triangles; - hence, the default results in 64 refined subtriangles for each - triangle of the initial triangulation. - - Returns - ------- - refi_triangulation : `~matplotlib.tri.Triangulation` - The refined triangulation. - found_index : int array - Index of the initial triangulation containing triangle, for each - point of *refi_triangulation*. - Returned only if *return_tri_index* is set to True. - """ - refi_triangulation = self._triangulation - ntri = refi_triangulation.triangles.shape[0] - - # Computes the triangulation ancestors numbers in the reference - # triangulation. - ancestors = np.arange(ntri, dtype=np.int32) - for _ in range(subdiv): - refi_triangulation, ancestors = self._refine_triangulation_once( - refi_triangulation, ancestors) - refi_npts = refi_triangulation.x.shape[0] - refi_triangles = refi_triangulation.triangles - - # Now we compute found_index table if needed - if return_tri_index: - # We have to initialize found_index with -1 because some nodes - # may very well belong to no triangle at all, e.g., in case of - # Delaunay Triangulation with DuplicatePointWarning. - found_index = np.full(refi_npts, -1, dtype=np.int32) - tri_mask = self._triangulation.mask - if tri_mask is None: - found_index[refi_triangles] = np.repeat(ancestors, - 3).reshape(-1, 3) - else: - # There is a subtlety here: we want to avoid whenever possible - # that refined points container is a masked triangle (which - # would result in artifacts in plots). - # So we impose the numbering from masked ancestors first, - # then overwrite it with unmasked ancestor numbers. - ancestor_mask = tri_mask[ancestors] - found_index[refi_triangles[ancestor_mask, :] - ] = np.repeat(ancestors[ancestor_mask], - 3).reshape(-1, 3) - found_index[refi_triangles[~ancestor_mask, :] - ] = np.repeat(ancestors[~ancestor_mask], - 3).reshape(-1, 3) - return refi_triangulation, found_index - else: - return refi_triangulation - - def refine_field(self, z, triinterpolator=None, subdiv=3): - """ - Refine a field defined on the encapsulated triangulation. - - Parameters - ---------- - z : (npoints,) array-like - Values of the field to refine, defined at the nodes of the - encapsulated triangulation. (``n_points`` is the number of points - in the initial triangulation) - triinterpolator : `~matplotlib.tri.TriInterpolator`, optional - Interpolator used for field interpolation. If not specified, - a `~matplotlib.tri.CubicTriInterpolator` will be used. - subdiv : int, default: 3 - Recursion level for the subdivision. - Each triangle is divided into ``4**subdiv`` child triangles. - - Returns - ------- - refi_tri : `~matplotlib.tri.Triangulation` - The returned refined triangulation. - refi_z : 1D array of length: *refi_tri* node count. - The returned interpolated field (at *refi_tri* nodes). - """ - if triinterpolator is None: - interp = matplotlib.tri.CubicTriInterpolator( - self._triangulation, z) - else: - _api.check_isinstance(matplotlib.tri.TriInterpolator, - triinterpolator=triinterpolator) - interp = triinterpolator - - refi_tri, found_index = self.refine_triangulation( - subdiv=subdiv, return_tri_index=True) - refi_z = interp._interpolate_multikeys( - refi_tri.x, refi_tri.y, tri_index=found_index)[0] - return refi_tri, refi_z - - @staticmethod - def _refine_triangulation_once(triangulation, ancestors=None): - """ - Refine a `.Triangulation` by splitting each triangle into 4 - child-masked_triangles built on the edges midside nodes. - - Masked triangles, if present, are also split, but their children - returned masked. - - If *ancestors* is not provided, returns only a new triangulation: - child_triangulation. - - If the array-like key table *ancestor* is given, it shall be of shape - (ntri,) where ntri is the number of *triangulation* masked_triangles. - In this case, the function returns - (child_triangulation, child_ancestors) - child_ancestors is defined so that the 4 child masked_triangles share - the same index as their father: child_ancestors.shape = (4 * ntri,). - """ - - x = triangulation.x - y = triangulation.y - - # According to tri.triangulation doc: - # neighbors[i, j] is the triangle that is the neighbor - # to the edge from point index masked_triangles[i, j] to point - # index masked_triangles[i, (j+1)%3]. - neighbors = triangulation.neighbors - triangles = triangulation.triangles - npts = np.shape(x)[0] - ntri = np.shape(triangles)[0] - if ancestors is not None: - ancestors = np.asarray(ancestors) - if np.shape(ancestors) != (ntri,): - raise ValueError( - "Incompatible shapes provide for triangulation" - ".masked_triangles and ancestors: {0} and {1}".format( - np.shape(triangles), np.shape(ancestors))) - - # Initiating tables refi_x and refi_y of the refined triangulation - # points - # hint: each apex is shared by 2 masked_triangles except the borders. - borders = np.sum(neighbors == -1) - added_pts = (3*ntri + borders) // 2 - refi_npts = npts + added_pts - refi_x = np.zeros(refi_npts) - refi_y = np.zeros(refi_npts) - - # First part of refi_x, refi_y is just the initial points - refi_x[:npts] = x - refi_y[:npts] = y - - # Second part contains the edge midside nodes. - # Each edge belongs to 1 triangle (if border edge) or is shared by 2 - # masked_triangles (interior edge). - # We first build 2 * ntri arrays of edge starting nodes (edge_elems, - # edge_apexes); we then extract only the masters to avoid overlaps. - # The so-called 'master' is the triangle with biggest index - # The 'slave' is the triangle with lower index - # (can be -1 if border edge) - # For slave and master we will identify the apex pointing to the edge - # start - edge_elems = np.tile(np.arange(ntri, dtype=np.int32), 3) - edge_apexes = np.repeat(np.arange(3, dtype=np.int32), ntri) - edge_neighbors = neighbors[edge_elems, edge_apexes] - mask_masters = (edge_elems > edge_neighbors) - - # Identifying the "masters" and adding to refi_x, refi_y vec - masters = edge_elems[mask_masters] - apex_masters = edge_apexes[mask_masters] - x_add = (x[triangles[masters, apex_masters]] + - x[triangles[masters, (apex_masters+1) % 3]]) * 0.5 - y_add = (y[triangles[masters, apex_masters]] + - y[triangles[masters, (apex_masters+1) % 3]]) * 0.5 - refi_x[npts:] = x_add - refi_y[npts:] = y_add - - # Building the new masked_triangles; each old masked_triangles hosts - # 4 new masked_triangles - # there are 6 pts to identify per 'old' triangle, 3 new_pt_corner and - # 3 new_pt_midside - new_pt_corner = triangles - - # What is the index in refi_x, refi_y of point at middle of apex iapex - # of elem ielem ? - # If ielem is the apex master: simple count, given the way refi_x was - # built. - # If ielem is the apex slave: yet we do not know; but we will soon - # using the neighbors table. - new_pt_midside = np.empty([ntri, 3], dtype=np.int32) - cum_sum = npts - for imid in range(3): - mask_st_loc = (imid == apex_masters) - n_masters_loc = np.sum(mask_st_loc) - elem_masters_loc = masters[mask_st_loc] - new_pt_midside[:, imid][elem_masters_loc] = np.arange( - n_masters_loc, dtype=np.int32) + cum_sum - cum_sum += n_masters_loc - - # Now dealing with slave elems. - # for each slave element we identify the master and then the inode - # once slave_masters is identified, slave_masters_apex is such that: - # neighbors[slaves_masters, slave_masters_apex] == slaves - mask_slaves = np.logical_not(mask_masters) - slaves = edge_elems[mask_slaves] - slaves_masters = edge_neighbors[mask_slaves] - diff_table = np.abs(neighbors[slaves_masters, :] - - np.outer(slaves, np.ones(3, dtype=np.int32))) - slave_masters_apex = np.argmin(diff_table, axis=1) - slaves_apex = edge_apexes[mask_slaves] - new_pt_midside[slaves, slaves_apex] = new_pt_midside[ - slaves_masters, slave_masters_apex] - - # Builds the 4 child masked_triangles - child_triangles = np.empty([ntri*4, 3], dtype=np.int32) - child_triangles[0::4, :] = np.vstack([ - new_pt_corner[:, 0], new_pt_midside[:, 0], - new_pt_midside[:, 2]]).T - child_triangles[1::4, :] = np.vstack([ - new_pt_corner[:, 1], new_pt_midside[:, 1], - new_pt_midside[:, 0]]).T - child_triangles[2::4, :] = np.vstack([ - new_pt_corner[:, 2], new_pt_midside[:, 2], - new_pt_midside[:, 1]]).T - child_triangles[3::4, :] = np.vstack([ - new_pt_midside[:, 0], new_pt_midside[:, 1], - new_pt_midside[:, 2]]).T - child_triangulation = Triangulation(refi_x, refi_y, child_triangles) - # Builds the child mask - if triangulation.mask is not None: - child_triangulation.set_mask(np.repeat(triangulation.mask, 4)) - if ancestors is None: - return child_triangulation - else: - return child_triangulation, np.repeat(ancestors, 4) +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/tri/tritools.py b/lib/matplotlib/tri/tritools.py index 11b500fcdd8f..9c6839ca2049 100644 --- a/lib/matplotlib/tri/tritools.py +++ b/lib/matplotlib/tri/tritools.py @@ -1,263 +1,9 @@ -""" -Tools for triangular grids. -""" - -import numpy as np - +from ._tritools import * # noqa: F401, F403 from matplotlib import _api -from matplotlib.tri import Triangulation - - -class TriAnalyzer: - """ - Define basic tools for triangular mesh analysis and improvement. - - A TriAnalyzer encapsulates a `.Triangulation` object and provides basic - tools for mesh analysis and mesh improvement. - - Attributes - ---------- - scale_factors - - Parameters - ---------- - triangulation : `~matplotlib.tri.Triangulation` - The encapsulated triangulation to analyze. - """ - - def __init__(self, triangulation): - _api.check_isinstance(Triangulation, triangulation=triangulation) - self._triangulation = triangulation - - @property - def scale_factors(self): - """ - Factors to rescale the triangulation into a unit square. - - Returns - ------- - (float, float) - Scaling factors (kx, ky) so that the triangulation - ``[triangulation.x * kx, triangulation.y * ky]`` - fits exactly inside a unit square. - """ - compressed_triangles = self._triangulation.get_masked_triangles() - node_used = (np.bincount(np.ravel(compressed_triangles), - minlength=self._triangulation.x.size) != 0) - return (1 / np.ptp(self._triangulation.x[node_used]), - 1 / np.ptp(self._triangulation.y[node_used])) - - def circle_ratios(self, rescale=True): - """ - Return a measure of the triangulation triangles flatness. - - The ratio of the incircle radius over the circumcircle radius is a - widely used indicator of a triangle flatness. - It is always ``<= 0.5`` and ``== 0.5`` only for equilateral - triangles. Circle ratios below 0.01 denote very flat triangles. - - To avoid unduly low values due to a difference of scale between the 2 - axis, the triangular mesh can first be rescaled to fit inside a unit - square with `scale_factors` (Only if *rescale* is True, which is - its default value). - - Parameters - ---------- - rescale : bool, default: True - If True, internally rescale (based on `scale_factors`), so that the - (unmasked) triangles fit exactly inside a unit square mesh. - - Returns - ------- - masked array - Ratio of the incircle radius over the circumcircle radius, for - each 'rescaled' triangle of the encapsulated triangulation. - Values corresponding to masked triangles are masked out. - - """ - # Coords rescaling - if rescale: - (kx, ky) = self.scale_factors - else: - (kx, ky) = (1.0, 1.0) - pts = np.vstack([self._triangulation.x*kx, - self._triangulation.y*ky]).T - tri_pts = pts[self._triangulation.triangles] - # Computes the 3 side lengths - a = tri_pts[:, 1, :] - tri_pts[:, 0, :] - b = tri_pts[:, 2, :] - tri_pts[:, 1, :] - c = tri_pts[:, 0, :] - tri_pts[:, 2, :] - a = np.hypot(a[:, 0], a[:, 1]) - b = np.hypot(b[:, 0], b[:, 1]) - c = np.hypot(c[:, 0], c[:, 1]) - # circumcircle and incircle radii - s = (a+b+c)*0.5 - prod = s*(a+b-s)*(a+c-s)*(b+c-s) - # We have to deal with flat triangles with infinite circum_radius - bool_flat = (prod == 0.) - if np.any(bool_flat): - # Pathologic flow - ntri = tri_pts.shape[0] - circum_radius = np.empty(ntri, dtype=np.float64) - circum_radius[bool_flat] = np.inf - abc = a*b*c - circum_radius[~bool_flat] = abc[~bool_flat] / ( - 4.0*np.sqrt(prod[~bool_flat])) - else: - # Normal optimized flow - circum_radius = (a*b*c) / (4.0*np.sqrt(prod)) - in_radius = (a*b*c) / (4.0*circum_radius*s) - circle_ratio = in_radius/circum_radius - mask = self._triangulation.mask - if mask is None: - return circle_ratio - else: - return np.ma.array(circle_ratio, mask=mask) - - def get_flat_tri_mask(self, min_circle_ratio=0.01, rescale=True): - """ - Eliminate excessively flat border triangles from the triangulation. - - Returns a mask *new_mask* which allows to clean the encapsulated - triangulation from its border-located flat triangles - (according to their :meth:`circle_ratios`). - This mask is meant to be subsequently applied to the triangulation - using `.Triangulation.set_mask`. - *new_mask* is an extension of the initial triangulation mask - in the sense that an initially masked triangle will remain masked. - - The *new_mask* array is computed recursively; at each step flat - triangles are removed only if they share a side with the current mesh - border. Thus no new holes in the triangulated domain will be created. - - Parameters - ---------- - min_circle_ratio : float, default: 0.01 - Border triangles with incircle/circumcircle radii ratio r/R will - be removed if r/R < *min_circle_ratio*. - rescale : bool, default: True - If True, first, internally rescale (based on `scale_factors`) so - that the (unmasked) triangles fit exactly inside a unit square - mesh. This rescaling accounts for the difference of scale which - might exist between the 2 axis. - - Returns - ------- - array of bool - Mask to apply to encapsulated triangulation. - All the initially masked triangles remain masked in the - *new_mask*. - - Notes - ----- - The rationale behind this function is that a Delaunay - triangulation - of an unstructured set of points - sometimes contains - almost flat triangles at its border, leading to artifacts in plots - (especially for high-resolution contouring). - Masked with computed *new_mask*, the encapsulated - triangulation would contain no more unmasked border triangles - with a circle ratio below *min_circle_ratio*, thus improving the - mesh quality for subsequent plots or interpolation. - """ - # Recursively computes the mask_current_borders, true if a triangle is - # at the border of the mesh OR touching the border through a chain of - # invalid aspect ratio masked_triangles. - ntri = self._triangulation.triangles.shape[0] - mask_bad_ratio = self.circle_ratios(rescale) < min_circle_ratio - - current_mask = self._triangulation.mask - if current_mask is None: - current_mask = np.zeros(ntri, dtype=bool) - valid_neighbors = np.copy(self._triangulation.neighbors) - renum_neighbors = np.arange(ntri, dtype=np.int32) - nadd = -1 - while nadd != 0: - # The active wavefront is the triangles from the border (unmasked - # but with a least 1 neighbor equal to -1 - wavefront = (np.min(valid_neighbors, axis=1) == -1) & ~current_mask - # The element from the active wavefront will be masked if their - # circle ratio is bad. - added_mask = wavefront & mask_bad_ratio - current_mask = added_mask | current_mask - nadd = np.sum(added_mask) - - # now we have to update the tables valid_neighbors - valid_neighbors[added_mask, :] = -1 - renum_neighbors[added_mask] = -1 - valid_neighbors = np.where(valid_neighbors == -1, -1, - renum_neighbors[valid_neighbors]) - - return np.ma.filled(current_mask, True) - - def _get_compressed_triangulation(self): - """ - Compress (if masked) the encapsulated triangulation. - - Returns minimal-length triangles array (*compressed_triangles*) and - coordinates arrays (*compressed_x*, *compressed_y*) that can still - describe the unmasked triangles of the encapsulated triangulation. - - Returns - ------- - compressed_triangles : array-like - the returned compressed triangulation triangles - compressed_x : array-like - the returned compressed triangulation 1st coordinate - compressed_y : array-like - the returned compressed triangulation 2nd coordinate - tri_renum : int array - renumbering table to translate the triangle numbers from the - encapsulated triangulation into the new (compressed) renumbering. - -1 for masked triangles (deleted from *compressed_triangles*). - node_renum : int array - renumbering table to translate the point numbers from the - encapsulated triangulation into the new (compressed) renumbering. - -1 for unused points (i.e. those deleted from *compressed_x* and - *compressed_y*). - - """ - # Valid triangles and renumbering - tri_mask = self._triangulation.mask - compressed_triangles = self._triangulation.get_masked_triangles() - ntri = self._triangulation.triangles.shape[0] - if tri_mask is not None: - tri_renum = self._total_to_compress_renum(~tri_mask) - else: - tri_renum = np.arange(ntri, dtype=np.int32) - - # Valid nodes and renumbering - valid_node = (np.bincount(np.ravel(compressed_triangles), - minlength=self._triangulation.x.size) != 0) - compressed_x = self._triangulation.x[valid_node] - compressed_y = self._triangulation.y[valid_node] - node_renum = self._total_to_compress_renum(valid_node) - - # Now renumbering the valid triangles nodes - compressed_triangles = node_renum[compressed_triangles] - - return (compressed_triangles, compressed_x, compressed_y, tri_renum, - node_renum) - - @staticmethod - def _total_to_compress_renum(valid): - """ - Parameters - ---------- - valid : 1D bool array - Validity mask. - Returns - ------- - int array - Array so that (`valid_array` being a compressed array - based on a `masked_array` with mask ~*valid*): - - For all i with valid[i] = True: - valid_array[renum[i]] = masked_array[i] - - For all i with valid[i] = False: - renum[i] = -1 (invalid value) - """ - renum = np.full(np.size(valid), -1, dtype=np.int32) - n_valid = np.sum(valid) - renum[valid] = np.arange(n_valid, dtype=np.int32) - return renum +_api.warn_deprecated( + "3.7", + message=f"Importing {__name__} was deprecated in Matplotlib 3.7 and will " + f"be removed two minor releases later. All functionality is " + f"available via the top-level module matplotlib.tri") diff --git a/lib/matplotlib/type1font.py b/lib/matplotlib/type1font.py index f417c0fc97a4..85d93c714dad 100644 --- a/lib/matplotlib/type1font.py +++ b/lib/matplotlib/type1font.py @@ -1,404 +1,3 @@ -""" -A class representing a Type 1 font. - -This version reads pfa and pfb files and splits them for embedding in -pdf files. It also supports SlantFont and ExtendFont transformations, -similarly to pdfTeX and friends. There is no support yet for subsetting. - -Usage:: - - >>> font = Type1Font(filename) - >>> clear_part, encrypted_part, finale = font.parts - >>> slanted_font = font.transform({'slant': 0.167}) - >>> extended_font = font.transform({'extend': 1.2}) - -Sources: - -* Adobe Technical Note #5040, Supporting Downloadable PostScript - Language Fonts. - -* Adobe Type 1 Font Format, Adobe Systems Incorporated, third printing, - v1.1, 1993. ISBN 0-201-57044-0. -""" - -import binascii -import enum -import itertools -import logging -import re -import struct - -import numpy as np - -from matplotlib.cbook import _format_approx -from . import _api - -_log = logging.getLogger(__name__) - -# token types -_TokenType = enum.Enum('_TokenType', - 'whitespace name string delimiter number') - - -class Type1Font: - """ - A class representing a Type-1 font, for use by backends. - - Attributes - ---------- - parts : tuple - A 3-tuple of the cleartext part, the encrypted part, and the finale of - zeros. - decrypted : bytes - The decrypted form of parts[1]. - prop : dict[str, Any] - A dictionary of font properties. - """ - __slots__ = ('parts', 'decrypted', 'prop') - - def __init__(self, input): - """ - Initialize a Type-1 font. - - Parameters - ---------- - input : str or 3-tuple - Either a pfb file name, or a 3-tuple of already-decoded Type-1 - font `~.Type1Font.parts`. - """ - if isinstance(input, tuple) and len(input) == 3: - self.parts = input - else: - with open(input, 'rb') as file: - data = self._read(file) - self.parts = self._split(data) - - self.decrypted = self._decrypt(self.parts[1], 'eexec') - self._parse() - - def _read(self, file): - """Read the font from a file, decoding into usable parts.""" - rawdata = file.read() - if not rawdata.startswith(b'\x80'): - return rawdata - - data = b'' - while rawdata: - if not rawdata.startswith(b'\x80'): - raise RuntimeError('Broken pfb file (expected byte 128, ' - 'got %d)' % rawdata[0]) - type = rawdata[1] - if type in (1, 2): - length, = struct.unpack('{}/%[]+') - _instring_re = re.compile(br'[()\\]') - - @staticmethod - def _decrypt(ciphertext, key, ndiscard=4): - """ - Decrypt ciphertext using the Type-1 font algorithm - - The algorithm is described in Adobe's "Adobe Type 1 Font Format". - The key argument can be an integer, or one of the strings - 'eexec' and 'charstring', which map to the key specified for the - corresponding part of Type-1 fonts. - - The ndiscard argument should be an integer, usually 4. - That number of bytes is discarded from the beginning of plaintext. - """ - - key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key) - plaintext = [] - for byte in ciphertext: - plaintext.append(byte ^ (key >> 8)) - key = ((key+byte) * 52845 + 22719) & 0xffff - - return bytes(plaintext[ndiscard:]) - - @staticmethod - def _encrypt(plaintext, key, ndiscard=4): - """ - Encrypt plaintext using the Type-1 font algorithm - - The algorithm is described in Adobe's "Adobe Type 1 Font Format". - The key argument can be an integer, or one of the strings - 'eexec' and 'charstring', which map to the key specified for the - corresponding part of Type-1 fonts. - - The ndiscard argument should be an integer, usually 4. That - number of bytes is prepended to the plaintext before encryption. - This function prepends NUL bytes for reproducibility, even though - the original algorithm uses random bytes, presumably to avoid - cryptanalysis. - """ - - key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key) - ciphertext = [] - for byte in b'\0' * ndiscard + plaintext: - c = byte ^ (key >> 8) - ciphertext.append(c) - key = ((key + c) * 52845 + 22719) & 0xffff - - return bytes(ciphertext) - - @classmethod - def _tokens(cls, text): - """ - A PostScript tokenizer. Yield (token, value) pairs such as - (_TokenType.whitespace, ' ') or (_TokenType.name, '/Foobar'). - """ - # Preload enum members for speed. - tok_whitespace = _TokenType.whitespace - tok_name = _TokenType.name - tok_string = _TokenType.string - tok_delimiter = _TokenType.delimiter - tok_number = _TokenType.number - pos = 0 - while pos < len(text): - match = cls._whitespace_or_comment_re.match(text, pos) - if match: - yield (tok_whitespace, match.group()) - pos = match.end() - elif text[pos:pos+1] == b'(': - start = pos - pos += 1 - depth = 1 - while depth: - match = cls._instring_re.search(text, pos) - if match is None: - return - pos = match.end() - if match.group() == b'(': - depth += 1 - elif match.group() == b')': - depth -= 1 - else: # a backslash - skip the next character - pos += 1 - yield (tok_string, text[start:pos]) - elif text[pos:pos + 2] in (b'<<', b'>>'): - yield (tok_delimiter, text[pos:pos + 2]) - pos += 2 - elif text[pos:pos+1] == b'<': - start = pos - pos = text.index(b'>', pos) - yield (tok_string, text[start:pos]) - else: - match = cls._token_re.match(text, pos) - if match: - try: - float(match.group()) - yield (tok_number, match.group()) - except ValueError: - yield (tok_name, match.group()) - pos = match.end() - else: - yield (tok_delimiter, text[pos:pos + 1]) - pos += 1 - - def _parse(self): - """ - Find the values of various font properties. This limited kind - of parsing is described in Chapter 10 "Adobe Type Manager - Compatibility" of the Type-1 spec. - """ - # Preload enum members for speed. - tok_whitespace = _TokenType.whitespace - tok_name = _TokenType.name - tok_string = _TokenType.string - tok_number = _TokenType.number - # Start with reasonable defaults - prop = {'weight': 'Regular', 'ItalicAngle': 0.0, 'isFixedPitch': False, - 'UnderlinePosition': -100, 'UnderlineThickness': 50} - filtered = ((token, value) - for token, value in self._tokens(self.parts[0]) - if token is not tok_whitespace) - # The spec calls this an ASCII format; in Python 2.x we could - # just treat the strings and names as opaque bytes but let's - # turn them into proper Unicode, and be lenient in case of high bytes. - def convert(x): return x.decode('ascii', 'replace') - for token, value in filtered: - if token is tok_name and value.startswith(b'/'): - key = convert(value[1:]) - token, value = next(filtered) - if token is tok_name: - if value in (b'true', b'false'): - value = value == b'true' - else: - value = convert(value.lstrip(b'/')) - elif token is tok_string: - value = convert(value.lstrip(b'(').rstrip(b')')) - elif token is tok_number: - if b'.' in value: - value = float(value) - else: - value = int(value) - else: # more complicated value such as an array - value = None - if key != 'FontInfo' and value is not None: - prop[key] = value - - # Fill in the various *Name properties - if 'FontName' not in prop: - prop['FontName'] = (prop.get('FullName') or - prop.get('FamilyName') or - 'Unknown') - if 'FullName' not in prop: - prop['FullName'] = prop['FontName'] - if 'FamilyName' not in prop: - extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' - '(ultra)?light|extra|condensed))+$') - prop['FamilyName'] = re.sub(extras, '', prop['FullName']) - - self.prop = prop - - @classmethod - def _transformer(cls, tokens, slant, extend): - tok_whitespace = _TokenType.whitespace - tok_name = _TokenType.name - - def fontname(name): - result = name - if slant: - result += b'_Slant_%d' % int(1000 * slant) - if extend != 1.0: - result += b'_Extend_%d' % int(1000 * extend) - return result - - def italicangle(angle): - return b'%a' % round( - float(angle) - np.arctan(slant) / np.pi * 180, - 5 - ) - - def fontmatrix(array): - array = array.lstrip(b'[').rstrip(b']').split() - array = [float(x) for x in array] - oldmatrix = np.eye(3, 3) - oldmatrix[0:3, 0] = array[::2] - oldmatrix[0:3, 1] = array[1::2] - modifier = np.array([[extend, 0, 0], - [slant, 1, 0], - [0, 0, 1]]) - newmatrix = np.dot(modifier, oldmatrix) - array[::2] = newmatrix[0:3, 0] - array[1::2] = newmatrix[0:3, 1] - return ( - '[%s]' % ' '.join(_format_approx(x, 6) for x in array) - ).encode('ascii') - - def replace(fun): - def replacer(tokens): - token, value = next(tokens) # name, e.g., /FontMatrix - yield value - token, value = next(tokens) # possible whitespace - while token is tok_whitespace: - yield value - token, value = next(tokens) - if value != b'[': # name/number/etc. - yield fun(value) - else: # array, e.g., [1 2 3] - result = b'' - while value != b']': - result += value - token, value = next(tokens) - result += value - yield fun(result) - return replacer - - def suppress(tokens): - for _ in itertools.takewhile(lambda x: x[1] != b'def', tokens): - pass - yield b'' - - table = {b'/FontName': replace(fontname), - b'/ItalicAngle': replace(italicangle), - b'/FontMatrix': replace(fontmatrix), - b'/UniqueID': suppress} - - for token, value in tokens: - if token is tok_name and value in table: - yield from table[value]( - itertools.chain([(token, value)], tokens)) - else: - yield value - - def transform(self, effects): - """ - Return a new font that is slanted and/or extended. - - Parameters - ---------- - effects : dict - A dict with optional entries: - - - 'slant' : float, default: 0 - Tangent of the angle that the font is to be slanted to the - right. Negative values slant to the left. - - 'extend' : float, default: 1 - Scaling factor for the font width. Values less than 1 condense - the glyphs. - - Returns - ------- - `Type1Font` - """ - tokenizer = self._tokens(self.parts[0]) - transformed = self._transformer(tokenizer, - slant=effects.get('slant', 0.0), - extend=effects.get('extend', 1.0)) - return Type1Font((b"".join(transformed), self.parts[1], self.parts[2])) +from matplotlib._type1font import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated("3.6", name=__name__, obj_type="module") diff --git a/lib/matplotlib/units.py b/lib/matplotlib/units.py index 910509f20310..e3480f228bb4 100644 --- a/lib/matplotlib/units.py +++ b/lib/matplotlib/units.py @@ -46,7 +46,7 @@ def default_units(x, axis): import numpy as np from numpy import ma -from matplotlib import _api, cbook +from matplotlib import cbook class ConversionError(TypeError): @@ -131,23 +131,6 @@ def convert(obj, unit, axis): """ return obj - @staticmethod - @_api.deprecated("3.5") - def is_numlike(x): - """ - The Matplotlib datalim, autoscaling, locators etc work with scalars - which are the units converted to floats given the current unit. The - converter may be passed these floats, or arrays of them, even when - units are set. - """ - if np.iterable(x): - for thisx in x: - if thisx is ma.masked: - continue - return isinstance(thisx, Number) - else: - return isinstance(x, Number) - class DecimalConverter(ConversionInterface): """Converter for decimal.Decimal data to float.""" @@ -197,7 +180,7 @@ def get_converter(self, x): except KeyError: pass try: # If cache lookup fails, look up based on first element... - first = cbook.safe_first_element(x) + first = cbook._safe_first_finite(x) except (TypeError, StopIteration): pass else: diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 8ff73c1cf25e..dfc0450132c6 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -11,16 +11,18 @@ from contextlib import ExitStack import copy +import itertools from numbers import Integral, Number +from cycler import cycler import numpy as np import matplotlib as mpl -from matplotlib import docstring -from . import _api, backend_tools, cbook, colors, ticker +from . import (_api, _docstring, backend_tools, cbook, collections, colors, + text as mtext, ticker, transforms) from .lines import Line2D -from .patches import Circle, Rectangle, Ellipse -from .transforms import TransformedPatchPath +from .patches import Circle, Rectangle, Ellipse, Polygon +from .transforms import TransformedPatchPath, Affine2D class LockDraw: @@ -88,6 +90,22 @@ def ignore(self, event): """ return not self.active + def _changed_canvas(self): + """ + Someone has switched the canvas on us! + + This happens if `savefig` needs to save to a format the previous + backend did not support (e.g. saving a figure using an Agg based + backend saved to a vector format). + + Returns + ------- + bool + True if the canvas has been changed. + + """ + return self.canvas is not self.ax.figure.canvas + class AxesWidget(Widget): """ @@ -104,15 +122,13 @@ class AxesWidget(Widget): Attributes ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. canvas : `~matplotlib.backend_bases.FigureCanvasBase` The parent figure canvas for the widget. active : bool If False, the widget does not respond to events. """ - cids = _api.deprecated("3.4")(property(lambda self: self._cids)) - def __init__(self, ax): self.ax = ax self.canvas = ax.figure.canvas @@ -153,13 +169,8 @@ class Button(AxesWidget): The color of the button when hovering. """ - cnt = _api.deprecated("3.4")(property( # Not real, but close enough. - lambda self: len(self._observers.callbacks['clicked']))) - observers = _api.deprecated("3.4")(property( - lambda self: self._observers.callbacks['clicked'])) - def __init__(self, ax, label, image=None, - color='0.85', hovercolor='0.95'): + color='0.85', hovercolor='0.95', *, useblit=True): """ Parameters ---------- @@ -174,6 +185,9 @@ def __init__(self, ax, label, image=None, The color of the button when not activated. hovercolor : color The color of the button when the mouse is over it. + useblit : bool, default: True + Use blitting for faster drawing if supported by the backend. + See the tutorial :doc:`/tutorials/advanced/blitting` for details. """ super().__init__(ax) @@ -184,7 +198,9 @@ def __init__(self, ax, label, image=None, horizontalalignment='center', transform=ax.transAxes) - self._observers = cbook.CallbackRegistry() + self._useblit = useblit and self.canvas.supports_blit + + self._observers = cbook.CallbackRegistry(signals=["clicked"]) self.connect_event('button_press_event', self._click) self.connect_event('button_release_event', self._release) @@ -216,7 +232,11 @@ def _motion(self, event): if not colors.same_color(c, self.ax.get_facecolor()): self.ax.set_facecolor(c) if self.drawon: - self.ax.figure.canvas.draw() + if self._useblit: + self.ax.draw_artist(self.ax) + self.canvas.blit(self.ax.bbox) + else: + self.canvas.draw() def on_clicked(self, func): """ @@ -276,7 +296,7 @@ def __init__(self, ax, orientation, closedmin, closedmax, self.connect_event("button_release_event", self._update) if dragging: self.connect_event("motion_notify_event", self._update) - self._observers = cbook.CallbackRegistry() + self._observers = cbook.CallbackRegistry(signals=["changed"]) def _stepped_value(self, val): """Return *val* coerced to closest number in the ``valstep`` grid.""" @@ -313,7 +333,7 @@ class Slider(SliderBase): """ A slider representing a floating point range. - Create a slider from *valmin* to *valmax* in axes *ax*. For the slider to + Create a slider from *valmin* to *valmax* in Axes *ax*. For the slider to remain responsive you must maintain a reference to it. Call :meth:`on_changed` to connect to the slider event. @@ -323,11 +343,7 @@ class Slider(SliderBase): Slider value. """ - cnt = _api.deprecated("3.4")(property( # Not real, but close enough. - lambda self: len(self._observers.callbacks['changed']))) - observers = _api.deprecated("3.4")(property( - lambda self: self._observers.callbacks['changed'])) - + @_api.make_keyword_only("3.7", name="valinit") def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, closedmin=True, closedmax=True, slidermin=None, slidermax=None, dragging=True, valstep=None, @@ -441,7 +457,7 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, ax.add_patch(self.track) self.poly = ax.axhspan(valmin, valinit, .25, .75, **kwargs) # Drawing a longer line and clipping it to the track avoids - # pixellization-related asymmetries. + # pixelation-related asymmetries. self.hline = ax.axhline(valinit, 0, 1, color=initcolor, lw=1, clip_path=TransformedPatchPath(self.track)) handleXY = [[0.5], [valinit]] @@ -591,7 +607,7 @@ class RangeSlider(SliderBase): max of the range via the *val* attribute as a tuple of (min, max). Create a slider that defines a range contained within [*valmin*, *valmax*] - in axes *ax*. For the slider to remain responsive you must maintain a + in Axes *ax*. For the slider to remain responsive you must maintain a reference to it. Call :meth:`on_changed` to connect to the slider event. Attributes @@ -600,6 +616,7 @@ class RangeSlider(SliderBase): Slider value. """ + @_api.make_keyword_only("3.7", name="valinit") def __init__( self, ax, @@ -709,7 +726,7 @@ def __init__( facecolor=track_color ) ax.add_patch(self.track) - self.poly = ax.axhspan(valinit[0], valinit[1], 0, 1, **kwargs) + poly_transform = self.ax.get_yaxis_transform(which="grid") handleXY_1 = [.5, valinit[0]] handleXY_2 = [.5, valinit[1]] else: @@ -719,9 +736,15 @@ def __init__( facecolor=track_color ) ax.add_patch(self.track) - self.poly = ax.axvspan(valinit[0], valinit[1], 0, 1, **kwargs) + poly_transform = self.ax.get_xaxis_transform(which="grid") handleXY_1 = [valinit[0], .5] handleXY_2 = [valinit[1], .5] + self.poly = Polygon(np.zeros([5, 2]), **kwargs) + self._update_selection_poly(*valinit) + self.poly.set_transform(poly_transform) + self.poly.get_path()._interpolation_steps = 100 + self.ax.add_patch(self.poly) + self.ax._request_autoscale_view() self._handles = [ ax.plot( *handleXY_1, @@ -777,6 +800,27 @@ def __init__( self._active_handle = None self.set_val(valinit) + def _update_selection_poly(self, vmin, vmax): + """ + Update the vertices of the *self.poly* slider in-place + to cover the data range *vmin*, *vmax*. + """ + # The vertices are positioned + # 1 ------ 2 + # | | + # 0, 4 ---- 3 + verts = self.poly.xy + if self.orientation == "vertical": + verts[0] = verts[4] = .25, vmin + verts[1] = .25, vmax + verts[2] = .75, vmax + verts[3] = .75, vmin + else: + verts[0] = verts[4] = vmin, .25 + verts[1] = vmin, .75 + verts[2] = vmax, .75 + verts[3] = vmax, .25 + def _min_in_bounds(self, min): """Ensure the new min value is between valmin and self.val[1].""" if min <= self.valmin: @@ -901,41 +945,26 @@ def set_val(self, val): ---------- val : tuple or array-like of float """ - val = np.sort(np.asanyarray(val)) - if val.shape != (2,): - raise ValueError( - f"val must have shape (2,) but has shape {val.shape}" - ) - val[0] = self._min_in_bounds(val[0]) - val[1] = self._max_in_bounds(val[1]) - xy = self.poly.xy + val = np.sort(val) + _api.check_shape((2,), val=val) + vmin, vmax = val + vmin = self._min_in_bounds(vmin) + vmax = self._max_in_bounds(vmax) + self._update_selection_poly(vmin, vmax) if self.orientation == "vertical": - xy[0] = .25, val[0] - xy[1] = .25, val[1] - xy[2] = .75, val[1] - xy[3] = .75, val[0] - xy[4] = .25, val[0] - - self._handles[0].set_ydata([val[0]]) - self._handles[1].set_ydata([val[1]]) + self._handles[0].set_ydata([vmin]) + self._handles[1].set_ydata([vmax]) else: - xy[0] = val[0], .25 - xy[1] = val[0], .75 - xy[2] = val[1], .75 - xy[3] = val[1], .25 - xy[4] = val[0], .25 + self._handles[0].set_xdata([vmin]) + self._handles[1].set_xdata([vmax]) - self._handles[0].set_xdata([val[0]]) - self._handles[1].set_xdata([val[1]]) - - self.poly.xy = xy - self.valtext.set_text(self._format(val)) + self.valtext.set_text(self._format((vmin, vmax))) if self.drawon: self.ax.figure.canvas.draw_idle() - self.val = val + self.val = (vmin, vmax) if self.eventson: - self._observers.process("changed", val) + self._observers.process("changed", (vmin, vmax)) def on_changed(self, func): """ @@ -945,7 +974,7 @@ def on_changed(self, func): ---------- func : callable Function to call when slider is changed. The function - must accept a numpy array with shape (2,) as its argument. + must accept a 2-tuple of floats as its argument. Returns ------- @@ -955,6 +984,11 @@ def on_changed(self, func): return self._observers.connect('changed', lambda val: func(val)) +def _expand_text_props(props): + props = cbook.normalize_kwargs(props, mtext.Text) + return cycler(**props)() if props else itertools.repeat({}) + + class CheckButtons(AxesWidget): r""" A GUI neutral set of check buttons. @@ -967,39 +1001,56 @@ class CheckButtons(AxesWidget): Attributes ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. + labels : list of `.Text` rectangles : list of `.Rectangle` lines : list of (`.Line2D`, `.Line2D`) pairs - List of lines for the x's in the check boxes. These lines exist for + List of lines for the x's in the checkboxes. These lines exist for each box, but have ``set_visible(False)`` when its box is not checked. """ - cnt = _api.deprecated("3.4")(property( # Not real, but close enough. - lambda self: len(self._observers.callbacks['clicked']))) - observers = _api.deprecated("3.4")(property( - lambda self: self._observers.callbacks['clicked'])) - - def __init__(self, ax, labels, actives=None): + def __init__(self, ax, labels, actives=None, *, useblit=True, + label_props=None, frame_props=None, check_props=None): """ Add check buttons to `matplotlib.axes.Axes` instance *ax*. Parameters ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. - + The parent Axes for the widget. labels : list of str The labels of the check buttons. - actives : list of bool, optional The initial check states of the buttons. The list must have the same length as *labels*. If not given, all buttons are unchecked. + useblit : bool, default: True + Use blitting for faster drawing if supported by the backend. + See the tutorial :doc:`/tutorials/advanced/blitting` for details. + label_props : dict, optional + Dictionary of `.Text` properties to be used for the labels. + + .. versionadded:: 3.7 + frame_props : dict, optional + Dictionary of scatter `.Collection` properties to be used for the + check button frame. Defaults (label font size / 2)**2 size, black + edgecolor, no facecolor, and 1.0 linewidth. + + .. versionadded:: 3.7 + check_props : dict, optional + Dictionary of scatter `.Collection` properties to be used for the + check button check. Defaults to (label font size / 2)**2 size, + black color, and 1.0 linewidth. + + .. versionadded:: 3.7 """ super().__init__(ax) + _api.check_isinstance((dict, None), label_props=label_props, + frame_props=frame_props, check_props=check_props) + ax.set_xticks([]) ax.set_yticks([]) ax.set_navigate(False) @@ -1007,56 +1058,138 @@ def __init__(self, ax, labels, actives=None): if actives is None: actives = [False] * len(labels) - if len(labels) > 1: - dy = 1. / (len(labels) + 1) - ys = np.linspace(1 - dy, dy, len(labels)) - else: - dy = 0.25 - ys = [0.5] + self._useblit = useblit and self.canvas.supports_blit + self._background = None + + ys = np.linspace(1, 0, len(labels)+2)[1:-1] + + label_props = _expand_text_props(label_props) + self.labels = [ + ax.text(0.25, y, label, transform=ax.transAxes, + horizontalalignment="left", verticalalignment="center", + **props) + for y, label, props in zip(ys, labels, label_props)] + text_size = np.array([text.get_fontsize() for text in self.labels]) / 2 + + frame_props = { + 's': text_size**2, + 'linewidth': 1, + **cbook.normalize_kwargs(frame_props, collections.PathCollection), + 'marker': 's', + 'transform': ax.transAxes, + } + frame_props.setdefault('facecolor', frame_props.get('color', 'none')) + frame_props.setdefault('edgecolor', frame_props.pop('color', 'black')) + self._frames = ax.scatter([0.15] * len(ys), ys, **frame_props) + check_props = { + 'linewidth': 1, + 's': text_size**2, + **cbook.normalize_kwargs(check_props, collections.PathCollection), + 'marker': 'x', + 'transform': ax.transAxes, + 'animated': self._useblit, + } + check_props.setdefault('facecolor', check_props.pop('color', 'black')) + self._checks = ax.scatter([0.15] * len(ys), ys, **check_props) + # The user may have passed custom colours in check_props, so we need to + # create the checks (above), and modify the visibility after getting + # whatever the user set. + self._init_status(actives) + + self.connect_event('button_press_event', self._clicked) + if self._useblit: + self.connect_event('draw_event', self._clear) - axcolor = ax.get_facecolor() + self._observers = cbook.CallbackRegistry(signals=["clicked"]) - self.labels = [] - self.lines = [] - self.rectangles = [] + def _clear(self, event): + """Internal event handler to clear the buttons.""" + if self.ignore(event) or self._changed_canvas(): + return + self._background = self.canvas.copy_from_bbox(self.ax.bbox) + self.ax.draw_artist(self._checks) + if hasattr(self, '_lines'): + for l1, l2 in self._lines: + self.ax.draw_artist(l1) + self.ax.draw_artist(l2) - lineparams = {'color': 'k', 'linewidth': 1.25, - 'transform': ax.transAxes, 'solid_capstyle': 'butt'} - for y, label, active in zip(ys, labels, actives): - t = ax.text(0.25, y, label, transform=ax.transAxes, - horizontalalignment='left', - verticalalignment='center') + def _clicked(self, event): + if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: + return + pclicked = self.ax.transAxes.inverted().transform((event.x, event.y)) + distances = {} + if hasattr(self, "_rectangles"): + for i, (p, t) in enumerate(zip(self._rectangles, self.labels)): + x0, y0 = p.get_xy() + if (t.get_window_extent().contains(event.x, event.y) + or (x0 <= pclicked[0] <= x0 + p.get_width() + and y0 <= pclicked[1] <= y0 + p.get_height())): + distances[i] = np.linalg.norm(pclicked - p.get_center()) + else: + _, frame_inds = self._frames.contains(event) + coords = self._frames.get_offset_transform().transform( + self._frames.get_offsets() + ) + for i, t in enumerate(self.labels): + if (i in frame_inds["ind"] + or t.get_window_extent().contains(event.x, event.y)): + distances[i] = np.linalg.norm(pclicked - coords[i]) + if len(distances) > 0: + closest = min(distances, key=distances.get) + self.set_active(closest) - w, h = dy / 2, dy / 2 - x, y = 0.05, y - h / 2 + def set_label_props(self, props): + """ + Set properties of the `.Text` labels. - p = Rectangle(xy=(x, y), width=w, height=h, edgecolor='black', - facecolor=axcolor, transform=ax.transAxes) + .. versionadded:: 3.7 - l1 = Line2D([x, x + w], [y + h, y], **lineparams) - l2 = Line2D([x, x + w], [y, y + h], **lineparams) + Parameters + ---------- + props : dict + Dictionary of `.Text` properties to be used for the labels. + """ + _api.check_isinstance(dict, props=props) + props = _expand_text_props(props) + for text, prop in zip(self.labels, props): + text.update(prop) - l1.set_visible(active) - l2.set_visible(active) - self.labels.append(t) - self.rectangles.append(p) - self.lines.append((l1, l2)) - ax.add_patch(p) - ax.add_line(l1) - ax.add_line(l2) + def set_frame_props(self, props): + """ + Set properties of the check button frames. - self.connect_event('button_press_event', self._clicked) + .. versionadded:: 3.7 - self._observers = cbook.CallbackRegistry() + Parameters + ---------- + props : dict + Dictionary of `.Collection` properties to be used for the check + button frames. + """ + _api.check_isinstance(dict, props=props) + if 's' in props: # Keep API consistent with constructor. + props['sizes'] = np.broadcast_to(props.pop('s'), len(self.labels)) + self._frames.update(props) - def _clicked(self, event): - if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: - return - for i, (p, t) in enumerate(zip(self.rectangles, self.labels)): - if (t.get_window_extent().contains(event.x, event.y) or - p.get_window_extent().contains(event.x, event.y)): - self.set_active(i) - break + def set_check_props(self, props): + """ + Set properties of the check button checks. + + .. versionadded:: 3.7 + + Parameters + ---------- + props : dict + Dictionary of `.Collection` properties to be used for the check + button check. + """ + _api.check_isinstance(dict, props=props) + if 's' in props: # Keep API consistent with constructor. + props['sizes'] = np.broadcast_to(props.pop('s'), len(self.labels)) + actives = self.get_status() + self._checks.update(props) + # If new colours are supplied, then we must re-apply the status. + self._init_status(actives) def set_active(self, index): """ @@ -1077,21 +1210,59 @@ def set_active(self, index): if index not in range(len(self.labels)): raise ValueError(f'Invalid CheckButton index: {index}') - l1, l2 = self.lines[index] - l1.set_visible(not l1.get_visible()) - l2.set_visible(not l2.get_visible()) + invisible = colors.to_rgba('none') + + facecolors = self._checks.get_facecolor() + facecolors[index] = ( + self._active_check_colors[index] + if colors.same_color(facecolors[index], invisible) + else invisible + ) + self._checks.set_facecolor(facecolors) + + if hasattr(self, "_lines"): + l1, l2 = self._lines[index] + l1.set_visible(not l1.get_visible()) + l2.set_visible(not l2.get_visible()) if self.drawon: - self.ax.figure.canvas.draw() + if self._useblit: + if self._background is not None: + self.canvas.restore_region(self._background) + self.ax.draw_artist(self._checks) + if hasattr(self, "_lines"): + for l1, l2 in self._lines: + self.ax.draw_artist(l1) + self.ax.draw_artist(l2) + self.canvas.blit(self.ax.bbox) + else: + self.canvas.draw() if self.eventson: self._observers.process('clicked', self.labels[index].get_text()) + def _init_status(self, actives): + """ + Initialize properties to match active status. + + The user may have passed custom colours in *check_props* to the + constructor, or to `.set_check_props`, so we need to modify the + visibility after getting whatever the user set. + """ + self._active_check_colors = self._checks.get_facecolor() + if len(self._active_check_colors) == 1: + self._active_check_colors = np.repeat(self._active_check_colors, + len(actives), axis=0) + self._checks.set_facecolor( + [ec if active else "none" + for ec, active in zip(self._active_check_colors, actives)]) + def get_status(self): """ - Return a tuple of the status (True/False) of all of the check buttons. + Return a list of the status (True/False) of all of the check buttons. """ - return [l1.get_visible() for (l1, l2) in self.lines] + return [not colors.same_color(color, colors.to_rgba("none")) + for color in self._checks.get_facecolors()] def on_clicked(self, func): """ @@ -1105,6 +1276,60 @@ def disconnect(self, cid): """Remove the observer with connection id *cid*.""" self._observers.disconnect(cid) + @_api.deprecated("3.7", + addendum="Any custom property styling may be lost.") + @property + def rectangles(self): + if not hasattr(self, "_rectangles"): + ys = np.linspace(1, 0, len(self.labels)+2)[1:-1] + dy = 1. / (len(self.labels) + 1) + w, h = dy / 2, dy / 2 + rectangles = self._rectangles = [ + Rectangle(xy=(0.05, ys[i] - h / 2), width=w, height=h, + edgecolor="black", + facecolor="none", + transform=self.ax.transAxes + ) + for i, y in enumerate(ys) + ] + self._frames.set_visible(False) + for rectangle in rectangles: + self.ax.add_patch(rectangle) + if not hasattr(self, "_lines"): + with _api.suppress_matplotlib_deprecation_warning(): + _ = self.lines + return self._rectangles + + @_api.deprecated("3.7", + addendum="Any custom property styling may be lost.") + @property + def lines(self): + if not hasattr(self, "_lines"): + ys = np.linspace(1, 0, len(self.labels)+2)[1:-1] + self._checks.set_visible(False) + dy = 1. / (len(self.labels) + 1) + w, h = dy / 2, dy / 2 + self._lines = [] + current_status = self.get_status() + lineparams = {'color': 'k', 'linewidth': 1.25, + 'transform': self.ax.transAxes, + 'solid_capstyle': 'butt', + 'animated': self._useblit} + for i, y in enumerate(ys): + x, y = 0.05, y - h / 2 + l1 = Line2D([x, x + w], [y + h, y], **lineparams) + l2 = Line2D([x, x + w], [y, y + h], **lineparams) + + l1.set_visible(current_status[i]) + l2.set_visible(current_status[i]) + self._lines.append((l1, l2)) + self.ax.add_line(l1) + self.ax.add_line(l2) + if not hasattr(self, "_rectangles"): + with _api.suppress_matplotlib_deprecation_warning(): + _ = self.rectangles + return self._lines + class TextBox(AxesWidget): """ @@ -1120,7 +1345,7 @@ class TextBox(AxesWidget): Attributes ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. label : `.Text` color : color @@ -1129,14 +1354,7 @@ class TextBox(AxesWidget): The color of the text box when hovering. """ - cnt = _api.deprecated("3.4")(property( # Not real, but close enough. - lambda self: sum(len(d) for d in self._observers.callbacks.values()))) - change_observers = _api.deprecated("3.4")(property( - lambda self: self._observers.callbacks['change'])) - submit_observers = _api.deprecated("3.4")(property( - lambda self: self._observers.callbacks['submit'])) - DIST_FROM_LEFT = _api.deprecate_privatize_attribute("3.5") - + @_api.make_keyword_only("3.7", name="color") def __init__(self, ax, label, initial='', color='.95', hovercolor='1', label_pad=.01, textalignment="left"): @@ -1160,8 +1378,6 @@ def __init__(self, ax, label, initial='', """ super().__init__(ax) - self._DIST_FROM_LEFT = .05 - self._text_position = _api.check_getitem( {"left": 0.05, "center": 0.5, "right": 0.95}, textalignment=textalignment) @@ -1176,7 +1392,7 @@ def __init__(self, ax, label, initial='', verticalalignment='center', horizontalalignment=textalignment, parse_math=False) - self._observers = cbook.CallbackRegistry() + self._observers = cbook.CallbackRegistry(signals=["change", "submit"]) ax.set( xlim=(0, 1), ylim=(0, 1), # s.t. cursor appears from first click. @@ -1212,7 +1428,7 @@ def _rendercursor(self): # This causes a single extra draw if the figure has never been rendered # yet, which should be fine as we're going to repeatedly re-render the # figure later anyways. - if self.ax.figure._cachedRenderer is None: + if self.ax.figure._get_renderer() is None: self.ax.figure.canvas.draw() text = self.text_disp.get_text() # Save value before overwriting it. @@ -1223,12 +1439,12 @@ def _rendercursor(self): bb_widthtext = self.text_disp.get_window_extent() if bb_text.y0 == bb_text.y1: # Restoring the height if no text. - bb_text.y0 -= (bb_widthtext.y1-bb_widthtext.y0)/2 - bb_text.y1 += (bb_widthtext.y1-bb_widthtext.y0)/2 + bb_text.y0 -= bb_widthtext.height / 2 + bb_text.y1 += bb_widthtext.height / 2 elif not widthtext: # Keep width to 0. bb_text.x1 = bb_text.x0 else: # Move the cursor using width of bb_widthtext. - bb_text.x1 = bb_text.x0 + (bb_widthtext.x1 - bb_widthtext.x0) + bb_text.x1 = bb_text.x0 + bb_widthtext.width self.cursor.set( segments=[[(bb_text.x1, bb_text.y0), (bb_text.x1, bb_text.y1)]], @@ -1290,7 +1506,8 @@ def set_val(self, val): self._observers.process('change', self.text) self._observers.process('submit', self.text) - def begin_typing(self, x): + @_api.delete_parameter("3.7", "x") + def begin_typing(self, x=None): self.capturekeystrokes = True # Disable keypress shortcuts, which may otherwise cause the figure to # be saved, closed, etc., until the user stops typing. The way to @@ -1326,17 +1543,6 @@ def stop_typing(self): # call it once we've already done our cleanup. self._observers.process('submit', self.text) - def position_cursor(self, x): - # now, we have to figure out where the cursor goes. - # approximate it based on assuming all characters the same length - if len(self.text) == 0: - self.cursor_index = 0 - else: - bb = self.text_disp.get_window_extent() - ratio = np.clip((x - bb.x0) / bb.width, 0, 1) - self.cursor_index = int(len(self.text) * ratio) - self._rendercursor() - def _click(self, event): if self.ignore(event): return @@ -1348,8 +1554,9 @@ def _click(self, event): if event.canvas.mouse_grabber != self.ax: event.canvas.grab_mouse(self.ax) if not self.capturekeystrokes: - self.begin_typing(event.x) - self.position_cursor(event.x) + self.begin_typing() + self.cursor_index = self.text_disp._char_index_at(event.x) + self._rendercursor() def _resize(self, event): self.stop_typing() @@ -1397,7 +1604,7 @@ class RadioButtons(AxesWidget): Attributes ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. activecolor : color The color of the selected button. labels : list of `.Text` @@ -1408,81 +1615,192 @@ class RadioButtons(AxesWidget): The label text of the currently selected button. """ - def __init__(self, ax, labels, active=0, activecolor='blue'): + def __init__(self, ax, labels, active=0, activecolor=None, *, + useblit=True, label_props=None, radio_props=None): """ Add radio buttons to an `~.axes.Axes`. Parameters ---------- ax : `~matplotlib.axes.Axes` - The axes to add the buttons to. + The Axes to add the buttons to. labels : list of str The button labels. active : int The index of the initially selected button. activecolor : color - The color of the selected button. + The color of the selected button. The default is ``'blue'`` if not + specified here or in *radio_props*. + useblit : bool, default: True + Use blitting for faster drawing if supported by the backend. + See the tutorial :doc:`/tutorials/advanced/blitting` for details. + label_props : dict or list of dict, optional + Dictionary of `.Text` properties to be used for the labels. + + .. versionadded:: 3.7 + radio_props : dict, optional + Dictionary of scatter `.Collection` properties to be used for the + radio buttons. Defaults to (label font size / 2)**2 size, black + edgecolor, and *activecolor* facecolor (when active). + + .. note:: + If a facecolor is supplied in *radio_props*, it will override + *activecolor*. This may be used to provide an active color per + button. + + .. versionadded:: 3.7 """ super().__init__(ax) - self.activecolor = activecolor - self.value_selected = None + + _api.check_isinstance((dict, None), label_props=label_props, + radio_props=radio_props) + + radio_props = cbook.normalize_kwargs(radio_props, + collections.PathCollection) + if activecolor is not None: + if 'facecolor' in radio_props: + _api.warn_external( + 'Both the *activecolor* parameter and the *facecolor* ' + 'key in the *radio_props* parameter has been specified. ' + '*activecolor* will be ignored.') + else: + activecolor = 'blue' # Default. + + self._activecolor = activecolor + self.value_selected = labels[active] ax.set_xticks([]) ax.set_yticks([]) ax.set_navigate(False) - dy = 1. / (len(labels) + 1) - ys = np.linspace(1 - dy, dy, len(labels)) - cnt = 0 - axcolor = ax.get_facecolor() - - # scale the radius of the circle with the spacing between each one - circle_radius = dy / 2 - 0.01 - # default to hard-coded value if the radius becomes too large - circle_radius = min(circle_radius, 0.05) - - self.labels = [] - self.circles = [] - for y, label in zip(ys, labels): - t = ax.text(0.25, y, label, transform=ax.transAxes, - horizontalalignment='left', - verticalalignment='center') - - if cnt == active: - self.value_selected = label - facecolor = activecolor - else: - facecolor = axcolor - - p = Circle(xy=(0.15, y), radius=circle_radius, edgecolor='black', - facecolor=facecolor, transform=ax.transAxes) - self.labels.append(t) - self.circles.append(p) - ax.add_patch(p) - cnt += 1 + ys = np.linspace(1, 0, len(labels) + 2)[1:-1] + + self._useblit = useblit and self.canvas.supports_blit + self._background = None + + label_props = _expand_text_props(label_props) + self.labels = [ + ax.text(0.25, y, label, transform=ax.transAxes, + horizontalalignment="left", verticalalignment="center", + **props) + for y, label, props in zip(ys, labels, label_props)] + text_size = np.array([text.get_fontsize() for text in self.labels]) / 2 + + radio_props = { + 's': text_size**2, + **radio_props, + 'marker': 'o', + 'transform': ax.transAxes, + 'animated': self._useblit, + } + radio_props.setdefault('edgecolor', radio_props.get('color', 'black')) + radio_props.setdefault('facecolor', + radio_props.pop('color', activecolor)) + self._buttons = ax.scatter([.15] * len(ys), ys, **radio_props) + # The user may have passed custom colours in radio_props, so we need to + # create the radios, and modify the visibility after getting whatever + # the user set. + self._active_colors = self._buttons.get_facecolor() + if len(self._active_colors) == 1: + self._active_colors = np.repeat(self._active_colors, len(labels), + axis=0) + self._buttons.set_facecolor( + [activecolor if i == active else "none" + for i, activecolor in enumerate(self._active_colors)]) self.connect_event('button_press_event', self._clicked) + if self._useblit: + self.connect_event('draw_event', self._clear) - self._observers = cbook.CallbackRegistry() + self._observers = cbook.CallbackRegistry(signals=["clicked"]) - cnt = _api.deprecated("3.4")(property( # Not real, but close enough. - lambda self: len(self._observers.callbacks['clicked']))) - observers = _api.deprecated("3.4")(property( - lambda self: self._observers.callbacks['clicked'])) + def _clear(self, event): + """Internal event handler to clear the buttons.""" + if self.ignore(event) or self._changed_canvas(): + return + self._background = self.canvas.copy_from_bbox(self.ax.bbox) + self.ax.draw_artist(self._buttons) + if hasattr(self, "_circles"): + for circle in self._circles: + self.ax.draw_artist(circle) def _clicked(self, event): if self.ignore(event) or event.button != 1 or event.inaxes != self.ax: return pclicked = self.ax.transAxes.inverted().transform((event.x, event.y)) + _, inds = self._buttons.contains(event) + coords = self._buttons.get_offset_transform().transform( + self._buttons.get_offsets()) distances = {} - for i, (p, t) in enumerate(zip(self.circles, self.labels)): - if (t.get_window_extent().contains(event.x, event.y) - or np.linalg.norm(pclicked - p.center) < p.radius): - distances[i] = np.linalg.norm(pclicked - p.center) + if hasattr(self, "_circles"): # Remove once circles is removed. + for i, (p, t) in enumerate(zip(self._circles, self.labels)): + if (t.get_window_extent().contains(event.x, event.y) + or np.linalg.norm(pclicked - p.center) < p.radius): + distances[i] = np.linalg.norm(pclicked - p.center) + else: + for i, t in enumerate(self.labels): + if (i in inds["ind"] + or t.get_window_extent().contains(event.x, event.y)): + distances[i] = np.linalg.norm(pclicked - coords[i]) if len(distances) > 0: closest = min(distances, key=distances.get) self.set_active(closest) + def set_label_props(self, props): + """ + Set properties of the `.Text` labels. + + .. versionadded:: 3.7 + + Parameters + ---------- + props : dict + Dictionary of `.Text` properties to be used for the labels. + """ + _api.check_isinstance(dict, props=props) + props = _expand_text_props(props) + for text, prop in zip(self.labels, props): + text.update(prop) + + def set_radio_props(self, props): + """ + Set properties of the `.Text` labels. + + .. versionadded:: 3.7 + + Parameters + ---------- + props : dict + Dictionary of `.Collection` properties to be used for the radio + buttons. + """ + _api.check_isinstance(dict, props=props) + if 's' in props: # Keep API consistent with constructor. + props['sizes'] = np.broadcast_to(props.pop('s'), len(self.labels)) + self._buttons.update(props) + self._active_colors = self._buttons.get_facecolor() + if len(self._active_colors) == 1: + self._active_colors = np.repeat(self._active_colors, + len(self.labels), axis=0) + self._buttons.set_facecolor( + [activecolor if text.get_text() == self.value_selected else "none" + for text, activecolor in zip(self.labels, self._active_colors)]) + + @property + def activecolor(self): + return self._activecolor + + @activecolor.setter + def activecolor(self, activecolor): + colors._check_color_like(activecolor=activecolor) + self._activecolor = activecolor + self.set_radio_props({'facecolor': activecolor}) + # Make sure the deprecated version is updated. + # Remove once circles is removed. + labels = [label.get_text() for label in self.labels] + with cbook._setattr_cm(self, eventson=False): + self.set_active(labels.index(self.value_selected)) + def set_active(self, index): """ Select button with number *index*. @@ -1491,18 +1809,27 @@ def set_active(self, index): """ if index not in range(len(self.labels)): raise ValueError(f'Invalid RadioButton index: {index}') - self.value_selected = self.labels[index].get_text() - - for i, p in enumerate(self.circles): - if i == index: - color = self.activecolor - else: - color = self.ax.get_facecolor() - p.set_facecolor(color) - + button_facecolors = self._buttons.get_facecolor() + button_facecolors[:] = colors.to_rgba("none") + button_facecolors[index] = colors.to_rgba(self._active_colors[index]) + self._buttons.set_facecolor(button_facecolors) + if hasattr(self, "_circles"): # Remove once circles is removed. + for i, p in enumerate(self._circles): + p.set_facecolor(self.activecolor if i == index else "none") + if self.drawon and self._useblit: + self.ax.draw_artist(p) if self.drawon: - self.ax.figure.canvas.draw() + if self._useblit: + if self._background is not None: + self.canvas.restore_region(self._background) + self.ax.draw_artist(self._buttons) + if hasattr(self, "_circles"): + for p in self._circles: + self.ax.draw_artist(p) + self.canvas.blit(self.ax.bbox) + else: + self.canvas.draw() if self.eventson: self._observers.process('clicked', self.labels[index].get_text()) @@ -1519,6 +1846,23 @@ def disconnect(self, cid): """Remove the observer with connection id *cid*.""" self._observers.disconnect(cid) + @_api.deprecated("3.7", + addendum="Any custom property styling may be lost.") + @property + def circles(self): + if not hasattr(self, "_circles"): + radius = min(.5 / (len(self.labels) + 1) - .01, .05) + circles = self._circles = [ + Circle(xy=self._buttons.get_offsets()[i], edgecolor="black", + facecolor=self._buttons.get_facecolor()[i], + radius=radius, transform=self.ax.transAxes, + animated=self._useblit) + for i in range(len(self.labels))] + self._buttons.set_visible(False) + for circle in circles: + self.ax.add_patch(circle) + return self._circles + class SubplotTool(Widget): """ @@ -1545,8 +1889,8 @@ def __init__(self, targetfig, toolfig): # The last subplot, removed below, keeps space for the "Reset" button. for name, ax in zip(names, toolfig.subplots(len(names) + 1)): ax.set_navigate(False) - slider = Slider(ax, name, - 0, 1, getattr(targetfig.subplotpars, name)) + slider = Slider(ax, name, 0, 1, + valinit=getattr(targetfig.subplotpars, name)) slider.on_changed(self._on_slider_changed) self._sliders.append(slider) toolfig.axes[-1].remove() @@ -1594,7 +1938,7 @@ def _on_reset(self, event): class Cursor(AxesWidget): """ - A crosshair cursor that spans the axes and moves with mouse cursor. + A crosshair cursor that spans the Axes and moves with mouse cursor. For the cursor to remain responsive you must keep a reference to it. @@ -1608,6 +1952,7 @@ class Cursor(AxesWidget): Whether to draw the vertical line. useblit : bool, default: False Use blitting for faster drawing if supported by the backend. + See the tutorial :doc:`/tutorials/advanced/blitting` for details. Other Parameters ---------------- @@ -1619,7 +1964,7 @@ class Cursor(AxesWidget): -------- See :doc:`/gallery/widgets/cursor`. """ - + @_api.make_keyword_only("3.7", "horizOn") def __init__(self, ax, horizOn=True, vertOn=True, useblit=False, **lineprops): super().__init__(ax) @@ -1642,12 +1987,10 @@ def __init__(self, ax, horizOn=True, vertOn=True, useblit=False, def clear(self, event): """Internal event handler to clear the cursor.""" - if self.ignore(event): + if self.ignore(event) or self._changed_canvas(): return if self.useblit: self.background = self.canvas.copy_from_bbox(self.ax.bbox) - self.linev.set_visible(False) - self.lineh.set_visible(False) def onmove(self, event): """Internal event handler to draw the cursor when the mouse moves.""" @@ -1664,15 +2007,15 @@ def onmove(self, event): self.needclear = False return self.needclear = True - if not self.visible: - return + self.linev.set_xdata((event.xdata, event.xdata)) + self.linev.set_visible(self.visible and self.vertOn) self.lineh.set_ydata((event.ydata, event.ydata)) - self.linev.set_visible(self.visible and self.vertOn) self.lineh.set_visible(self.visible and self.horizOn) - self._update() + if self.visible and (self.vertOn or self.horizOn): + self._update() def _update(self): if self.useblit: @@ -1689,25 +2032,27 @@ def _update(self): class MultiCursor(Widget): """ Provide a vertical (default) and/or horizontal line cursor shared between - multiple axes. + multiple Axes. For the cursor to remain responsive you must keep a reference to it. Parameters ---------- - canvas : `matplotlib.backend_bases.FigureCanvasBase` - The FigureCanvas that contains all the axes. + canvas : object + This parameter is entirely unused and only kept for back-compatibility. axes : list of `matplotlib.axes.Axes` The `~.axes.Axes` to attach the cursor to. useblit : bool, default: True Use blitting for faster drawing if supported by the backend. + See the tutorial :doc:`/tutorials/advanced/blitting` + for details. horizOn : bool, default: False Whether to draw the horizontal line. - vertOn: bool, default: True + vertOn : bool, default: True Whether to draw the vertical line. Other Parameters @@ -1721,110 +2066,122 @@ class MultiCursor(Widget): See :doc:`/gallery/widgets/multicursor`. """ + @_api.make_keyword_only("3.6", "useblit") def __init__(self, canvas, axes, useblit=True, horizOn=False, vertOn=True, **lineprops): - self.canvas = canvas + # canvas is stored only to provide the deprecated .canvas attribute; + # once it goes away the unused argument won't need to be stored at all. + self._canvas = canvas + self.axes = axes self.horizOn = horizOn self.vertOn = vertOn + self._canvas_infos = { + ax.figure.canvas: {"cids": [], "background": None} for ax in axes} + xmin, xmax = axes[-1].get_xlim() ymin, ymax = axes[-1].get_ylim() xmid = 0.5 * (xmin + xmax) ymid = 0.5 * (ymin + ymax) self.visible = True - self.useblit = useblit and self.canvas.supports_blit - self.background = None - self.needclear = False + self.useblit = ( + useblit + and all(canvas.supports_blit for canvas in self._canvas_infos)) if self.useblit: lineprops['animated'] = True - if vertOn: - self.vlines = [ax.axvline(xmid, visible=False, **lineprops) - for ax in axes] - else: - self.vlines = [] - - if horizOn: - self.hlines = [ax.axhline(ymid, visible=False, **lineprops) - for ax in axes] - else: - self.hlines = [] + self.vlines = [ax.axvline(xmid, visible=False, **lineprops) + for ax in axes] + self.hlines = [ax.axhline(ymid, visible=False, **lineprops) + for ax in axes] self.connect() + canvas = _api.deprecate_privatize_attribute("3.6") + background = _api.deprecated("3.6")(lambda self: ( + self._backgrounds[self.axes[0].figure.canvas] if self.axes else None)) + needclear = _api.deprecated("3.7")(lambda self: False) + def connect(self): """Connect events.""" - self._cidmotion = self.canvas.mpl_connect('motion_notify_event', - self.onmove) - self._ciddraw = self.canvas.mpl_connect('draw_event', self.clear) + for canvas, info in self._canvas_infos.items(): + info["cids"] = [ + canvas.mpl_connect('motion_notify_event', self.onmove), + canvas.mpl_connect('draw_event', self.clear), + ] def disconnect(self): """Disconnect events.""" - self.canvas.mpl_disconnect(self._cidmotion) - self.canvas.mpl_disconnect(self._ciddraw) + for canvas, info in self._canvas_infos.items(): + for cid in info["cids"]: + canvas.mpl_disconnect(cid) + info["cids"].clear() def clear(self, event): """Clear the cursor.""" if self.ignore(event): return if self.useblit: - self.background = ( - self.canvas.copy_from_bbox(self.canvas.figure.bbox)) - for line in self.vlines + self.hlines: - line.set_visible(False) + for canvas, info in self._canvas_infos.items(): + # someone has switched the canvas on us! This happens if + # `savefig` needs to save to a format the previous backend did + # not support (e.g. saving a figure using an Agg based backend + # saved to a vector format). + if canvas is not canvas.figure.canvas: + continue + info["background"] = canvas.copy_from_bbox(canvas.figure.bbox) def onmove(self, event): - if self.ignore(event): + if (self.ignore(event) + or event.inaxes not in self.axes + or not event.canvas.widgetlock.available(self)): return - if event.inaxes not in self.axes: - return - if not self.canvas.widgetlock.available(self): - return - self.needclear = True - if not self.visible: - return - if self.vertOn: - for line in self.vlines: - line.set_xdata((event.xdata, event.xdata)) - line.set_visible(self.visible) - if self.horizOn: - for line in self.hlines: - line.set_ydata((event.ydata, event.ydata)) - line.set_visible(self.visible) - self._update() + for line in self.vlines: + line.set_xdata((event.xdata, event.xdata)) + line.set_visible(self.visible and self.vertOn) + for line in self.hlines: + line.set_ydata((event.ydata, event.ydata)) + line.set_visible(self.visible and self.horizOn) + if self.visible and (self.vertOn or self.horizOn): + self._update() def _update(self): if self.useblit: - if self.background is not None: - self.canvas.restore_region(self.background) + for canvas, info in self._canvas_infos.items(): + if info["background"]: + canvas.restore_region(info["background"]) if self.vertOn: for ax, line in zip(self.axes, self.vlines): ax.draw_artist(line) if self.horizOn: for ax, line in zip(self.axes, self.hlines): ax.draw_artist(line) - self.canvas.blit() + for canvas in self._canvas_infos: + canvas.blit() else: - self.canvas.draw_idle() + for canvas in self._canvas_infos: + canvas.draw_idle() class _SelectorWidget(AxesWidget): def __init__(self, ax, onselect, useblit=False, button=None, - state_modifier_keys=None): + state_modifier_keys=None, use_data_coordinates=False): super().__init__(ax) - self.visible = True + self._visible = True self.onselect = onselect self.useblit = useblit and self.canvas.supports_blit self.connect_default_events() - self.state_modifier_keys = dict(move=' ', clear='escape', - square='shift', center='control') - self.state_modifier_keys.update(state_modifier_keys or {}) + self._state_modifier_keys = dict(move=' ', clear='escape', + square='shift', center='control', + rotate='r') + self._state_modifier_keys.update(state_modifier_keys or {}) + self._use_data_coordinates = use_data_coordinates self.background = None @@ -1842,17 +2199,26 @@ def __init__(self, ax, onselect, useblit=False, button=None, self._eventrelease = None self._prev_event = None self._state = set() - self._default_state = set() - eventpress = _api.deprecate_privatize_attribute("3.5") - eventrelease = _api.deprecate_privatize_attribute("3.5") - state = _api.deprecate_privatize_attribute("3.5") + state_modifier_keys = _api.deprecate_privatize_attribute("3.6") def set_active(self, active): super().set_active(active) if active: self.update_background(None) + def _get_animated_artists(self): + """ + Convenience method to get all animated artists of the figure containing + this widget, excluding those already present in self.artists. + The returned tuple is not sorted by 'z_order': z_order sorting is + valid only when considering all artists and not only a subset of all + artists. + """ + return tuple(a for ax_ in self.ax.get_figure().get_axes() + for a in ax_.get_children() + if a.get_animated() and a not in self.artists) + def update_background(self, event): """Force an update of the background.""" # If you add a call to `ignore` here, you'll want to check edge case: @@ -1862,15 +2228,21 @@ def update_background(self, event): # Make sure that widget artists don't get accidentally included in the # background, by re-rendering the background if needed (and then # re-re-rendering the canvas with the visible widget artists). - needs_redraw = any(artist.get_visible() for artist in self.artists) + # We need to remove all artists which will be drawn when updating + # the selector: if we have animated artists in the figure, it is safer + # to redrawn by default, in case they have updated by the callback + # zorder needs to be respected when redrawing + artists = sorted(self.artists + self._get_animated_artists(), + key=lambda a: a.get_zorder()) + needs_redraw = any(artist.get_visible() for artist in artists) with ExitStack() as stack: if needs_redraw: - for artist in self.artists: + for artist in artists: stack.enter_context(artist._cm_set(visible=False)) self.canvas.draw() self.background = self.canvas.copy_from_bbox(self.ax.bbox) if needs_redraw: - for artist in self.artists: + for artist in artists: self.ax.draw_artist(artist) def connect_default_events(self): @@ -1898,7 +2270,7 @@ def ignore(self, event): and event.button not in self.validButtons): return True # If no button was pressed yet ignore the event if it was out - # of the axes + # of the Axes if self._eventpress is None: return event.inaxes != self.ax # If a button was pressed, check if the release-button is the same. @@ -1910,19 +2282,24 @@ def ignore(self, event): def update(self): """Draw using blit() or draw_idle(), depending on ``self.useblit``.""" - if not self.ax.get_visible() or self.ax.figure._cachedRenderer is None: - return False + if (not self.ax.get_visible() or + self.ax.figure._get_renderer() is None): + return if self.useblit: if self.background is not None: self.canvas.restore_region(self.background) else: self.update_background(None) - for artist in self.artists: + # We need to draw all artists, which are not included in the + # background, therefore we also draw self._get_animated_artists() + # and we make sure that we respect z_order + artists = sorted(self.artists + self._get_animated_artists(), + key=lambda a: a.get_zorder()) + for artist in artists: self.ax.draw_artist(artist) self.canvas.blit(self.ax.bbox) else: self.canvas.draw_idle() - return False def _get_data(self, event): """Get the xdata and ydata for event, with limits.""" @@ -1957,7 +2334,7 @@ def press(self, event): key = event.key or '' key = key.replace('ctrl', 'control') # move state is locked in on a button press - if key == self.state_modifier_keys['move']: + if key == self._state_modifier_keys['move']: self._state.add('move') self._press(event) return True @@ -2005,12 +2382,20 @@ def on_key_press(self, event): if self.active: key = event.key or '' key = key.replace('ctrl', 'control') - if key == self.state_modifier_keys['clear']: + if key == self._state_modifier_keys['clear']: self.clear() return - for (state, modifier) in self.state_modifier_keys.items(): - if modifier in key: - self._state.add(state) + for (state, modifier) in self._state_modifier_keys.items(): + if modifier in key.split('+'): + # 'rotate' is changing _state on press and is not removed + # from _state when releasing + if state == 'rotate': + if state in self._state: + self._state.discard(state) + else: + self._state.add(state) + else: + self._state.add(state) self._on_key_press(event) def _on_key_press(self, event): @@ -2020,8 +2405,10 @@ def on_key_release(self, event): """Key release event handler and validator.""" if self.active: key = event.key or '' - for (state, modifier) in self.state_modifier_keys.items(): - if modifier in key: + for (state, modifier) in self._state_modifier_keys.items(): + # 'rotate' is changing _state on press and is not removed + # from _state when releasing + if modifier in key.split('+') and state != 'rotate': self._state.discard(state) self._on_key_release(event) @@ -2029,16 +2416,32 @@ def _on_key_release(self, event): """Key release event handler.""" def set_visible(self, visible): - """Set the visibility of our artists.""" - self.visible = visible + """Set the visibility of the selector artists.""" + self._visible = visible for artist in self.artists: artist.set_visible(visible) + def get_visible(self): + """Get the visibility of the selector artists.""" + return self._visible + + @property + def visible(self): + return self.get_visible() + + @visible.setter + def visible(self, visible): + _api.warn_deprecated("3.6", alternative="set_visible") + self.set_visible(visible) + def clear(self): """Clear the selection and set the selector ready to make a new one.""" + self._clear_without_update() + self.update() + + def _clear_without_update(self): self._selection_completed = False self.set_visible(False) - self.update() @property def artists(self): @@ -2075,6 +2478,53 @@ def set_handle_props(self, **handle_props): self.update() self._handle_props.update(handle_props) + def _validate_state(self, state): + supported_state = [ + key for key, value in self._state_modifier_keys.items() + if key != 'clear' and value != 'not-applicable' + ] + _api.check_in_list(supported_state, state=state) + + def add_state(self, state): + """ + Add a state to define the widget's behavior. See the + `state_modifier_keys` parameters for details. + + Parameters + ---------- + state : str + Must be a supported state of the selector. See the + `state_modifier_keys` parameters for details. + + Raises + ------ + ValueError + When the state is not supported by the selector. + + """ + self._validate_state(state) + self._state.add(state) + + def remove_state(self, state): + """ + Remove a state to define the widget's behavior. See the + `state_modifier_keys` parameters for details. + + Parameters + ---------- + value : str + Must be a supported state of the selector. See the + `state_modifier_keys` parameters for details. + + Raises + ------ + ValueError + When the state is not supported by the selector. + + """ + self._validate_state(state) + self._state.remove(state) + class SpanSelector(_SelectorWidget): """ @@ -2110,7 +2560,8 @@ def on_select(min: float, max: float) -> Any useblit : bool, default: False If True, use the backend-dependent blitting features for faster - canvas updates. + canvas updates. See the tutorial :doc:`/tutorials/advanced/blitting` + for details. props : dict, optional Dictionary of `matplotlib.patches.Patch` properties. @@ -2141,6 +2592,12 @@ def on_select(min: float, max: float) -> Any Distance in pixels within which the interactive tool handles can be activated. + state_modifier_keys : dict, optional + Keyboard modifiers which affect the widget's behavior. Values + amend the defaults, which are: + + - "clear": Clear the current shape, default: "escape". + drag_from_anywhere : bool, default: False If `True`, the widget can be moved by clicking anywhere within its bounds. @@ -2149,6 +2606,9 @@ def on_select(min: float, max: float) -> Any If `True`, the event triggered outside the span selector will be ignored. + snap_values : 1D array-like, optional + Snap the selector edges to the given values. + Examples -------- >>> import matplotlib.pyplot as plt @@ -2164,14 +2624,20 @@ def on_select(min: float, max: float) -> Any See also: :doc:`/gallery/widgets/span_selector` """ - @_api.rename_parameter("3.5", "rectprops", "props") - @_api.rename_parameter("3.5", "span_stays", "interactive") + @_api.make_keyword_only("3.7", name="minspan") def __init__(self, ax, onselect, direction, minspan=0, useblit=False, props=None, onmove_callback=None, interactive=False, button=None, handle_props=None, grab_range=10, - drag_from_anywhere=False, ignore_event_outside=False): - - super().__init__(ax, onselect, useblit=useblit, button=button) + state_modifier_keys=None, drag_from_anywhere=False, + ignore_event_outside=False, snap_values=None): + + if state_modifier_keys is None: + state_modifier_keys = dict(clear='escape', + square='not-applicable', + center='not-applicable', + rotate='not-applicable') + super().__init__(ax, onselect, useblit=useblit, button=button, + state_modifier_keys=state_modifier_keys) if props is None: props = dict(facecolor='red', alpha=0.5) @@ -2179,9 +2645,8 @@ def __init__(self, ax, onselect, direction, minspan=0, useblit=False, props['animated'] = self.useblit self.direction = direction - - self.visible = True self._extents_on_press = None + self.snap_values = snap_values # self._pressv is deprecated and we don't use it internally anymore # but we maintain it until it is removed @@ -2215,24 +2680,6 @@ def __init__(self, ax, onselect, direction, minspan=0, useblit=False, # prev attribute is deprecated but we still need to maintain it self._prev = (0, 0) - rect = _api.deprecated("3.5")( - property(lambda self: self._selection_artist) - ) - - rectprops = _api.deprecated("3.5")( - property(lambda self: self._props) - ) - - active_handle = _api.deprecate_privatize_attribute("3.5") - - pressv = _api.deprecate_privatize_attribute("3.5") - - span_stays = _api.deprecated("3.5")( - property(lambda self: self._interactive) - ) - - prev = _api.deprecate_privatize_attribute("3.5") - def new_axes(self, ax): """Set SpanSelector to operate on a new Axes.""" self.ax = ax @@ -2317,11 +2764,11 @@ def _press(self, event): # when the press event outside the span, we initially set the # visibility to False and extents to (v, v) # update will be called when setting the extents - self.visible = False + self._visible = False self.extents = v, v # We need to set the visibility back, so the span selector will be # drawn when necessary (span width > 0) - self.visible = True + self._visible = True else: self.set_visible(True) @@ -2481,6 +2928,15 @@ def _contains(self, event): """Return True if event is within the patch.""" return self._selection_artist.contains(event, radius=0)[0] + @staticmethod + def _snap(values, snap_values): + """Snap values to a given array values (snap_values).""" + # take into account machine precision + eps = np.min(np.abs(np.diff(snap_values))) * 1e-12 + return tuple( + snap_values[np.abs(snap_values - v + np.sign(v) * eps).argmin()] + for v in values) + @property def extents(self): """Return extents of the span selector.""" @@ -2495,11 +2951,13 @@ def extents(self): @extents.setter def extents(self, extents): # Update displayed shape + if self.snap_values is not None: + extents = tuple(self._snap(extents, self.snap_values)) self._draw_shape(*extents) if self._interactive: # Update displayed handles self._edge_handles.set_data(self.extents) - self.set_visible(self.visible) + self.set_visible(self._visible) self.update() @@ -2510,7 +2968,7 @@ class ToolLineHandles: Parameters ---------- ax : `matplotlib.axes.Axes` - Matplotlib axes where tool handles are displayed. + Matplotlib Axes where tool handles are displayed. positions : 1D array Positions of handles in data coordinates. direction : {"horizontal", "vertical"} @@ -2519,9 +2977,11 @@ class ToolLineHandles: Additional line properties. See `matplotlib.lines.Line2D`. useblit : bool, default: True Whether to use blitting for faster drawing (if supported by the - backend). + backend). See the tutorial :doc:`/tutorials/advanced/blitting` + for details. """ + @_api.make_keyword_only("3.7", "line_props") def __init__(self, ax, positions, direction, line_props=None, useblit=True): self.ax = ax @@ -2529,9 +2989,11 @@ def __init__(self, ax, positions, direction, line_props=None, _api.check_in_list(['horizontal', 'vertical'], direction=direction) self._direction = direction - if line_props is None: - line_props = {} - line_props.update({'visible': False, 'animated': useblit}) + line_props = { + **(line_props if line_props is not None else {}), + 'visible': False, + 'animated': useblit, + } line_fun = ax.axvline if self.direction == 'horizontal' else ax.axhline @@ -2554,8 +3016,8 @@ def direction(self): def set_data(self, positions): """ - Set x or y positions of handles, depending if the lines are vertical - of horizontal. + Set x- or y-positions of handles, depending on if the lines are + vertical or horizontal. Parameters ---------- @@ -2617,7 +3079,7 @@ class ToolHandles: Parameters ---------- ax : `matplotlib.axes.Axes` - Matplotlib axes where tool handles are displayed. + Matplotlib Axes where tool handles are displayed. x, y : 1D arrays Coordinates of control handles. marker : str, default: 'o' @@ -2626,9 +3088,11 @@ class ToolHandles: Additional marker properties. See `matplotlib.lines.Line2D`. useblit : bool, default: True Whether to use blitting for faster drawing (if supported by the - backend). + backend). See the tutorial :doc:`/tutorials/advanced/blitting` + for details. """ + @_api.make_keyword_only("3.7", "marker") def __init__(self, ax, x, y, marker='o', marker_props=None, useblit=True): self.ax = ax props = {'marker': marker, 'markersize': 7, 'markerfacecolor': 'w', @@ -2701,7 +3165,8 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) useblit : bool, default: False Whether to use blitting for faster drawing (if supported by the - backend). + backend). See the tutorial :doc:`/tutorials/advanced/blitting` + for details. props : dict, optional Properties with which the __ARTIST_NAME__ is drawn. See @@ -2734,15 +3199,18 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) state_modifier_keys : dict, optional Keyboard modifiers which affect the widget's behavior. Values - amend the defaults. + amend the defaults, which are: - "move": Move the existing shape, default: no modifier. - "clear": Clear the current shape, default: "escape". - "square": Make the shape square, default: "shift". - - "center": Make the initial point the center of the shape, - default: "ctrl". + - "center": change the shape around its center, default: "ctrl". + - "rotate": Rotate the shape around its center between -45° and 45°, + default: "r". - "square" and "center" can be combined. + "square" and "center" can be combined. The square shape can be defined + in data or display coordinates as determined by the + ``use_data_coordinates`` argument specified when creating the selector. drag_from_anywhere : bool, default: False If `True`, the widget can be moved by clicking anywhere within @@ -2752,14 +3220,17 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) If `True`, the event triggered outside the span selector will be ignored. + use_data_coordinates : bool, default: False + If `True`, the "square" shape of the selector is defined in + data coordinates instead of display coordinates. """ -@docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( +@_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'rectangle')) class RectangleSelector(_SelectorWidget): """ - Select a rectangular region of an axes. + Select a rectangular region of an Axes. For the cursor to remain responsive you must keep a reference to it. @@ -2780,73 +3251,49 @@ class RectangleSelector(_SelectorWidget): ... print(erelease.xdata, erelease.ydata) >>> props = dict(facecolor='blue', alpha=0.5) >>> rect = mwidgets.RectangleSelector(ax, onselect, interactive=True, - props=props) + ... props=props) >>> fig.show() + >>> rect.add_state('square') See also: :doc:`/gallery/widgets/rectangle_selector` """ - _shape_klass = Rectangle - - @_api.rename_parameter("3.5", "maxdist", "grab_range") - @_api.rename_parameter("3.5", "marker_props", "handle_props") - @_api.rename_parameter("3.5", "rectprops", "props") - @_api.delete_parameter("3.5", "drawtype") - @_api.delete_parameter("3.5", "lineprops") - def __init__(self, ax, onselect, drawtype='box', - minspanx=0, minspany=0, useblit=False, - lineprops=None, props=None, spancoords='data', - button=None, grab_range=10, handle_props=None, - interactive=False, state_modifier_keys=None, - drag_from_anywhere=False, ignore_event_outside=False): + def __init__(self, ax, onselect, *, minspanx=0, minspany=0, useblit=False, + props=None, spancoords='data', button=None, grab_range=10, + handle_props=None, interactive=False, + state_modifier_keys=None, drag_from_anywhere=False, + ignore_event_outside=False, use_data_coordinates=False): super().__init__(ax, onselect, useblit=useblit, button=button, - state_modifier_keys=state_modifier_keys) + state_modifier_keys=state_modifier_keys, + use_data_coordinates=use_data_coordinates) - self.visible = True self._interactive = interactive self.drag_from_anywhere = drag_from_anywhere self.ignore_event_outside = ignore_event_outside + self._rotation = 0.0 + self._aspect_ratio_correction = 1.0 + + # State to allow the option of an interactive selector that can't be + # interactively drawn. This is used in PolygonSelector as an + # interactive bounding box to allow the polygon to be easily resized + self._allow_creation = True - if drawtype == 'none': # draw a line but make it invisible - _api.warn_deprecated( - "3.5", message="Support for drawtype='none' is deprecated " - "since %(since)s and will be removed " - "%(removal)s." - "Use props=dict(visible=False) instead.") - drawtype = 'line' - self.visible = False - - if drawtype == 'box': - if props is None: - props = dict(facecolor='red', edgecolor='black', - alpha=0.2, fill=True) - props['animated'] = self.useblit - self.visible = props.pop('visible', self.visible) - self._props = props - to_draw = self._shape_klass((0, 0), 0, 1, visible=False, - **self._props) - self.ax.add_patch(to_draw) - if drawtype == 'line': - _api.warn_deprecated( - "3.5", message="Support for drawtype='line' is deprecated " - "since %(since)s and will be removed " - "%(removal)s.") - if lineprops is None: - lineprops = dict(color='black', linestyle='-', - linewidth=2, alpha=0.5) - lineprops['animated'] = self.useblit - self._props = lineprops - to_draw = Line2D([0, 0], [0, 0], visible=False, **self._props) - self.ax.add_line(to_draw) + if props is None: + props = dict(facecolor='red', edgecolor='black', + alpha=0.2, fill=True) + self._props = {**props, 'animated': self.useblit} + self._visible = self._props.pop('visible', self._visible) + to_draw = self._init_shape(**self._props) + self.ax.add_patch(to_draw) self._selection_artist = to_draw + self._set_aspect_ratio_correction() self.minspanx = minspanx self.minspany = minspany _api.check_in_list(['data', 'pixels'], spancoords=spancoords) self.spancoords = spancoords - self._drawtype = drawtype self.grab_range = grab_range @@ -2877,48 +3324,43 @@ def __init__(self, ax, onselect, drawtype='box', self._extents_on_press = None - to_draw = _api.deprecated("3.5")( - property(lambda self: self._selection_artist) - ) - - drawtype = _api.deprecate_privatize_attribute("3.5") - - active_handle = _api.deprecate_privatize_attribute("3.5") - - interactive = _api.deprecate_privatize_attribute("3.5") - - maxdist = _api.deprecated("3.5", name="maxdist", alternative="grab_range")( - property(lambda self: self.grab_range, - lambda self, value: setattr(self, "grab_range", value))) - @property def _handles_artists(self): return (*self._center_handle.artists, *self._corner_handles.artists, *self._edge_handles.artists) + def _init_shape(self, **props): + return Rectangle((0, 0), 0, 1, visible=False, + rotation_point='center', **props) + def _press(self, event): """Button press event handler.""" # make the drawn box/line visible get the click-coordinates, # button, ... if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) - self._extents_on_press = self.extents else: self._active_handle = None - if self._active_handle is None or not self._interactive: + if ((self._active_handle is None or not self._interactive) and + self._allow_creation): # Clear previous rectangle before drawing new rectangle. self.update() - if self._active_handle is None and not self.ignore_event_outside: + if (self._active_handle is None and not self.ignore_event_outside and + self._allow_creation): x = event.xdata y = event.ydata - self.visible = False + self._visible = False self.extents = x, x, y, y - self.visible = True + self._visible = True else: self.set_visible(True) + self._extents_on_press = self.extents + self._rotation_on_press = self._rotation + self._set_aspect_ratio_correction() + return False def _release(self, event): @@ -2954,14 +3396,11 @@ def _release(self, event): spancoords=self.spancoords) # check if drawn distance (if it exists) is not too small in # either x or y-direction - minspanxy = (spanx <= self.minspanx or spany <= self.minspany) - if (self._drawtype != 'none' and minspanxy): - for artist in self.artists: - artist.set_visible(False) + if spanx <= self.minspanx or spany <= self.minspany: if self._selection_completed: # Call onselect, only when the selection is already existing self.onselect(self._eventpress, self._eventrelease) - self._selection_completed = False + self._clear_without_update() else: self.onselect(self._eventpress, self._eventrelease) self._selection_completed = True @@ -2973,102 +3412,136 @@ def _release(self, event): return False def _onmove(self, event): - """Motion notify event handler.""" + """ + Motion notify event handler. - state = self._state | self._default_state - # resize an existing shape - if self._active_handle and self._active_handle != 'C': - x0, x1, y0, y1 = self._extents_on_press + This can do one of four things: + - Translate + - Rotate + - Re-size + - Continue the creation of a new shape + """ + eventpress = self._eventpress + # The calculations are done for rotation at zero: we apply inverse + # transformation to events except when we rotate and move + state = self._state + rotate = ('rotate' in state and + self._active_handle in self._corner_order) + move = self._active_handle == 'C' + resize = self._active_handle and not move + + if resize: + inv_tr = self._get_rotation_transform().inverted() + event.xdata, event.ydata = inv_tr.transform( + [event.xdata, event.ydata]) + eventpress.xdata, eventpress.ydata = inv_tr.transform( + [eventpress.xdata, eventpress.ydata] + ) + + dx = event.xdata - eventpress.xdata + dy = event.ydata - eventpress.ydata + # refmax is used when moving the corner handle with the square state + # and is the maximum between refx and refy + refmax = None + if self._use_data_coordinates: + refx, refy = dx, dy + else: + # Get dx/dy in display coordinates + refx = event.x - eventpress.x + refy = event.y - eventpress.y + + x0, x1, y0, y1 = self._extents_on_press + # rotate an existing shape + if rotate: + # calculate angle abc + a = np.array([eventpress.xdata, eventpress.ydata]) + b = np.array(self.center) + c = np.array([event.xdata, event.ydata]) + angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) - + np.arctan2(a[1]-b[1], a[0]-b[0])) + self.rotation = np.rad2deg(self._rotation_on_press + angle) + + elif resize: size_on_press = [x1 - x0, y1 - y0] center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2] - dx = event.xdata - self._eventpress.xdata - dy = event.ydata - self._eventpress.ydata - - # change sign of relative changes to simplify calculation - # Switch variables so that only x1 and/or y1 are updated on move - x_factor = y_factor = 1 - if 'W' in self._active_handle: - x_factor *= -1 - dx *= x_factor - x0 = x1 - if 'S' in self._active_handle: - y_factor *= -1 - dy *= y_factor - y0 = y1 # Keeping the center fixed if 'center' in state: + # hh, hw are half-height and half-width if 'square' in state: - # Force the same change in dx and dy - if self._active_handle in ['E', 'W']: - # using E, W handle we need to update dy accordingly - dy = dx - elif self._active_handle in ['S', 'N']: - # using S, N handle, we need to update dx accordingly - dx = dy + # when using a corner, find which reference to use + if self._active_handle in self._corner_order: + refmax = max(refx, refy, key=abs) + if self._active_handle in ['E', 'W'] or refmax == refx: + hw = event.xdata - center[0] + hh = hw / self._aspect_ratio_correction else: - dx = dy = max(dx, dy, key=abs) - - # new half-width and half-height - hw = size_on_press[0] / 2 + dx - hh = size_on_press[1] / 2 + dy - - if 'square' not in state: + hh = event.ydata - center[1] + hw = hh * self._aspect_ratio_correction + else: + hw = size_on_press[0] / 2 + hh = size_on_press[1] / 2 # cancel changes in perpendicular direction - if self._active_handle in ['E', 'W']: - hh = size_on_press[1] / 2 - if self._active_handle in ['N', 'S']: - hw = size_on_press[0] / 2 + if self._active_handle in ['E', 'W'] + self._corner_order: + hw = abs(event.xdata - center[0]) + if self._active_handle in ['N', 'S'] + self._corner_order: + hh = abs(event.ydata - center[1]) x0, x1, y0, y1 = (center[0] - hw, center[0] + hw, center[1] - hh, center[1] + hh) else: - # Keeping the opposite corner/edge fixed + # change sign of relative changes to simplify calculation + # Switch variables so that x1 and/or y1 are updated on move + if 'W' in self._active_handle: + x0 = x1 + if 'S' in self._active_handle: + y0 = y1 + if self._active_handle in ['E', 'W'] + self._corner_order: + x1 = event.xdata + if self._active_handle in ['N', 'S'] + self._corner_order: + y1 = event.ydata if 'square' in state: - dx = dy = max(dx, dy, key=abs) - x1 = x0 + x_factor * (dx + size_on_press[0]) - y1 = y0 + y_factor * (dy + size_on_press[1]) - else: - if self._active_handle in ['E', 'W'] + self._corner_order: - x1 = event.xdata - if self._active_handle in ['N', 'S'] + self._corner_order: - y1 = event.ydata + # when using a corner, find which reference to use + if self._active_handle in self._corner_order: + refmax = max(refx, refy, key=abs) + if self._active_handle in ['E', 'W'] or refmax == refx: + sign = np.sign(event.ydata - y0) + y1 = y0 + sign * abs(x1 - x0) / \ + self._aspect_ratio_correction + else: + sign = np.sign(event.xdata - x0) + x1 = x0 + sign * abs(y1 - y0) * \ + self._aspect_ratio_correction - # move existing shape - elif (self._active_handle == 'C' or - (self.drag_from_anywhere and self._contains(event)) and - self._extents_on_press is not None): + elif move: x0, x1, y0, y1 = self._extents_on_press - dx = event.xdata - self._eventpress.xdata - dy = event.ydata - self._eventpress.ydata + dx = event.xdata - eventpress.xdata + dy = event.ydata - eventpress.ydata x0 += dx x1 += dx y0 += dy y1 += dy - # new shape else: + # Create a new shape + self._rotation = 0 # Don't create a new rectangle if there is already one when # ignore_event_outside=True - if self.ignore_event_outside and self._selection_completed: + if ((self.ignore_event_outside and self._selection_completed) or + not self._allow_creation): return - center = [self._eventpress.xdata, self._eventpress.ydata] - center_pix = [self._eventpress.x, self._eventpress.y] + center = [eventpress.xdata, eventpress.ydata] dx = (event.xdata - center[0]) / 2. dy = (event.ydata - center[1]) / 2. # square shape if 'square' in state: - dx_pix = abs(event.x - center_pix[0]) - dy_pix = abs(event.y - center_pix[1]) - if not dx_pix: - return - maxd = max(abs(dx_pix), abs(dy_pix)) - if abs(dx_pix) < maxd: - dx *= maxd / (abs(dx_pix) + 1e-6) - if abs(dy_pix) < maxd: - dy *= maxd / (abs(dy_pix) + 1e-6) + refmax = max(refx, refy, key=abs) + if refmax == refx: + dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction + else: + dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction # from center if 'center' in state: @@ -3087,45 +3560,64 @@ def _onmove(self, event): @property def _rect_bbox(self): - if self._drawtype == 'box': - x0 = self._selection_artist.get_x() - y0 = self._selection_artist.get_y() - width = self._selection_artist.get_width() - height = self._selection_artist.get_height() - return x0, y0, width, height + return self._selection_artist.get_bbox().bounds + + def _set_aspect_ratio_correction(self): + aspect_ratio = self.ax._get_aspect_ratio() + self._selection_artist._aspect_ratio_correction = aspect_ratio + if self._use_data_coordinates: + self._aspect_ratio_correction = 1 else: - x, y = self._selection_artist.get_data() - x0, x1 = min(x), max(x) - y0, y1 = min(y), max(y) - return x0, y0, x1 - x0, y1 - y0 + self._aspect_ratio_correction = aspect_ratio + + def _get_rotation_transform(self): + aspect_ratio = self.ax._get_aspect_ratio() + return Affine2D().translate(-self.center[0], -self.center[1]) \ + .scale(1, aspect_ratio) \ + .rotate(self._rotation) \ + .scale(1, 1 / aspect_ratio) \ + .translate(*self.center) @property def corners(self): - """Corners of rectangle from lower left, moving clockwise.""" + """ + Corners of rectangle in data coordinates from lower left, + moving clockwise. + """ x0, y0, width, height = self._rect_bbox xc = x0, x0 + width, x0 + width, x0 yc = y0, y0, y0 + height, y0 + height - return xc, yc + transform = self._get_rotation_transform() + coords = transform.transform(np.array([xc, yc]).T).T + return coords[0], coords[1] @property def edge_centers(self): - """Midpoint of rectangle edges from left, moving anti-clockwise.""" + """ + Midpoint of rectangle edges in data coordinates from left, + moving anti-clockwise. + """ x0, y0, width, height = self._rect_bbox w = width / 2. h = height / 2. xe = x0, x0 + w, x0 + width, x0 + w ye = y0 + h, y0, y0 + h, y0 + height - return xe, ye + transform = self._get_rotation_transform() + coords = transform.transform(np.array([xe, ye]).T).T + return coords[0], coords[1] @property def center(self): - """Center of rectangle.""" + """Center of rectangle in data coordinates.""" x0, y0, width, height = self._rect_bbox return x0 + width / 2., y0 + height / 2. @property def extents(self): - """Return (xmin, xmax, ymin, ymax).""" + """ + Return (xmin, xmax, ymin, ymax) in data coordinates as defined by the + bounding box before rotation. + """ x0, y0, width, height = self._rect_bbox xmin, xmax = sorted([x0, x0 + width]) ymin, ymax = sorted([y0, y0 + height]) @@ -3139,11 +3631,27 @@ def extents(self, extents): # Update displayed handles self._corner_handles.set_data(*self.corners) self._edge_handles.set_data(*self.edge_centers) - self._center_handle.set_data(*self.center) - self.set_visible(self.visible) + x, y = self.center + self._center_handle.set_data([x], [y]) + self.set_visible(self._visible) self.update() - draw_shape = _api.deprecate_privatize_attribute('3.5') + @property + def rotation(self): + """ + Rotation in degree in interval [-45°, 45°]. The rotation is limited in + range to keep the implementation simple. + """ + return np.rad2deg(self._rotation) + + @rotation.setter + def rotation(self, value): + # Restrict to a limited range of rotation [-45°, 45°] to avoid changing + # order of handles + if -45 <= value and value <= 45: + self._rotation = np.deg2rad(value) + # call extents setter to draw shape and update handles positions + self.extents = self.extents def _draw_shape(self, extents): x0, x1, y0, y1 = extents @@ -3157,14 +3665,11 @@ def _draw_shape(self, extents): xmax = min(xmax, xlim[1]) ymax = min(ymax, ylim[1]) - if self._drawtype == 'box': - self._selection_artist.set_x(xmin) - self._selection_artist.set_y(ymin) - self._selection_artist.set_width(xmax - xmin) - self._selection_artist.set_height(ymax - ymin) - - elif self._drawtype == 'line': - self._selection_artist.set_data([xmin, xmax], [ymin, ymax]) + self._selection_artist.set_x(xmin) + self._selection_artist.set_y(ymin) + self._selection_artist.set_width(xmax - xmin) + self._selection_artist.set_height(ymax - ymin) + self._selection_artist.set_angle(self.rotation) def _set_active_handle(self, event): """Set active handle based on the location of the mouse event.""" @@ -3179,8 +3684,7 @@ def _set_active_handle(self, event): elif m_dist < self.grab_range * 2: # Prioritise center handle over other handles self._active_handle = 'C' - elif (c_dist > self.grab_range and - e_dist > self.grab_range): + elif c_dist > self.grab_range and e_dist > self.grab_range: # Not close to any handles if self.drag_from_anywhere and self._contains(event): # Check if we've clicked inside the region @@ -3204,9 +3708,8 @@ def geometry(self): """ Return an array of shape (2, 5) containing the x (``RectangleSelector.geometry[1, :]``) and - y (``RectangleSelector.geometry[0, :]``) coordinates - of the four corners of the rectangle starting and ending - in the top left corner. + y (``RectangleSelector.geometry[0, :]``) data coordinates of the four + corners of the rectangle starting and ending in the top left corner. """ if hasattr(self._selection_artist, 'get_verts'): xfm = self.ax.transData.inverted() @@ -3216,11 +3719,11 @@ def geometry(self): return np.array(self._selection_artist.get_data()) -@docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( +@_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'ellipse')) class EllipseSelector(RectangleSelector): """ - Select an elliptical region of an axes. + Select an elliptical region of an Axes. For the cursor to remain responsive you must keep a reference to it. @@ -3234,9 +3737,8 @@ class EllipseSelector(RectangleSelector): -------- :doc:`/gallery/widgets/rectangle_selector` """ - - _shape_klass = Ellipse - draw_shape = _api.deprecate_privatize_attribute('3.5') + def _init_shape(self, **props): + return Ellipse((0, 0), 0, 1, visible=False, **props) def _draw_shape(self, extents): x0, x1, y0, y1 = extents @@ -3246,28 +3748,17 @@ def _draw_shape(self, extents): a = (xmax - xmin) / 2. b = (ymax - ymin) / 2. - if self._drawtype == 'box': - self._selection_artist.center = center - self._selection_artist.width = 2 * a - self._selection_artist.height = 2 * b - else: - rad = np.deg2rad(np.arange(31) * 12) - x = a * np.cos(rad) + center[0] - y = b * np.sin(rad) + center[1] - self._selection_artist.set_data(x, y) + self._selection_artist.center = center + self._selection_artist.width = 2 * a + self._selection_artist.height = 2 * b + self._selection_artist.angle = self.rotation @property def _rect_bbox(self): - if self._drawtype == 'box': - x, y = self._selection_artist.center - width = self._selection_artist.width - height = self._selection_artist.height - return x - width / 2., y - height / 2., width, height - else: - x, y = self._selection_artist.get_data() - x0, x1 = min(x), max(x) - y0, y1 = min(y), max(y) - return x0, y0, x1 - x0, y1 - y0 + x, y = self._selection_artist.center + width = self._selection_artist.width + height = self._selection_artist.height + return x - width / 2., y - height / 2., width, height class LassoSelector(_SelectorWidget): @@ -3281,7 +3772,7 @@ class LassoSelector(_SelectorWidget): In contrast to `Lasso`, `LassoSelector` is written with an interface similar to `RectangleSelector` and `SpanSelector`, and will continue to - interact with the axes until disconnected. + interact with the Axes until disconnected. Example usage:: @@ -3295,13 +3786,14 @@ def onselect(verts): Parameters ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. onselect : function Whenever the lasso is released, the *onselect* function is called and passed the vertices of the selected path. useblit : bool, default: True Whether to use blitting for faster drawing (if supported by the - backend). + backend). See the tutorial :doc:`/tutorials/advanced/blitting` + for details. props : dict, optional Properties with which the line is drawn, see `matplotlib.lines.Line2D` for valid properties. Default values are defined in ``mpl.rcParams``. @@ -3310,31 +3802,24 @@ def onselect(verts): which corresponds to all buttons. """ - @_api.rename_parameter("3.5", "lineprops", "props") - def __init__(self, ax, onselect=None, useblit=True, props=None, - button=None): + @_api.make_keyword_only("3.7", name="useblit") + def __init__(self, ax, onselect, useblit=True, props=None, button=None): super().__init__(ax, onselect, useblit=useblit, button=button) self.verts = None - if props is None: - props = dict() - # self.useblit may be != useblit, if the canvas doesn't support blit. - props.update(animated=self.useblit, visible=False) + props = { + **(props if props is not None else {}), + # Note that self.useblit may be != useblit, if the canvas doesn't + # support blitting. + 'animated': self.useblit, 'visible': False, + } line = Line2D([], [], **props) self.ax.add_line(line) self._selection_artist = line - @_api.deprecated("3.5", alternative="press") - def onpress(self, event): - self.press(event) - def _press(self, event): self.verts = [self._get_data(event)] self._selection_artist.set_visible(True) - @_api.deprecated("3.5", alternative="release") - def onrelease(self, event): - self.release(event) - def _release(self, event): if self.verts is not None: self.verts.append(self._get_data(event)) @@ -3354,7 +3839,7 @@ def _onmove(self, event): class PolygonSelector(_SelectorWidget): """ - Select a polygon region of an axes. + Select a polygon region of an Axes. Place vertices with each mouse click, and make the selection by completing the polygon (clicking on the first vertex). Once drawn individual vertices @@ -3365,7 +3850,7 @@ class PolygonSelector(_SelectorWidget): - Hold *ctrl* and click and drag a vertex to reposition it before the polygon has been completed. - - Hold the *shift* key and click and drag anywhere in the axes to move + - Hold the *shift* key and click and drag anywhere in the Axes to move all vertices. - Press the *esc* key to start a new polygon. @@ -3374,7 +3859,7 @@ class PolygonSelector(_SelectorWidget): Parameters ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. onselect : function When a polygon is completed or modified after completion, @@ -3383,7 +3868,8 @@ class PolygonSelector(_SelectorWidget): useblit : bool, default: False Whether to use blitting for faster drawing (if supported by the - backend). + backend). See the tutorial :doc:`/tutorials/advanced/blitting` + for details. props : dict, optional Properties with which the line is drawn, see `matplotlib.lines.Line2D` @@ -3403,8 +3889,22 @@ class PolygonSelector(_SelectorWidget): A vertex is selected (to complete the polygon or to move a vertex) if the mouse click is within *grab_range* pixels of the vertex. + draw_bounding_box : bool, optional + If `True`, a bounding box will be drawn around the polygon selector + once it is complete. This box can be used to move and resize the + selector. + + box_handle_props : dict, optional + Properties to set for the box handles. See the documentation for the + *handle_props* argument to `RectangleSelector` for more info. + + box_props : dict, optional + Properties to set for the box. See the documentation for the *props* + argument to `RectangleSelector` for more info. + Examples -------- + :doc:`/gallery/widgets/polygon_selector_simple` :doc:`/gallery/widgets/polygon_selector_demo` Notes @@ -3414,11 +3914,11 @@ class PolygonSelector(_SelectorWidget): point. """ - @_api.rename_parameter("3.5", "lineprops", "props") - @_api.rename_parameter("3.5", "markerprops", "handle_props") - @_api.rename_parameter("3.5", "vertex_select_radius", "grab_range") + @_api.make_keyword_only("3.7", name="useblit") def __init__(self, ax, onselect, useblit=False, - props=None, handle_props=None, grab_range=10): + props=None, handle_props=None, grab_range=10, *, + draw_bounding_box=False, box_handle_props=None, + box_props=None): # The state modifiers 'move', 'square', and 'center' are expected by # _SelectorWidget but are not supported by PolygonSelector # Note: could not use the existing 'move' state modifier in-place of @@ -3427,25 +3927,24 @@ def __init__(self, ax, onselect, useblit=False, state_modifier_keys = dict(clear='escape', move_vertex='control', move_all='shift', move='not-applicable', square='not-applicable', - center='not-applicable') + center='not-applicable', + rotate='not-applicable') super().__init__(ax, onselect, useblit=useblit, state_modifier_keys=state_modifier_keys) - self._xs, self._ys = [0], [0] + self._xys = [(0, 0)] if props is None: props = dict(color='k', linestyle='-', linewidth=2, alpha=0.5) - props['animated'] = self.useblit - self._props = props - line = Line2D(self._xs, self._ys, **self._props) + self._props = {**props, 'animated': self.useblit} + self._selection_artist = line = Line2D([], [], **self._props) self.ax.add_line(line) - self._selection_artist = line if handle_props is None: handle_props = dict(markeredgecolor='k', markerfacecolor=self._props.get('color', 'k')) self._handle_props = handle_props - self._polygon_handles = ToolHandles(self.ax, self._xs, self._ys, + self._polygon_handles = ToolHandles(self.ax, [], [], useblit=self.useblit, marker_props=self._handle_props) @@ -3453,20 +3952,74 @@ def __init__(self, ax, onselect, useblit=False, self.grab_range = grab_range self.set_visible(True) + self._draw_box = draw_bounding_box + self._box = None + + if box_handle_props is None: + box_handle_props = {} + self._box_handle_props = self._handle_props.update(box_handle_props) + self._box_props = box_props + + def _get_bbox(self): + return self._selection_artist.get_bbox() + + def _add_box(self): + self._box = RectangleSelector(self.ax, + onselect=lambda *args, **kwargs: None, + useblit=self.useblit, + grab_range=self.grab_range, + handle_props=self._box_handle_props, + props=self._box_props, + interactive=True) + self._box._state_modifier_keys.pop('rotate') + self._box.connect_event('motion_notify_event', self._scale_polygon) + self._update_box() + # Set state that prevents the RectangleSelector from being created + # by the user + self._box._allow_creation = False + self._box._selection_completed = True + self._draw_polygon() - line = _api.deprecated("3.5")( - property(lambda self: self._selection_artist) - ) + def _remove_box(self): + if self._box is not None: + self._box.set_visible(False) + self._box = None - vertex_select_radius = _api.deprecated("3.5", name="vertex_select_radius", - alternative="grab_range")( - property(lambda self: self.grab_range, - lambda self, value: setattr(self, "grab_range", value)) - ) + def _update_box(self): + # Update selection box extents to the extents of the polygon + if self._box is not None: + bbox = self._get_bbox() + self._box.extents = [bbox.x0, bbox.x1, bbox.y0, bbox.y1] + # Save a copy + self._old_box_extents = self._box.extents - @property - def _nverts(self): - return len(self._xs) + def _scale_polygon(self, event): + """ + Scale the polygon selector points when the bounding box is moved or + scaled. + + This is set as a callback on the bounding box RectangleSelector. + """ + if not self._selection_completed: + return + + if self._old_box_extents == self._box.extents: + return + + # Create transform from old box to new box + x1, y1, w1, h1 = self._box._rect_bbox + old_bbox = self._get_bbox() + t = (transforms.Affine2D() + .translate(-old_bbox.x0, -old_bbox.y0) + .scale(1 / old_bbox.width, 1 / old_bbox.height) + .scale(w1, h1) + .translate(x1, y1)) + + # Update polygon verts. Must be a list of tuples for consistency. + new_verts = [(x, y) for x, y in t.transform(np.array(self.verts))] + self._xys = [*new_verts, new_verts[0]] + self._draw_polygon() + self._old_box_extents = self._box.extents @property def _handles_artists(self): @@ -3474,38 +4027,35 @@ def _handles_artists(self): def _remove_vertex(self, i): """Remove vertex with index i.""" - if (self._nverts > 2 and + if (len(self._xys) > 2 and self._selection_completed and - i in (0, self._nverts - 1)): + i in (0, len(self._xys) - 1)): # If selecting the first or final vertex, remove both first and # last vertex as they are the same for a closed polygon - self._xs.pop(0) - self._ys.pop(0) - self._xs.pop(-1) - self._ys.pop(-1) + self._xys.pop(0) + self._xys.pop(-1) # Close the polygon again by appending the new first vertex to the # end - self._xs.append(self._xs[0]) - self._ys.append(self._ys[0]) + self._xys.append(self._xys[0]) else: - self._xs.pop(i) - self._ys.pop(i) - if self._nverts <= 2: + self._xys.pop(i) + if len(self._xys) <= 2: # If only one point left, return to incomplete state to let user # start drawing again self._selection_completed = False + self._remove_box() def _press(self, event): """Button press event handler.""" # Check for selection of a tool handle. if ((self._selection_completed or 'move_vertex' in self._state) - and len(self._xs) > 0): + and len(self._xys) > 0): h_idx, h_dist = self._polygon_handles.closest(event.x, event.y) if h_dist < self.grab_range: self._active_handle_idx = h_idx # Save the vertex positions at the time of the press event (needed to # support the 'move_all' state modifier). - self._xs_at_press, self._ys_at_press = self._xs.copy(), self._ys.copy() + self._xys_at_press = self._xys.copy() def _release(self, event): """Button release event handler.""" @@ -3517,17 +4067,16 @@ def _release(self, event): self._active_handle_idx = -1 # Complete the polygon. - elif (len(self._xs) > 3 - and self._xs[-1] == self._xs[0] - and self._ys[-1] == self._ys[0]): + elif len(self._xys) > 3 and self._xys[-1] == self._xys[0]: self._selection_completed = True + if self._draw_box and self._box is None: + self._add_box() # Place new vertex. elif (not self._selection_completed and 'move_all' not in self._state and 'move_vertex' not in self._state): - self._xs.insert(-1, event.xdata) - self._ys.insert(-1, event.ydata) + self._xys.insert(-1, (event.xdata, event.ydata)) if self._selection_completed: self.onselect(self.verts) @@ -3549,19 +4098,19 @@ def _onmove(self, event): # Move the active vertex (ToolHandle). if self._active_handle_idx >= 0: idx = self._active_handle_idx - self._xs[idx], self._ys[idx] = event.xdata, event.ydata + self._xys[idx] = event.xdata, event.ydata # Also update the end of the polygon line if the first vertex is # the active handle and the polygon is completed. if idx == 0 and self._selection_completed: - self._xs[-1], self._ys[-1] = event.xdata, event.ydata + self._xys[-1] = event.xdata, event.ydata # Move all vertices. elif 'move_all' in self._state and self._eventpress: dx = event.xdata - self._eventpress.xdata dy = event.ydata - self._eventpress.ydata - for k in range(len(self._xs)): - self._xs[k] = self._xs_at_press[k] + dx - self._ys[k] = self._ys_at_press[k] + dy + for k in range(len(self._xys)): + x_at_press, y_at_press = self._xys_at_press[k] + self._xys[k] = x_at_press + dx, y_at_press + dy # Do nothing if completed or waiting for a move. elif (self._selection_completed @@ -3571,15 +4120,14 @@ def _onmove(self, event): # Position pending vertex. else: # Calculate distance to the start vertex. - x0, y0 = self._selection_artist.get_transform().transform( - (self._xs[0], self._ys[0]) - ) + x0, y0 = \ + self._selection_artist.get_transform().transform(self._xys[0]) v0_dist = np.hypot(x0 - event.x, y0 - event.y) # Lock on to the start vertex if near it and ready to complete. - if len(self._xs) > 3 and v0_dist < self.grab_range: - self._xs[-1], self._ys[-1] = self._xs[0], self._ys[0] + if len(self._xys) > 3 and v0_dist < self.grab_range: + self._xys[-1] = self._xys[0] else: - self._xs[-1], self._ys[-1] = event.xdata, event.ydata + self._xys[-1] = event.xdata, event.ydata self._draw_polygon() @@ -3590,7 +4138,7 @@ def _on_key_press(self, event): if (not self._selection_completed and ('move_vertex' in self._state or 'move_all' in self._state)): - self._xs, self._ys = self._xs[:-1], self._ys[:-1] + self._xys.pop() self._draw_polygon() def _on_key_release(self, event): @@ -3599,37 +4147,53 @@ def _on_key_release(self, event): # 'move_all' mode (by checking the released key) if (not self._selection_completed and - (event.key == self.state_modifier_keys.get('move_vertex') - or event.key == self.state_modifier_keys.get('move_all'))): - self._xs.append(event.xdata) - self._ys.append(event.ydata) + (event.key == self._state_modifier_keys.get('move_vertex') + or event.key == self._state_modifier_keys.get('move_all'))): + self._xys.append((event.xdata, event.ydata)) self._draw_polygon() # Reset the polygon if the released key is the 'clear' key. - elif event.key == self.state_modifier_keys.get('clear'): + elif event.key == self._state_modifier_keys.get('clear'): event = self._clean_event(event) - self._xs, self._ys = [event.xdata], [event.ydata] + self._xys = [(event.xdata, event.ydata)] self._selection_completed = False + self._remove_box() self.set_visible(True) def _draw_polygon(self): """Redraw the polygon based on the new vertex positions.""" - self._selection_artist.set_data(self._xs, self._ys) + xs, ys = zip(*self._xys) if self._xys else ([], []) + self._selection_artist.set_data(xs, ys) + self._update_box() # Only show one tool handle at the start and end vertex of the polygon # if the polygon is completed or the user is locked on to the start # vertex. if (self._selection_completed - or (len(self._xs) > 3 - and self._xs[-1] == self._xs[0] - and self._ys[-1] == self._ys[0])): - self._polygon_handles.set_data(self._xs[:-1], self._ys[:-1]) + or (len(self._xys) > 3 + and self._xys[-1] == self._xys[0])): + self._polygon_handles.set_data(xs[:-1], ys[:-1]) else: - self._polygon_handles.set_data(self._xs, self._ys) + self._polygon_handles.set_data(xs, ys) self.update() @property def verts(self): """The polygon vertices, as a list of ``(x, y)`` pairs.""" - return list(zip(self._xs[:-1], self._ys[:-1])) + return self._xys[:-1] + + @verts.setter + def verts(self, xys): + """ + Set the polygon vertices. + + This will remove any preexisting vertices, creating a complete polygon + with the new vertices. + """ + self._xys = [*xys, xys[0]] + self._selection_completed = True + self.set_visible(True) + if self._draw_box and self._box is None: + self._add_box() + self._draw_polygon() class Lasso(AxesWidget): @@ -3645,18 +4209,20 @@ class Lasso(AxesWidget): Parameters ---------- ax : `~matplotlib.axes.Axes` - The parent axes for the widget. + The parent Axes for the widget. xy : (float, float) Coordinates of the start of the lasso. - useblit : bool, default: True - Whether to use blitting for faster drawing (if supported by the - backend). callback : callable Whenever the lasso is released, the *callback* function is called and passed the vertices of the selected path. + useblit : bool, default: True + Whether to use blitting for faster drawing (if supported by the + backend). See the tutorial :doc:`/tutorials/advanced/blitting` + for details. """ - def __init__(self, ax, xy, callback=None, useblit=True): + @_api.make_keyword_only("3.7", name="useblit") + def __init__(self, ax, xy, callback, useblit=True): super().__init__(ax) self.useblit = useblit and self.canvas.supports_blit diff --git a/lib/mpl_toolkits/axes_grid/__init__.py b/lib/mpl_toolkits/axes_grid/__init__.py deleted file mode 100644 index 2a906781a1db..000000000000 --- a/lib/mpl_toolkits/axes_grid/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from . import axes_size as Size -from .axes_divider import Divider, SubplotDivider, make_axes_locatable -from .axes_grid import Grid, ImageGrid, AxesGrid -from matplotlib import _api -_api.warn_deprecated(since='2.1', - name='mpl_toolkits.axes_grid', - alternative='mpl_toolkits.axes_grid1 and' - ' mpl_toolkits.axisartist, which provide' - ' the same functionality', - obj_type='module') diff --git a/lib/mpl_toolkits/axes_grid/anchored_artists.py b/lib/mpl_toolkits/axes_grid/anchored_artists.py deleted file mode 100644 index f486805d98c6..000000000000 --- a/lib/mpl_toolkits/axes_grid/anchored_artists.py +++ /dev/null @@ -1,6 +0,0 @@ -from matplotlib.offsetbox import AnchoredOffsetbox, AuxTransformBox, VPacker,\ - TextArea, AnchoredText, DrawingArea, AnnotationBbox - -from mpl_toolkits.axes_grid1.anchored_artists import \ - AnchoredDrawingArea, AnchoredAuxTransformBox, \ - AnchoredEllipse, AnchoredSizeBar diff --git a/lib/mpl_toolkits/axes_grid/angle_helper.py b/lib/mpl_toolkits/axes_grid/angle_helper.py deleted file mode 100644 index fa14e7f5897b..000000000000 --- a/lib/mpl_toolkits/axes_grid/angle_helper.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.angle_helper import * diff --git a/lib/mpl_toolkits/axes_grid/axes_divider.py b/lib/mpl_toolkits/axes_grid/axes_divider.py deleted file mode 100644 index 33b4e512c9a0..000000000000 --- a/lib/mpl_toolkits/axes_grid/axes_divider.py +++ /dev/null @@ -1,3 +0,0 @@ -from mpl_toolkits.axes_grid1.axes_divider import ( - AxesDivider, AxesLocator, Divider, SubplotDivider, make_axes_locatable) -from mpl_toolkits.axisartist.axislines import Axes diff --git a/lib/mpl_toolkits/axes_grid/axes_grid.py b/lib/mpl_toolkits/axes_grid/axes_grid.py deleted file mode 100644 index 893b7800ccde..000000000000 --- a/lib/mpl_toolkits/axes_grid/axes_grid.py +++ /dev/null @@ -1,2 +0,0 @@ -from mpl_toolkits.axisartist.axes_grid import ( - AxesGrid, CbarAxes, Grid, ImageGrid) diff --git a/lib/mpl_toolkits/axes_grid/axes_rgb.py b/lib/mpl_toolkits/axes_grid/axes_rgb.py deleted file mode 100644 index 30f7e42743f9..000000000000 --- a/lib/mpl_toolkits/axes_grid/axes_rgb.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.axes_rgb import * diff --git a/lib/mpl_toolkits/axes_grid/axes_size.py b/lib/mpl_toolkits/axes_grid/axes_size.py deleted file mode 100644 index 742f7fe6347c..000000000000 --- a/lib/mpl_toolkits/axes_grid/axes_size.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axes_grid1.axes_size import * diff --git a/lib/mpl_toolkits/axes_grid/axis_artist.py b/lib/mpl_toolkits/axes_grid/axis_artist.py deleted file mode 100644 index 11180b10bfef..000000000000 --- a/lib/mpl_toolkits/axes_grid/axis_artist.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.axis_artist import * diff --git a/lib/mpl_toolkits/axes_grid/axisline_style.py b/lib/mpl_toolkits/axes_grid/axisline_style.py deleted file mode 100644 index 0c846e22afa0..000000000000 --- a/lib/mpl_toolkits/axes_grid/axisline_style.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.axisline_style import * diff --git a/lib/mpl_toolkits/axes_grid/axislines.py b/lib/mpl_toolkits/axes_grid/axislines.py deleted file mode 100644 index a8ceb9cc28ad..000000000000 --- a/lib/mpl_toolkits/axes_grid/axislines.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.axislines import * diff --git a/lib/mpl_toolkits/axes_grid/clip_path.py b/lib/mpl_toolkits/axes_grid/clip_path.py deleted file mode 100644 index 5b92d9ae57f6..000000000000 --- a/lib/mpl_toolkits/axes_grid/clip_path.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.clip_path import * diff --git a/lib/mpl_toolkits/axes_grid/floating_axes.py b/lib/mpl_toolkits/axes_grid/floating_axes.py deleted file mode 100644 index de8ebb7367be..000000000000 --- a/lib/mpl_toolkits/axes_grid/floating_axes.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.floating_axes import * diff --git a/lib/mpl_toolkits/axes_grid/grid_finder.py b/lib/mpl_toolkits/axes_grid/grid_finder.py deleted file mode 100644 index 6cdec87a7f40..000000000000 --- a/lib/mpl_toolkits/axes_grid/grid_finder.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.grid_finder import * diff --git a/lib/mpl_toolkits/axes_grid/grid_helper_curvelinear.py b/lib/mpl_toolkits/axes_grid/grid_helper_curvelinear.py deleted file mode 100644 index ebb3edf139f5..000000000000 --- a/lib/mpl_toolkits/axes_grid/grid_helper_curvelinear.py +++ /dev/null @@ -1 +0,0 @@ -from mpl_toolkits.axisartist.grid_helper_curvelinear import * diff --git a/lib/mpl_toolkits/axes_grid/inset_locator.py b/lib/mpl_toolkits/axes_grid/inset_locator.py deleted file mode 100644 index 9d656e6edaf7..000000000000 --- a/lib/mpl_toolkits/axes_grid/inset_locator.py +++ /dev/null @@ -1,4 +0,0 @@ -from mpl_toolkits.axes_grid1.inset_locator import InsetPosition, \ - AnchoredSizeLocator, \ - AnchoredZoomLocator, BboxPatch, BboxConnector, BboxConnectorPatch, \ - inset_axes, zoomed_inset_axes, mark_inset diff --git a/lib/mpl_toolkits/axes_grid/parasite_axes.py b/lib/mpl_toolkits/axes_grid/parasite_axes.py deleted file mode 100644 index d988d704c344..000000000000 --- a/lib/mpl_toolkits/axes_grid/parasite_axes.py +++ /dev/null @@ -1,12 +0,0 @@ -from matplotlib import _api -from mpl_toolkits.axes_grid1.parasite_axes import ( - host_axes_class_factory, parasite_axes_class_factory, - parasite_axes_auxtrans_class_factory, subplot_class_factory) -from mpl_toolkits.axisartist.axislines import Axes - - -ParasiteAxes = parasite_axes_class_factory(Axes) -HostAxes = host_axes_class_factory(Axes) -SubplotHost = subplot_class_factory(HostAxes) -with _api.suppress_matplotlib_deprecation_warning(): - ParasiteAxesAuxTrans = parasite_axes_auxtrans_class_factory(ParasiteAxes) diff --git a/lib/mpl_toolkits/axes_grid1/__init__.py b/lib/mpl_toolkits/axes_grid1/__init__.py index 0f359c9e01f3..c55302485e3f 100644 --- a/lib/mpl_toolkits/axes_grid1/__init__.py +++ b/lib/mpl_toolkits/axes_grid1/__init__.py @@ -1,5 +1,10 @@ from . import axes_size as Size from .axes_divider import Divider, SubplotDivider, make_axes_locatable -from .axes_grid import Grid, ImageGrid, AxesGrid +from .axes_grid import AxesGrid, Grid, ImageGrid from .parasite_axes import host_subplot, host_axes + +__all__ = ["Size", + "Divider", "SubplotDivider", "make_axes_locatable", + "AxesGrid", "Grid", "ImageGrid", + "host_subplot", "host_axes"] diff --git a/lib/mpl_toolkits/axes_grid1/anchored_artists.py b/lib/mpl_toolkits/axes_grid1/anchored_artists.py index 3f10d439d520..7638a75d924a 100644 --- a/lib/mpl_toolkits/axes_grid1/anchored_artists.py +++ b/lib/mpl_toolkits/axes_grid1/anchored_artists.py @@ -14,7 +14,7 @@ def __init__(self, width, height, xdescent, ydescent, loc, pad=0.4, borderpad=0.5, prop=None, frameon=True, **kwargs): """ - An anchored container with a fixed size and fillable DrawingArea. + An anchored container with a fixed size and fillable `.DrawingArea`. Artists added to the *drawing_area* will have their coordinates interpreted as pixels. Any transformations set on the artists will be @@ -23,36 +23,36 @@ def __init__(self, width, height, xdescent, ydescent, Parameters ---------- width, height : float - width and height of the container, in pixels. + Width and height of the container, in pixels. xdescent, ydescent : float - descent of the container in the x- and y- direction, in pixels. + Descent of the container in the x- and y- direction, in pixels. loc : str Location of this artist. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. pad : float, default: 0.4 Padding around the child objects, in fraction of the font size. borderpad : float, default: 0.5 Border padding, in fraction of the font size. - prop : `matplotlib.font_manager.FontProperties`, optional + prop : `~matplotlib.font_manager.FontProperties`, optional Font property used as a reference for paddings. frameon : bool, default: True - If True, draw a box around this artists. + If True, draw a box around this artist. **kwargs Keyword arguments forwarded to `.AnchoredOffsetbox`. Attributes ---------- - drawing_area : `matplotlib.offsetbox.DrawingArea` + drawing_area : `~matplotlib.offsetbox.DrawingArea` A container for artists to display. Examples -------- To display blue and red circles of different sizes in the upper right - of an axes *ax*: + of an Axes *ax*: >>> ada = AnchoredDrawingArea(20, 20, 0, 0, ... loc='upper right', frameon=False) @@ -81,30 +81,30 @@ def __init__(self, transform, loc, Parameters ---------- - transform : `matplotlib.transforms.Transform` + transform : `~matplotlib.transforms.Transform` The transformation object for the coordinate system in use, i.e., :attr:`matplotlib.axes.Axes.transData`. loc : str Location of this artist. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. pad : float, default: 0.4 Padding around the child objects, in fraction of the font size. borderpad : float, default: 0.5 Border padding, in fraction of the font size. - prop : `matplotlib.font_manager.FontProperties`, optional + prop : `~matplotlib.font_manager.FontProperties`, optional Font property used as a reference for paddings. frameon : bool, default: True - If True, draw a box around this artists. + If True, draw a box around this artist. **kwargs Keyword arguments forwarded to `.AnchoredOffsetbox`. Attributes ---------- - drawing_area : `matplotlib.offsetbox.AuxTransformBox` + drawing_area : `~matplotlib.offsetbox.AuxTransformBox` A container for artists to display. Examples @@ -132,7 +132,7 @@ def __init__(self, transform, width, height, angle, loc, Parameters ---------- - transform : `matplotlib.transforms.Transform` + transform : `~matplotlib.transforms.Transform` The transformation object for the coordinate system in use, i.e., :attr:`matplotlib.axes.Axes.transData`. width, height : float @@ -141,10 +141,10 @@ def __init__(self, transform, width, height, angle, loc, angle : float Rotation of the ellipse, in degrees, anti-clockwise. loc : str - Location of this ellipse. Valid locations are + Location of the ellipse. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. pad : float, default: 0.1 @@ -153,18 +153,18 @@ def __init__(self, transform, width, height, angle, loc, Border padding, in fraction of the font size. frameon : bool, default: True If True, draw a box around the ellipse. - prop : `matplotlib.font_manager.FontProperties`, optional + prop : `~matplotlib.font_manager.FontProperties`, optional Font property used as a reference for paddings. **kwargs Keyword arguments forwarded to `.AnchoredOffsetbox`. Attributes ---------- - ellipse : `matplotlib.patches.Ellipse` + ellipse : `~matplotlib.patches.Ellipse` Ellipse patch drawn. """ self._box = AuxTransformBox(transform) - self.ellipse = Ellipse((0, 0), width, height, angle) + self.ellipse = Ellipse((0, 0), width, height, angle=angle) self._box.add_artist(self.ellipse) super().__init__(loc, pad=pad, borderpad=borderpad, child=self._box, @@ -182,7 +182,7 @@ def __init__(self, transform, size, label, loc, Parameters ---------- - transform : `matplotlib.transforms.Transform` + transform : `~matplotlib.transforms.Transform` The transformation object for the coordinate system in use, i.e., :attr:`matplotlib.axes.Axes.transData`. size : float @@ -191,10 +191,10 @@ def __init__(self, transform, size, label, loc, label : str Label to display. loc : str - Location of this ellipse. Valid locations are + Location of the size bar. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. pad : float, default: 0.1 @@ -213,10 +213,10 @@ def __init__(self, transform, size, label, loc, Color for the size bar and label. label_top : bool, default: False If True, the label will be over the size bar. - fontproperties : `matplotlib.font_manager.FontProperties`, optional + fontproperties : `~matplotlib.font_manager.FontProperties`, optional Font properties for the label text. fill_bar : bool, optional - If True and if size_vertical is nonzero, the size bar will + If True and if *size_vertical* is nonzero, the size bar will be filled in with the color specified by the size bar. Defaults to True if *size_vertical* is greater than zero and False otherwise. @@ -225,15 +225,15 @@ def __init__(self, transform, size, label, loc, Attributes ---------- - size_bar : `matplotlib.offsetbox.AuxTransformBox` + size_bar : `~matplotlib.offsetbox.AuxTransformBox` Container for the size bar. - txt_label : `matplotlib.offsetbox.TextArea` + txt_label : `~matplotlib.offsetbox.TextArea` Container for the label of the size bar. Notes ----- If *prop* is passed as a keyword argument, but *fontproperties* is - not, then *prop* is be assumed to be the intended *fontproperties*. + not, then *prop* is assumed to be the intended *fontproperties*. Using both *prop* and *fontproperties* is not supported. Examples @@ -301,7 +301,7 @@ def __init__(self, transform, label_x, label_y, length=0.15, Parameters ---------- - transform : `matplotlib.transforms.Transform` + transform : `~matplotlib.transforms.Transform` The transformation object for the coordinate system in use, i.e., :attr:`matplotlib.axes.Axes.transAxes`. label_x, label_y : str @@ -311,10 +311,10 @@ def __init__(self, transform, label_x, label_y, length=0.15, fontsize : float, default: 0.08 Size of label strings, given in coordinates of *transform*. loc : str, default: 'upper left' - Location of this ellipse. Valid locations are + Location of the arrow. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. angle : float, default: 0 @@ -335,37 +335,37 @@ def __init__(self, transform, label_x, label_y, length=0.15, sep_x, sep_y : float, default: 0.01 and 0 respectively Separation between the arrows and labels in coordinates of *transform*. - fontproperties : `matplotlib.font_manager.FontProperties`, optional + fontproperties : `~matplotlib.font_manager.FontProperties`, optional Font properties for the label text. back_length : float, default: 0.15 Fraction of the arrow behind the arrow crossing. head_width : float, default: 10 - Width of arrow head, sent to ArrowStyle. + Width of arrow head, sent to `.ArrowStyle`. head_length : float, default: 15 - Length of arrow head, sent to ArrowStyle. + Length of arrow head, sent to `.ArrowStyle`. tail_width : float, default: 2 - Width of arrow tail, sent to ArrowStyle. + Width of arrow tail, sent to `.ArrowStyle`. text_props, arrow_props : dict - Properties of the text and arrows, passed to - `.textpath.TextPath` and `.patches.FancyArrowPatch`. + Properties of the text and arrows, passed to `.TextPath` and + `.FancyArrowPatch`. **kwargs Keyword arguments forwarded to `.AnchoredOffsetbox`. Attributes ---------- - arrow_x, arrow_y : `matplotlib.patches.FancyArrowPatch` + arrow_x, arrow_y : `~matplotlib.patches.FancyArrowPatch` Arrow x and y - text_path_x, text_path_y : `matplotlib.textpath.TextPath` + text_path_x, text_path_y : `~matplotlib.text.TextPath` Path for arrow labels - p_x, p_y : `matplotlib.patches.PathPatch` + p_x, p_y : `~matplotlib.patches.PathPatch` Patch for arrow labels - box : `matplotlib.offsetbox.AuxTransformBox` + box : `~matplotlib.offsetbox.AuxTransformBox` Container for the arrows and labels. Notes ----- If *prop* is passed as a keyword argument, but *fontproperties* is - not, then *prop* is be assumed to be the intended *fontproperties*. + not, then *prop* is assumed to be the intended *fontproperties*. Using both *prop* and *fontproperties* is not supported. Examples diff --git a/lib/mpl_toolkits/axes_grid1/axes_divider.py b/lib/mpl_toolkits/axes_grid1/axes_divider.py index 43e23b1c3101..2a6bd0d5da08 100644 --- a/lib/mpl_toolkits/axes_grid1/axes_divider.py +++ b/lib/mpl_toolkits/axes_grid1/axes_divider.py @@ -6,8 +6,7 @@ import matplotlib as mpl from matplotlib import _api -from matplotlib.axes import SubplotBase -from matplotlib.gridspec import SubplotSpec, GridSpec +from matplotlib.gridspec import SubplotSpec import matplotlib.transforms as mtransforms from . import axes_size as Size @@ -39,7 +38,8 @@ def __init__(self, fig, pos, horizontal, vertical, aspect : bool Whether overall rectangular area is reduced so that the relative part of the horizontal and vertical scales have the same scale. - anchor : {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', 'NW', 'W'} + anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ +'NW', 'W'} Placement of the reduced rectangle, when *aspect* is True. """ @@ -48,44 +48,17 @@ def __init__(self, fig, pos, horizontal, vertical, self._horizontal = horizontal self._vertical = vertical self._anchor = anchor + self.set_anchor(anchor) self._aspect = aspect self._xrefindex = 0 self._yrefindex = 0 self._locator = None def get_horizontal_sizes(self, renderer): - return [s.get_size(renderer) for s in self.get_horizontal()] + return np.array([s.get_size(renderer) for s in self.get_horizontal()]) def get_vertical_sizes(self, renderer): - return [s.get_size(renderer) for s in self.get_vertical()] - - @_api.deprecated("3.5") - def get_vsize_hsize(self): - vsize = Size.AddList(self.get_vertical()) - hsize = Size.AddList(self.get_horizontal()) - return vsize, hsize - - @staticmethod - def _calc_k(l, total_size): - - rs_sum, as_sum = 0., 0. - - for _rs, _as in l: - rs_sum += _rs - as_sum += _as - - if rs_sum != 0.: - k = (total_size - as_sum) / rs_sum - return k - else: - return 0. - - @staticmethod - def _calc_offsets(l, k): - offsets = [0.] - for _rs, _as in l: - offsets.append(offsets[-1] + _rs*k + _as) - return offsets + return np.array([s.get_size(renderer) for s in self.get_vertical()]) def set_position(self, pos): """ @@ -106,7 +79,8 @@ def set_anchor(self, anchor): """ Parameters ---------- - anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', ...} + anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ +'NW', 'W'} Either an (*x*, *y*) pair of relative coordinates (0 is left or bottom, 1 is right or top), 'C' (center), or a cardinal direction ('SW', southwest, is bottom left, etc.). @@ -115,8 +89,10 @@ def set_anchor(self, anchor): -------- .Axes.set_anchor """ - if len(anchor) != 2: + if isinstance(anchor, str): _api.check_in_list(mtransforms.Bbox.coefs, anchor=anchor) + elif not isinstance(anchor, (tuple, list)) or len(anchor) != 2: + raise TypeError("anchor must be str or 2-tuple") self._anchor = anchor def get_anchor(self): @@ -173,15 +149,28 @@ def get_position_runtime(self, ax, renderer): else: return self._locator(ax, renderer).bounds + @staticmethod + def _calc_k(sizes, total): + # sizes is a (n, 2) array of (rel_size, abs_size); this method finds + # the k factor such that sum(rel_size * k + abs_size) == total. + rel_sum, abs_sum = sizes.sum(0) + return (total - abs_sum) / rel_sum if rel_sum else 0 + + @staticmethod + def _calc_offsets(sizes, k): + # Apply k factors to (n, 2) sizes array of (rel_size, abs_size); return + # the resulting cumulative offset positions. + return np.cumsum([0, *(sizes @ [k, 1])]) + def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): """ Parameters ---------- nx, nx1 : int - Integers specifying the column-position of the - cell. When *nx1* is None, a single *nx*-th column is - specified. Otherwise location of columns spanning between *nx* - to *nx1* (but excluding *nx1*-th column) is specified. + Integers specifying the column-position of the cell. When *nx1* is + None, a single *nx*-th column is specified. Otherwise, the + location of columns spanning between *nx* to *nx1* (but excluding + *nx1*-th column) is specified. ny, ny1 : int Same as *nx* and *nx1*, but for row positions. axes @@ -205,8 +194,7 @@ def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): hh = (oy[-1] - oy[0]) / fig_h pb = mtransforms.Bbox.from_bounds(x, y, w, h) pb1 = mtransforms.Bbox.from_bounds(x, y, ww, hh) - pb1_anchored = pb1.anchored(self.get_anchor(), pb) - x0, y0 = pb1_anchored.x0, pb1_anchored.y0 + x0, y0 = pb1.anchored(self.get_anchor(), pb).p0 else: ox = self._calc_offsets(hsizes, k_h) @@ -214,17 +202,9 @@ def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): x0, y0 = x, y if nx1 is None: - _api.warn_deprecated( - "3.5", message="Support for passing nx1=None to mean nx+1 is " - "deprecated since %(since)s; in a future version, nx1=None " - "will mean 'up to the last cell'.") - nx1 = nx + 1 + nx1 = -1 if ny1 is None: - _api.warn_deprecated( - "3.5", message="Support for passing ny1=None to mean ny+1 is " - "deprecated since %(since)s; in a future version, ny1=None " - "will mean 'up to the last cell'.") - ny1 = ny + 1 + ny1 = -1 x1, w1 = x0 + ox[nx] / fig_w, (ox[nx1] - ox[nx]) / fig_w y1, h1 = y0 + oy[ny] / fig_h, (oy[ny1] - oy[ny]) / fig_h @@ -233,14 +213,14 @@ def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): def new_locator(self, nx, ny, nx1=None, ny1=None): """ - Return a new `AxesLocator` for the specified cell. + Return a new `.AxesLocator` for the specified cell. Parameters ---------- nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is - specified. Otherwise location of columns spanning between *nx* + specified. Otherwise, location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. ny, ny1 : int Same as *nx* and *nx1*, but for row positions. @@ -251,6 +231,8 @@ def new_locator(self, nx, ny, nx1=None, ny1=None): ny1 if ny1 is not None else ny + 1) def append_size(self, position, size): + _api.check_in_list(["left", "right", "bottom", "top"], + position=position) if position == "left": self._horizontal.insert(0, size) self._xrefindex += 1 @@ -259,38 +241,44 @@ def append_size(self, position, size): elif position == "bottom": self._vertical.insert(0, size) self._yrefindex += 1 - elif position == "top": + else: # 'top' self._vertical.append(size) - else: - _api.check_in_list(["left", "right", "bottom", "top"], - position=position) def add_auto_adjustable_area(self, use_axes, pad=0.1, adjust_dirs=None): + """ + Add auto-adjustable padding around *use_axes* to take their decorations + (title, labels, ticks, ticklabels) into account during layout. + + Parameters + ---------- + use_axes : `~matplotlib.axes.Axes` or list of `~matplotlib.axes.Axes` + The Axes whose decorations are taken into account. + pad : float, optional + Additional padding in inches. + adjust_dirs : list of {"left", "right", "bottom", "top"}, optional + The sides where padding is added; defaults to all four sides. + """ if adjust_dirs is None: adjust_dirs = ["left", "right", "bottom", "top"] - from .axes_size import Padded, SizeFromFunc, GetExtentHelper for d in adjust_dirs: - helper = GetExtentHelper(use_axes, d) - size = SizeFromFunc(helper) - padded_size = Padded(size, pad) # pad in inch - self.append_size(d, padded_size) + self.append_size(d, Size._AxesDecorationsSize(use_axes, d) + pad) class AxesLocator: """ A callable object which returns the position and size of a given - AxesDivider cell. + `.AxesDivider` cell. """ def __init__(self, axes_divider, nx, ny, nx1=None, ny1=None): """ Parameters ---------- - axes_divider : AxesDivider + axes_divider : `~mpl_toolkits.axes_grid1.axes_divider.AxesDivider` nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is - specified. Otherwise location of columns spanning between *nx* + specified. Otherwise, location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. ny, ny1 : int Same as *nx* and *nx1*, but for row positions. @@ -303,17 +291,9 @@ def __init__(self, axes_divider, nx, ny, nx1=None, ny1=None): self._nx, self._ny = nx - _xrefindex, ny - _yrefindex if nx1 is None: - _api.warn_deprecated( - "3.5", message="Support for passing nx1=None to mean nx+1 is " - "deprecated since %(since)s; in a future version, nx1=None " - "will mean 'up to the last cell'.") - nx1 = nx + 1 + nx1 = len(self._axes_divider) if ny1 is None: - _api.warn_deprecated( - "3.5", message="Support for passing ny1=None to mean ny+1 is " - "deprecated since %(since)s; in a future version, ny1=None " - "will mean 'up to the last cell'.") - ny1 = ny + 1 + ny1 = len(self._axes_divider[0]) self._nx1 = nx1 - _xrefindex self._ny1 = ny1 - _yrefindex @@ -331,10 +311,7 @@ def __call__(self, axes, renderer): renderer) def get_subplotspec(self): - if hasattr(self._axes_divider, "get_subplotspec"): - return self._axes_divider.get_subplotspec() - else: - return None + return self._axes_divider.get_subplotspec() class SubplotDivider(Divider): @@ -347,7 +324,7 @@ def __init__(self, fig, *args, horizontal=None, vertical=None, """ Parameters ---------- - fig : `matplotlib.figure.Figure` + fig : `~matplotlib.figure.Figure` *args : tuple (*nrows*, *ncols*, *index*) or int The array of subplots in the figure has dimensions ``(nrows, @@ -369,30 +346,6 @@ def get_position(self): """Return the bounds of the subplot box.""" return self.get_subplotspec().get_position(self.figure).bounds - @_api.deprecated("3.4") - @property - def figbox(self): - return self.get_subplotspec().get_position(self.figure) - - @_api.deprecated("3.4") - def update_params(self): - pass - - @_api.deprecated( - "3.4", alternative="get_subplotspec", - addendum="(get_subplotspec returns a SubplotSpec instance.)") - def get_geometry(self): - """Get the subplot geometry, e.g., (2, 2, 3).""" - rows, cols, num1, num2 = self.get_subplotspec().get_geometry() - return rows, cols, num1 + 1 # for compatibility - - @_api.deprecated("3.4", alternative="set_subplotspec") - def change_geometry(self, numrows, numcols, num): - """Change subplot geometry, e.g., from (1, 1, 1) to (2, 2, 3).""" - self._subplotspec = GridSpec(numrows, numcols)[num-1] - self.update_params() - self.set_position(self.figbox) - def get_subplotspec(self): """Get the SubplotSpec instance.""" return self._subplotspec @@ -405,7 +358,7 @@ def set_subplotspec(self, subplotspec): class AxesDivider(Divider): """ - Divider based on the pre-existing axes. + Divider based on the preexisting axes. """ def __init__(self, axes, xref=None, yref=None): @@ -433,35 +386,17 @@ def __init__(self, axes, xref=None, yref=None): def _get_new_axes(self, *, axes_class=None, **kwargs): axes = self._axes if axes_class is None: - if isinstance(axes, SubplotBase): - axes_class = axes._axes_class - else: - axes_class = type(axes) + axes_class = type(axes) return axes_class(axes.get_figure(), axes.get_position(original=True), **kwargs) def new_horizontal(self, size, pad=None, pack_start=False, **kwargs): """ - Add a new axes on the right (or left) side of the main axes. + Helper method for ``append_axes("left")`` and ``append_axes("right")``. - Parameters - ---------- - size : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str - The axes width. float or str arguments are interpreted as - ``axes_size.from_any(size, AxesX())``. - pad : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str - Padding between the axes. float or str arguments are interpreted - as ``axes_size.from_any(size, AxesX())``. Defaults to - :rc:`figure.subplot.wspace` times the main axes width. - pack_start : bool - If False, the new axes is appended at the end - of the list, i.e., it became the right-most axes. If True, it is - inserted at the start of the list, and becomes the left-most axes. - **kwargs - All extra keywords arguments are passed to the created axes. - If *axes_class* is given, the new axes will be created as an - instance of the given class. Otherwise, the same class of the - main axes will be used. + See the documentation of `append_axes` for more details. + + :meta private: """ if pad is None: pad = mpl.rcParams["figure.subplot.wspace"] * self._xref @@ -489,26 +424,11 @@ def new_horizontal(self, size, pad=None, pack_start=False, **kwargs): def new_vertical(self, size, pad=None, pack_start=False, **kwargs): """ - Add a new axes on the top (or bottom) side of the main axes. + Helper method for ``append_axes("top")`` and ``append_axes("bottom")``. - Parameters - ---------- - size : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str - The axes height. float or str arguments are interpreted as - ``axes_size.from_any(size, AxesY())``. - pad : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str - Padding between the axes. float or str arguments are interpreted - as ``axes_size.from_any(size, AxesY())``. Defaults to - :rc:`figure.subplot.hspace` times the main axes height. - pack_start : bool - If False, the new axes is appended at the end - of the list, i.e., it became the right-most axes. If True, it is - inserted at the start of the list, and becomes the left-most axes. - **kwargs - All extra keywords arguments are passed to the created axes. - If *axes_class* is given, the new axes will be created as an - instance of the given class. Otherwise, the same class of the - main axes will be used. + See the documentation of `append_axes` for more details. + + :meta private: """ if pad is None: pad = mpl.rcParams["figure.subplot.hspace"] * self._yref @@ -529,36 +449,43 @@ def new_vertical(self, size, pad=None, pack_start=False, **kwargs): else: self._vertical.append(size) locator = self.new_locator( - nx=self._xrefindex, ny=len(self._vertical)-1) + nx=self._xrefindex, ny=len(self._vertical) - 1) ax = self._get_new_axes(**kwargs) ax.set_axes_locator(locator) return ax - @_api.delete_parameter("3.5", "add_to_figure", alternative="ax.remove()") - def append_axes(self, position, size, pad=None, add_to_figure=True, + def append_axes(self, position, size, pad=None, *, axes_class=None, **kwargs): """ - Create an axes at the given *position* with the same height - (or width) of the main axes. + Add a new axes on a given side of the main axes. - *position* - ["left"|"right"|"bottom"|"top"] - - *size* and *pad* should be axes_grid.axes_size compatible. + Parameters + ---------- + position : {"left", "right", "bottom", "top"} + Where the new axes is positioned relative to the main axes. + size : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str + The axes width or height. float or str arguments are interpreted + as ``axes_size.from_any(size, AxesX())`` for left or + right axes, and likewise with ``AxesY`` for bottom or top axes. + pad : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str + Padding between the axes. float or str arguments are interpreted + as for *size*. Defaults to :rc:`figure.subplot.wspace` times the + main Axes width (left or right axes) or :rc:`figure.subplot.hspace` + times the main Axes height (bottom or top axes). + axes_class : subclass type of `~.axes.Axes`, optional + The type of the new axes. Defaults to the type of the main axes. + **kwargs + All extra keywords arguments are passed to the created axes. """ - if position == "left": - ax = self.new_horizontal(size, pad, pack_start=True, **kwargs) - elif position == "right": - ax = self.new_horizontal(size, pad, pack_start=False, **kwargs) - elif position == "bottom": - ax = self.new_vertical(size, pad, pack_start=True, **kwargs) - elif position == "top": - ax = self.new_vertical(size, pad, pack_start=False, **kwargs) - else: - _api.check_in_list(["left", "right", "bottom", "top"], - position=position) - if add_to_figure: - self._fig.add_axes(ax) + create_axes, pack_start = _api.check_getitem({ + "left": (self.new_horizontal, True), + "right": (self.new_horizontal, False), + "bottom": (self.new_vertical, True), + "top": (self.new_vertical, False), + }, position=position) + ax = create_axes( + size, pad, pack_start=pack_start, axes_class=axes_class, **kwargs) + self._fig.add_axes(ax) return ax def get_aspect(self): @@ -585,67 +512,48 @@ def get_anchor(self): return self._anchor def get_subplotspec(self): - if hasattr(self._axes, "get_subplotspec"): - return self._axes.get_subplotspec() - else: - return None + return self._axes.get_subplotspec() # Helper for HBoxDivider/VBoxDivider. # The variable names are written for a horizontal layout, but the calculations -# work identically for vertical layouts (and likewise for the helpers below). -def _determine_karray(summed_widths, equal_heights, total_width, max_height): +# work identically for vertical layouts. +def _locate(x, y, w, h, summed_widths, equal_heights, fig_w, fig_h, anchor): + + total_width = fig_w * w + max_height = fig_h * h + + # Determine the k factors. n = len(equal_heights) - eq_rs, eq_as = np.asarray(equal_heights).T - sm_rs, sm_as = np.asarray(summed_widths).T - A = np.zeros((n + 1, n + 1)) - B = np.zeros(n + 1) - np.fill_diagonal(A[:n, :n], eq_rs) + eq_rels, eq_abss = equal_heights.T + sm_rels, sm_abss = summed_widths.T + A = np.diag([*eq_rels, 0]) A[:n, -1] = -1 - A[-1, :-1] = sm_rs - B[:n] = -eq_as - B[-1] = total_width - sum(sm_as) - # A @ K = B: This solves for {k_0, ..., k_{N-1}, H} so that - # eq_r_i * k_i + eq_a_i = H for all i: all axes have the same height - # sum(sm_r_i * k_i + sm_a_i) = total_summed_width: fixed total width - # (foo_r_i * k_i + foo_a_i will end up being the size of foo.) - karray_and_height = np.linalg.solve(A, B) - karray = karray_and_height[:-1] - height = karray_and_height[-1] + A[-1, :-1] = sm_rels + B = [*(-eq_abss), total_width - sm_abss.sum()] + # A @ K = B: This finds factors {k_0, ..., k_{N-1}, H} so that + # eq_rel_i * k_i + eq_abs_i = H for all i: all axes have the same height + # sum(sm_rel_i * k_i + sm_abs_i) = total_width: fixed total width + # (foo_rel_i * k_i + foo_abs_i will end up being the size of foo.) + *karray, height = np.linalg.solve(A, B) if height > max_height: # Additionally, upper-bound the height. - karray = (max_height - eq_as) / eq_rs - return karray - - -# Helper for HBoxDivider/VBoxDivider (see above re: variable naming). -def _calc_offsets(summed_sizes, karray): - offsets = [0.] - for (r, a), k in zip(summed_sizes, karray): - offsets.append(offsets[-1] + r*k + a) - return offsets - - -# Helper for HBoxDivider/VBoxDivider (see above re: variable naming). -def _locate(x, y, w, h, summed_widths, equal_heights, fig_w, fig_h, anchor): - karray = _determine_karray( - summed_widths, equal_heights, - total_width=fig_w * w, max_height=fig_h * h) - ox = _calc_offsets(summed_widths, karray) + karray = (max_height - eq_abss) / eq_rels + # Compute the offsets corresponding to these factors. + ox = np.cumsum([0, *(sm_rels * karray + sm_abss)]) ww = (ox[-1] - ox[0]) / fig_w - h0_r, h0_a = equal_heights[0] - hh = (karray[0]*h0_r + h0_a) / fig_h + h0_rel, h0_abs = equal_heights[0] + hh = (karray[0]*h0_rel + h0_abs) / fig_h pb = mtransforms.Bbox.from_bounds(x, y, w, h) pb1 = mtransforms.Bbox.from_bounds(x, y, ww, hh) - pb1_anchored = pb1.anchored(anchor, pb) - x0, y0 = pb1_anchored.x0, pb1_anchored.y0 + x0, y0 = pb1.anchored(anchor, pb).p0 return x0, y0, ox, hh class HBoxDivider(SubplotDivider): """ - A `SubplotDivider` for laying out axes horizontally, while ensuring that + A `.SubplotDivider` for laying out axes horizontally, while ensuring that they have equal heights. Examples @@ -655,14 +563,14 @@ class HBoxDivider(SubplotDivider): def new_locator(self, nx, nx1=None): """ - Create a new `AxesLocator` for the specified cell. + Create a new `.AxesLocator` for the specified cell. Parameters ---------- nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is - specified. Otherwise location of columns spanning between *nx* + specified. Otherwise, location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. """ return AxesLocator(self, nx, 0, nx1 if nx1 is not None else nx + 1, 1) @@ -676,11 +584,7 @@ def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): x0, y0, ox, hh = _locate( x, y, w, h, summed_ws, equal_hs, fig_w, fig_h, self.get_anchor()) if nx1 is None: - _api.warn_deprecated( - "3.5", message="Support for passing nx1=None to mean nx+1 is " - "deprecated since %(since)s; in a future version, nx1=None " - "will mean 'up to the last cell'.") - nx1 = nx + 1 + nx1 = -1 x1, w1 = x0 + ox[nx] / fig_w, (ox[nx1] - ox[nx]) / fig_w y1, h1 = y0, hh return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) @@ -688,20 +592,20 @@ def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): class VBoxDivider(SubplotDivider): """ - A `SubplotDivider` for laying out axes vertically, while ensuring that they - have equal widths. + A `.SubplotDivider` for laying out axes vertically, while ensuring that + they have equal widths. """ def new_locator(self, ny, ny1=None): """ - Create a new `AxesLocator` for the specified cell. + Create a new `.AxesLocator` for the specified cell. Parameters ---------- ny, ny1 : int Integers specifying the row-position of the cell. When *ny1* is None, a single *ny*-th row is - specified. Otherwise location of rows spanning between *ny* + specified. Otherwise, location of rows spanning between *ny* to *ny1* (but excluding *ny1*-th row) is specified. """ return AxesLocator(self, 0, ny, 1, ny1 if ny1 is not None else ny + 1) @@ -715,11 +619,7 @@ def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): y0, x0, oy, ww = _locate( y, x, h, w, summed_hs, equal_ws, fig_h, fig_w, self.get_anchor()) if ny1 is None: - _api.warn_deprecated( - "3.5", message="Support for passing ny1=None to mean ny+1 is " - "deprecated since %(since)s; in a future version, ny1=None " - "will mean 'up to the last cell'.") - ny1 = ny + 1 + ny1 = -1 x1, w1 = x0, ww y1, h1 = y0 + oy[ny] / fig_h, (oy[ny1] - oy[ny]) / fig_h return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) @@ -733,15 +633,20 @@ def make_axes_locatable(axes): return divider -def make_axes_area_auto_adjustable(ax, - use_axes=None, pad=0.1, - adjust_dirs=None): +def make_axes_area_auto_adjustable( + ax, use_axes=None, pad=0.1, adjust_dirs=None): + """ + Add auto-adjustable padding around *ax* to take its decorations (title, + labels, ticks, ticklabels) into account during layout, using + `.Divider.add_auto_adjustable_area`. + + By default, padding is determined from the decorations of *ax*. + Pass *use_axes* to consider the decorations of other Axes instead. + """ if adjust_dirs is None: adjust_dirs = ["left", "right", "bottom", "top"] divider = make_axes_locatable(ax) - if use_axes is None: use_axes = ax - divider.add_auto_adjustable_area(use_axes=use_axes, pad=pad, adjust_dirs=adjust_dirs) diff --git a/lib/mpl_toolkits/axes_grid1/axes_grid.py b/lib/mpl_toolkits/axes_grid1/axes_grid.py index 3198ab2b3bdf..1d6d6265e86f 100644 --- a/lib/mpl_toolkits/axes_grid1/axes_grid.py +++ b/lib/mpl_toolkits/axes_grid1/axes_grid.py @@ -20,8 +20,6 @@ def _tick_only(ax, bottom_on, left_on): class CbarAxesBase: def __init__(self, *args, orientation, **kwargs): self.orientation = orientation - self._default_label_on = True - self._locator = None # deprecated. super().__init__(*args, **kwargs) def colorbar(self, mappable, *, ticks=None, **kwargs): @@ -33,7 +31,6 @@ def colorbar(self, mappable, *, ticks=None, **kwargs): return cb def toggle_label(self, b): - self._default_label_on = b axis = self.axis[self.orientation] axis.toggle(ticklabels=b, label=b) @@ -43,11 +40,6 @@ def cla(self): self.orientation = orientation -@_api.deprecated("3.5") -class CbarAxes(CbarAxesBase, Axes): - pass - - _cbaraxes_class_factory = cbook._make_class_factory(CbarAxesBase, "Cbar{}") @@ -55,7 +47,7 @@ class Grid: """ A grid of Axes. - In Matplotlib, the axes location (and size) is specified in normalized + In Matplotlib, the Axes location (and size) is specified in normalized figure coordinates. This may not be ideal for images that needs to be displayed with a given aspect ratio; for example, it is difficult to display multiple images of a same size with some fixed padding between @@ -83,9 +75,11 @@ def __init__(self, fig, ---------- fig : `.Figure` The parent figure. - rect : (float, float, float, float) or int - The axes position, as a ``(left, bottom, width, height)`` tuple or - as a three-digit subplot position code (e.g., "121"). + rect : (float, float, float, float), (int, int, int), int, or \ + `~.SubplotSpec` + The axes position, as a ``(left, bottom, width, height)`` tuple, + as a three-digit subplot position code (e.g., ``(1, 2, 1)`` or + ``121``), or as a `~.SubplotSpec`. nrows_ncols : (int, int) Number of rows and columns in the grid. ngrids : int or None, default: None @@ -104,13 +98,14 @@ def __init__(self, fig, Whether all axes of a column share their x-axis. share_y : bool, default: True Whether all axes of a row share their y-axis. - label_mode : {"L", "1", "all"}, default: "L" + label_mode : {"L", "1", "all", "keep"}, default: "L" Determines which axes will get tick labels: - "L": All axes on the left column get vertical tick labels; all axes on the bottom row get horizontal tick labels. - "1": Only the bottom left axes is labelled. - - "all": all axes are labelled. + - "all": All axes are labelled. + - "keep": Do not do anything. axes_class : subclass of `matplotlib.axes.Axes`, default: None aspect : bool, default: False @@ -123,7 +118,8 @@ def __init__(self, fig, ngrids = self._nrows * self._ncols else: if not 0 < ngrids <= self._nrows * self._ncols: - raise Exception("") + raise ValueError( + "ngrids must be positive and not larger than nrows*ncols") self.ngrids = ngrids @@ -140,14 +136,14 @@ def __init__(self, fig, axes_class = functools.partial(cls, **kwargs) kw = dict(horizontal=[], vertical=[], aspect=aspect) - if isinstance(rect, (str, Number, SubplotSpec)): + if isinstance(rect, (Number, SubplotSpec)): self._divider = SubplotDivider(fig, rect, **kw) elif len(rect) == 3: self._divider = SubplotDivider(fig, *rect, **kw) elif len(rect) == 4: self._divider = Divider(fig, rect, **kw) else: - raise Exception("") + raise TypeError("Incorrect rect format") rect = self._divider.get_position() @@ -262,13 +258,14 @@ def set_label_mode(self, mode): Parameters ---------- - mode : {"L", "1", "all"} + mode : {"L", "1", "all", "keep"} The label mode: - "L": All axes on the left column get vertical tick labels; all axes on the bottom row get horizontal tick labels. - "1": Only the bottom left axes is labelled. - - "all": all axes are labelled. + - "all": All axes are labelled. + - "keep": Do not do anything. """ if mode == "all": for ax in self.axes_all: @@ -296,6 +293,16 @@ def set_label_mode(self, mode): ax = self.axes_llc _tick_only(ax, bottom_on=False, left_on=False) + else: + # Use _api.check_in_list at the top of the method when deprecation + # period expires + if mode != 'keep': + _api.warn_deprecated( + '3.7', name="Grid label_mode", + message='Passing an undefined label_mode is deprecated ' + 'since %(since)s and will become an error ' + '%(removal)s. To silence this warning, pass ' + '"keep", which gives the same behaviour.') def get_divider(self): return self._divider @@ -306,10 +313,6 @@ def set_axes_locator(self, locator): def get_axes_locator(self): return self._divider.get_locator() - @_api.deprecated("3.5") - def get_vsize_hsize(self): - return self._divider.get_vsize_hsize() - class ImageGrid(Grid): # docstring inherited @@ -378,6 +381,10 @@ def __init__(self, fig, to associated *cbar_axes*. axes_class : subclass of `matplotlib.axes.Axes`, default: None """ + _api.check_in_list(["each", "single", "edge", None], + cbar_mode=cbar_mode) + _api.check_in_list(["left", "right", "bottom", "top"], + cbar_location=cbar_location) self._colorbar_mode = cbar_mode self._colorbar_location = cbar_location self._colorbar_pad = cbar_pad diff --git a/lib/mpl_toolkits/axes_grid1/axes_rgb.py b/lib/mpl_toolkits/axes_grid1/axes_rgb.py index c94f443a8f83..f0672b7a8b29 100644 --- a/lib/mpl_toolkits/axes_grid1/axes_rgb.py +++ b/lib/mpl_toolkits/axes_grid1/axes_rgb.py @@ -8,8 +8,15 @@ def make_rgb_axes(ax, pad=0.01, axes_class=None, **kwargs): """ Parameters ---------- - pad : float - Fraction of the axes height. + ax : `~matplotlib.axes.Axes` + Axes instance to create the RGB Axes in. + pad : float, optional + Fraction of the Axes height to pad. + axes_class : `matplotlib.axes.Axes` or None, optional + Axes class to use for the R, G, and B Axes. If None, use + the same class as *ax*. + **kwargs : + Forwarded to *axes_class* init for the R, G, and B Axes. """ divider = make_axes_locatable(ax) @@ -26,10 +33,7 @@ def make_rgb_axes(ax, pad=0.01, axes_class=None, **kwargs): ax_rgb = [] if axes_class is None: - try: - axes_class = ax._axes_class - except AttributeError: - axes_class = type(ax) + axes_class = type(ax) for ny in [4, 2, 0]: ax1 = axes_class(ax.get_figure(), ax.get_position(original=True), @@ -55,30 +59,31 @@ def make_rgb_axes(ax, pad=0.01, axes_class=None, **kwargs): class RGBAxes: """ - 4-panel imshow (RGB, R, G, B). + 4-panel `~.Axes.imshow` (RGB, R, G, B). - Layout: + Layout:: - +---------------+-----+ - | | R | - + +-----+ - | RGB | G | - + +-----+ - | | B | - +---------------+-----+ + ┌───────────────┬─────┐ + │ │ R │ + │ ├─────┤ + │ RGB │ G │ + │ ├─────┤ + │ │ B │ + └───────────────┴─────┘ Subclasses can override the ``_defaultAxesClass`` attribute. + By default RGBAxes uses `.mpl_axes.Axes`. Attributes ---------- RGB : ``_defaultAxesClass`` - The axes object for the three-channel imshow. + The Axes object for the three-channel `~.Axes.imshow`. R : ``_defaultAxesClass`` - The axes object for the red channel imshow. + The Axes object for the red channel `~.Axes.imshow`. G : ``_defaultAxesClass`` - The axes object for the green channel imshow. + The Axes object for the green channel `~.Axes.imshow`. B : ``_defaultAxesClass`` - The axes object for the blue channel imshow. + The Axes object for the blue channel `~.Axes.imshow`. """ _defaultAxesClass = Axes @@ -88,13 +93,13 @@ def __init__(self, *args, pad=0, **kwargs): Parameters ---------- pad : float, default: 0 - fraction of the axes height to put as padding. - axes_class : matplotlib.axes.Axes - + Fraction of the Axes height to put as padding. + axes_class : `~matplotlib.axes.Axes` + Axes class to use. If not provided, ``_defaultAxesClass`` is used. *args - Unpacked into axes_class() init for RGB + Forwarded to *axes_class* init for the RGB Axes **kwargs - Unpacked into axes_class() init for RGB, R, G, B axes + Forwarded to *axes_class* init for the RGB, R, G, and B Axes """ axes_class = kwargs.pop("axes_class", self._defaultAxesClass) self.RGB = ax = axes_class(*args, **kwargs) @@ -114,15 +119,15 @@ def imshow_rgb(self, r, g, b, **kwargs): ---------- r, g, b : array-like The red, green, and blue arrays. - kwargs : imshow kwargs - kwargs get unpacked into the imshow calls for the four images. + **kwargs : + Forwarded to `~.Axes.imshow` calls for the four images. Returns ------- - rgb : matplotlib.image.AxesImage - r : matplotlib.image.AxesImage - g : matplotlib.image.AxesImage - b : matplotlib.image.AxesImage + rgb : `~matplotlib.image.AxesImage` + r : `~matplotlib.image.AxesImage` + g : `~matplotlib.image.AxesImage` + b : `~matplotlib.image.AxesImage` """ if not (r.shape == g.shape == b.shape): raise ValueError( diff --git a/lib/mpl_toolkits/axes_grid1/axes_size.py b/lib/mpl_toolkits/axes_grid1/axes_size.py index 87bc2419e1b4..cb800210fb79 100644 --- a/lib/mpl_toolkits/axes_grid1/axes_size.py +++ b/lib/mpl_toolkits/axes_grid1/axes_size.py @@ -1,6 +1,6 @@ """ Provides classes of simple units that will be used with AxesDivider -class (or others) to determine the size of each axes. The unit +class (or others) to determine the size of each Axes. The unit classes define `get_size` method that returns a tuple of two floats, meaning relative and absolute sizes, respectively. @@ -16,7 +16,6 @@ class (or others) to determine the size of each axes. The unit class _Base: - def __rmul__(self, other): return Fraction(other, self) @@ -38,6 +37,8 @@ def get_size(self, renderer): return a_rel_size + b_rel_size, a_abs_size + b_abs_size +@_api.deprecated( + "3.6", alternative="sum(sizes, start=Fixed(0))") class AddList(_Base): def __init__(self, add_list): self._list = add_list @@ -203,9 +204,10 @@ def get_size(self, renderer): return rel_size, abs_size +@_api.deprecated("3.6", alternative="size + pad") class Padded(_Base): """ - Return a instance where the absolute part of *size* is + Return an instance where the absolute part of *size* is increase by the amount of *pad*. """ @@ -237,6 +239,7 @@ def from_any(size, fraction_ref=None): raise ValueError("Unknown format") +@_api.deprecated("3.6") class SizeFromFunc(_Base): def __init__(self, func): self._func = func @@ -251,6 +254,7 @@ def get_size(self, renderer): return rel_size, abs_size +@_api.deprecated("3.6") class GetExtentHelper: _get_func_map = { "left": lambda self, axes_bbox: axes_bbox.xmin - self.xmin, @@ -270,3 +274,31 @@ def __call__(self, renderer): ax.bbox) for ax in self._ax_list] return max(vl) + + +class _AxesDecorationsSize(_Base): + """ + Fixed size, corresponding to the size of decorations on a given Axes side. + """ + + _get_size_map = { + "left": lambda tight_bb, axes_bb: axes_bb.xmin - tight_bb.xmin, + "right": lambda tight_bb, axes_bb: tight_bb.xmax - axes_bb.xmax, + "bottom": lambda tight_bb, axes_bb: axes_bb.ymin - tight_bb.ymin, + "top": lambda tight_bb, axes_bb: tight_bb.ymax - axes_bb.ymax, + } + + def __init__(self, ax, direction): + self._get_size = _api.check_getitem( + self._get_size_map, direction=direction) + self._ax_list = [ax] if isinstance(ax, Axes) else ax + + def get_size(self, renderer): + sz = max([ + self._get_size(ax.get_tightbbox(renderer, call_axes_locator=False), + ax.bbox) + for ax in self._ax_list]) + dpi = renderer.points_to_pixels(72) + abs_size = sz / dpi + rel_size = 0 + return rel_size, abs_size diff --git a/lib/mpl_toolkits/axes_grid1/inset_locator.py b/lib/mpl_toolkits/axes_grid1/inset_locator.py index 5193cc540c31..0771efd71fa0 100644 --- a/lib/mpl_toolkits/axes_grid1/inset_locator.py +++ b/lib/mpl_toolkits/axes_grid1/inset_locator.py @@ -2,7 +2,7 @@ A collection of functions and objects for creating or placing inset axes. """ -from matplotlib import _api, docstring +from matplotlib import _api, _docstring from matplotlib.offsetbox import AnchoredOffsetbox from matplotlib.patches import Patch, Rectangle from matplotlib.path import Path @@ -14,7 +14,7 @@ class InsetPosition: - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, parent, lbwh): """ An object for positioning an inset axes. @@ -24,7 +24,7 @@ def __init__(self, parent, lbwh): Parameters ---------- - parent : `matplotlib.axes.Axes` + parent : `~matplotlib.axes.Axes` Axes to use for normalizing coordinates. lbwh : iterable of four floats @@ -38,12 +38,12 @@ def __init__(self, parent, lbwh): Examples -------- The following bounds the inset axes to a box with 20%% of the parent - axes's height and 40%% of the width. The size of the axes specified + axes height and 40%% of the width. The size of the axes specified ([0, 0, 1, 1]) ensures that the axes completely fills the bounding box: >>> parent_axes = plt.gca() >>> ax_ins = plt.axes([0, 0, 1, 1]) - >>> ip = InsetPosition(ax, [0.5, 0.1, 0.4, 0.2]) + >>> ip = InsetPosition(parent_axes, [0.5, 0.1, 0.4, 0.2]) >>> ax_ins.set_axes_locator(ip) """ self.parent = parent @@ -70,18 +70,11 @@ def draw(self, renderer): def __call__(self, ax, renderer): self.axes = ax - - fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) - self._update_offset_func(renderer, fontsize) - - width, height, xdescent, ydescent = self.get_extent(renderer) - - px, py = self.get_offset(width, height, 0, 0, renderer) - bbox_canvas = Bbox.from_bounds(px, py, width, height) - tr = ax.figure.transFigure.inverted() - bb = TransformedBbox(bbox_canvas, tr) - - return bb + bbox = self.get_window_extent(renderer) + px, py = self.get_offset(bbox.width, bbox.height, 0, 0, renderer) + bbox_canvas = Bbox.from_bounds(px, py, bbox.width, bbox.height) + tr = ax.figure.transSubfigure.inverted() + return TransformedBbox(bbox_canvas, tr) class AnchoredSizeLocator(AnchoredLocatorBase): @@ -95,7 +88,7 @@ def __init__(self, bbox_to_anchor, x_size, y_size, loc, self.x_size = Size.from_any(x_size) self.y_size = Size.from_any(y_size) - def get_extent(self, renderer): + def get_bbox(self, renderer): bbox = self.get_bbox_to_anchor() dpi = renderer.points_to_pixels(72.) @@ -104,12 +97,10 @@ def get_extent(self, renderer): r, a = self.y_size.get_size(renderer) height = bbox.height * r + a * dpi - xd, yd = 0, 0 - fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) pad = self.pad * fontsize - return width + 2 * pad, height + 2 * pad, xd + pad, yd + pad + return Bbox.from_bounds(0, 0, width, height).padded(pad) class AnchoredZoomLocator(AnchoredLocatorBase): @@ -125,24 +116,25 @@ def __init__(self, parent_axes, zoom, loc, bbox_to_anchor, None, loc, borderpad=borderpad, bbox_transform=bbox_transform) - def get_extent(self, renderer): - bb = TransformedBbox(self.axes.viewLim, self.parent_axes.transData) + def get_bbox(self, renderer): + bb = self.parent_axes.transData.transform_bbox(self.axes.viewLim) fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) pad = self.pad * fontsize - return (abs(bb.width * self.zoom) + 2 * pad, - abs(bb.height * self.zoom) + 2 * pad, - pad, pad) + return ( + Bbox.from_bounds( + 0, 0, abs(bb.width * self.zoom), abs(bb.height * self.zoom)) + .padded(pad)) class BboxPatch(Patch): - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, bbox, **kwargs): """ Patch showing the shape bounded by a Bbox. Parameters ---------- - bbox : `matplotlib.transforms.Bbox` + bbox : `~matplotlib.transforms.Bbox` Bbox to use for the extents of this patch. **kwargs @@ -160,32 +152,15 @@ def __init__(self, bbox, **kwargs): def get_path(self): # docstring inherited x0, y0, x1, y1 = self.bbox.extents - return Path([(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], - closed=True) + return Path._create_closed([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) class BboxConnector(Patch): @staticmethod def get_bbox_edge_pos(bbox, loc): """ - Helper function to obtain the location of a corner of a bbox - - Parameters - ---------- - bbox : `matplotlib.transforms.Bbox` - - loc : {1, 2, 3, 4} - Corner of *bbox*. Valid values are:: - - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4 - - Returns - ------- - x, y : float - Coordinates of the corner specified by *loc*. + Return the ``(x, y)`` coordinates of corner *loc* of *bbox*; parameters + behave as documented for the `.BboxConnector` constructor. """ x0, y0, x1, y1 = bbox.extents if loc == 1: @@ -200,35 +175,9 @@ def get_bbox_edge_pos(bbox, loc): @staticmethod def connect_bbox(bbox1, bbox2, loc1, loc2=None): """ - Helper function to obtain a Path from one bbox to another. - - Parameters - ---------- - bbox1, bbox2 : `matplotlib.transforms.Bbox` - Bounding boxes to connect. - - loc1 : {1, 2, 3, 4} - Corner of *bbox1* to use. Valid values are:: - - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4 - - loc2 : {1, 2, 3, 4}, optional - Corner of *bbox2* to use. If None, defaults to *loc1*. - Valid values are:: - - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4 - - Returns - ------- - path : `matplotlib.path.Path` - A line segment from the *loc1* corner of *bbox1* to the *loc2* - corner of *bbox2*. + Construct a `.Path` connecting corner *loc1* of *bbox1* to corner + *loc2* of *bbox2*, where parameters behave as documented as for the + `.BboxConnector` constructor. """ if isinstance(bbox1, Rectangle): bbox1 = TransformedBbox(Bbox.unit(), bbox1.get_transform()) @@ -240,32 +189,25 @@ def connect_bbox(bbox1, bbox2, loc1, loc2=None): x2, y2 = BboxConnector.get_bbox_edge_pos(bbox2, loc2) return Path([[x1, y1], [x2, y2]]) - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, bbox1, bbox2, loc1, loc2=None, **kwargs): """ Connect two bboxes with a straight line. Parameters ---------- - bbox1, bbox2 : `matplotlib.transforms.Bbox` + bbox1, bbox2 : `~matplotlib.transforms.Bbox` Bounding boxes to connect. - loc1 : {1, 2, 3, 4} - Corner of *bbox1* to draw the line. Valid values are:: + loc1, loc2 : {1, 2, 3, 4} + Corner of *bbox1* and *bbox2* to draw the line. Valid values are:: 'upper right' : 1, 'upper left' : 2, 'lower left' : 3, 'lower right' : 4 - loc2 : {1, 2, 3, 4}, optional - Corner of *bbox2* to draw the line. If None, defaults to *loc1*. - Valid values are:: - - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4 + *loc2* is optional and defaults to *loc1*. **kwargs Patch properties for the line drawn. Valid arguments include: @@ -293,7 +235,7 @@ def get_path(self): class BboxConnectorPatch(BboxConnector): - @docstring.dedent_interpd + @_docstring.dedent_interpd def __init__(self, bbox1, bbox2, loc1a, loc2a, loc1b, loc2b, **kwargs): """ Connect two bboxes with a quadrilateral. @@ -305,21 +247,13 @@ def __init__(self, bbox1, bbox2, loc1a, loc2a, loc1b, loc2b, **kwargs): Parameters ---------- - bbox1, bbox2 : `matplotlib.transforms.Bbox` + bbox1, bbox2 : `~matplotlib.transforms.Bbox` Bounding boxes to connect. - loc1a, loc2a : {1, 2, 3, 4} - Corners of *bbox1* and *bbox2* to draw the first line. - Valid values are:: - - 'upper right' : 1, - 'upper left' : 2, - 'lower left' : 3, - 'lower right' : 4 - - loc1b, loc2b : {1, 2, 3, 4} - Corners of *bbox1* and *bbox2* to draw the second line. - Valid values are:: + loc1a, loc2a, loc1b, loc2b : {1, 2, 3, 4} + The first line connects corners *loc1a* of *bbox1* and *loc2a* of + *bbox2*; the second line connects corners *loc1b* of *bbox1* and + *loc2b* of *bbox2*. Valid values are:: 'upper right' : 1, 'upper left' : 2, @@ -352,7 +286,7 @@ def _add_inset_axes(parent_axes, inset_axes): inset_axes.set_navigate(False) -@docstring.dedent_interpd +@_docstring.dedent_interpd def inset_axes(parent_axes, width, height, loc='upper right', bbox_to_anchor=None, bbox_transform=None, axes_class=None, axes_kwargs=None, @@ -400,18 +334,18 @@ def inset_axes(parent_axes, width, height, loc='upper right', the size in inches, e.g. *width=1.3*. If a string is provided, it is the size in relative units, e.g. *width='40%%'*. By default, i.e. if neither *bbox_to_anchor* nor *bbox_transform* are specified, those - are relative to the parent_axes. Otherwise they are to be understood + are relative to the parent_axes. Otherwise, they are to be understood relative to the bounding box provided via *bbox_to_anchor*. loc : str, default: 'upper right' Location to place the inset axes. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. - bbox_to_anchor : tuple or `matplotlib.transforms.BboxBase`, optional + bbox_to_anchor : tuple or `~matplotlib.transforms.BboxBase`, optional Bbox that the inset axes will be anchored to. If None, a tuple of (0, 0, 1, 1) is used if *bbox_transform* is set to *parent_axes.transAxes* or *parent_axes.figure.transFigure*. @@ -425,7 +359,7 @@ def inset_axes(parent_axes, width, height, loc='upper right', a *bbox_transform*. This might often be the axes transform *parent_axes.transAxes*. - bbox_transform : `matplotlib.transforms.Transform`, optional + bbox_transform : `~matplotlib.transforms.Transform`, optional Transformation for the bbox that contains the inset axes. If None, a `.transforms.IdentityTransform` is used. The value of *bbox_to_anchor* (or the return value of its get_points method) @@ -434,7 +368,7 @@ def inset_axes(parent_axes, width, height, loc='upper right', You may provide *bbox_to_anchor* in some normalized coordinate, and give an appropriate transform (e.g., *parent_axes.transAxes*). - axes_class : `matplotlib.axes.Axes` type, default: `.HostAxes` + axes_class : `~matplotlib.axes.Axes` type, default: `.HostAxes` The type of the newly created inset axes. axes_kwargs : dict, optional @@ -492,7 +426,7 @@ def inset_axes(parent_axes, width, height, loc='upper right', return inset_axes -@docstring.dedent_interpd +@_docstring.dedent_interpd def zoomed_inset_axes(parent_axes, zoom, loc='upper right', bbox_to_anchor=None, bbox_transform=None, axes_class=None, axes_kwargs=None, @@ -503,7 +437,7 @@ def zoomed_inset_axes(parent_axes, zoom, loc='upper right', Parameters ---------- - parent_axes : `matplotlib.axes.Axes` + parent_axes : `~matplotlib.axes.Axes` Axes to place the inset axes. zoom : float @@ -515,11 +449,11 @@ def zoomed_inset_axes(parent_axes, zoom, loc='upper right', Location to place the inset axes. Valid locations are 'upper left', 'upper center', 'upper right', 'center left', 'center', 'center right', - 'lower left', 'lower center, 'lower right'. + 'lower left', 'lower center', 'lower right'. For backward compatibility, numeric values are accepted as well. See the parameter *loc* of `.Legend` for details. - bbox_to_anchor : tuple or `matplotlib.transforms.BboxBase`, optional + bbox_to_anchor : tuple or `~matplotlib.transforms.BboxBase`, optional Bbox that the inset axes will be anchored to. If None, *parent_axes.bbox* is used. If a tuple, can be either [left, bottom, width, height], or [left, bottom]. @@ -530,7 +464,7 @@ def zoomed_inset_axes(parent_axes, zoom, loc='upper right', also specify a *bbox_transform*. This might often be the axes transform *parent_axes.transAxes*. - bbox_transform : `matplotlib.transforms.Transform`, optional + bbox_transform : `~matplotlib.transforms.Transform`, optional Transformation for the bbox that contains the inset axes. If None, a `.transforms.IdentityTransform` is used (i.e. pixel coordinates). This is useful when not providing any argument to @@ -541,7 +475,7 @@ def zoomed_inset_axes(parent_axes, zoom, loc='upper right', *bbox_to_anchor* will use *parent_axes.bbox*, the units of which are in display (pixel) coordinates. - axes_class : `matplotlib.axes.Axes` type, default: `.HostAxes` + axes_class : `~matplotlib.axes.Axes` type, default: `.HostAxes` The type of the newly created inset axes. axes_kwargs : dict, optional @@ -579,7 +513,23 @@ def zoomed_inset_axes(parent_axes, zoom, loc='upper right', return inset_axes -@docstring.dedent_interpd +class _TransformedBboxWithCallback(TransformedBbox): + """ + Variant of `.TransformBbox` which calls *callback* before returning points. + + Used by `.mark_inset` to unstale the parent axes' viewlim as needed. + """ + + def __init__(self, *args, callback, **kwargs): + super().__init__(*args, **kwargs) + self._callback = callback + + def get_points(self): + self._callback() + return super().get_points() + + +@_docstring.dedent_interpd def mark_inset(parent_axes, inset_axes, loc1, loc2, **kwargs): """ Draw a box to mark the location of an area represented by an inset axes. @@ -590,10 +540,10 @@ def mark_inset(parent_axes, inset_axes, loc1, loc2, **kwargs): Parameters ---------- - parent_axes : `matplotlib.axes.Axes` + parent_axes : `~matplotlib.axes.Axes` Axes which contains the area of the inset axes. - inset_axes : `matplotlib.axes.Axes` + inset_axes : `~matplotlib.axes.Axes` The inset axes. loc1, loc2 : {1, 2, 3, 4} @@ -607,13 +557,15 @@ def mark_inset(parent_axes, inset_axes, loc1, loc2, **kwargs): Returns ------- - pp : `matplotlib.patches.Patch` + pp : `~matplotlib.patches.Patch` The patch drawn to represent the area of the inset axes. - p1, p2 : `matplotlib.patches.Patch` + p1, p2 : `~matplotlib.patches.Patch` The patches connecting two corners of the inset axes and its area. """ - rect = TransformedBbox(inset_axes.viewLim, parent_axes.transData) + rect = _TransformedBboxWithCallback( + inset_axes.viewLim, parent_axes.transData, + callback=parent_axes._unstale_viewLim) if 'fill' in kwargs: pp = BboxPatch(rect, **kwargs) diff --git a/lib/mpl_toolkits/axes_grid1/mpl_axes.py b/lib/mpl_toolkits/axes_grid1/mpl_axes.py index 3bb73770a07b..51c8748758cb 100644 --- a/lib/mpl_toolkits/axes_grid1/mpl_axes.py +++ b/lib/mpl_toolkits/axes_grid1/mpl_axes.py @@ -40,9 +40,14 @@ def __getitem__(self, k): def __call__(self, *v, **kwargs): return maxes.Axes.axis(self.axes, *v, **kwargs) - def _init_axis_artists(self, axes=None): - if axes is None: - axes = self + @property + def axis(self): + return self._axislines + + def clear(self): + # docstring inherited + super().clear() + # Init axis artists. self._axislines = self.AxisDict(self) self._axislines.update( bottom=SimpleAxisArtist(self.xaxis, 1, self.spines["bottom"]), @@ -50,14 +55,6 @@ def _init_axis_artists(self, axes=None): left=SimpleAxisArtist(self.yaxis, 1, self.spines["left"]), right=SimpleAxisArtist(self.yaxis, 2, self.spines["right"])) - @property - def axis(self): - return self._axislines - - def cla(self): - super().cla() - self._init_axis_artists() - class SimpleAxisArtist(Artist): def __init__(self, axis, axisnum, spine): @@ -115,14 +112,11 @@ def toggle(self, all=None, ticks=None, ticklabels=None, label=None): if label is not None: _label = label - tickOn = "tick%dOn" % self._axisnum - labelOn = "label%dOn" % self._axisnum - if _ticks is not None: - tickparam = {tickOn: _ticks} + tickparam = {f"tick{self._axisnum}On": _ticks} self._axis.set_tick_params(**tickparam) if _ticklabels is not None: - tickparam = {labelOn: _ticklabels} + tickparam = {f"label{self._axisnum}On": _ticklabels} self._axis.set_tick_params(**tickparam) if _label is not None: diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py index a140be9ef260..b8781a18c22b 100644 --- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py +++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py @@ -1,10 +1,6 @@ -import functools - from matplotlib import _api, cbook import matplotlib.artist as martist -import matplotlib.image as mimage import matplotlib.transforms as mtransforms -from matplotlib.axes import subplot_class_factory from matplotlib.transforms import Bbox from .mpl_axes import Axes @@ -19,25 +15,14 @@ def __init__(self, parent_axes, aux_transform=None, kwargs["frameon"] = False super().__init__(parent_axes.figure, parent_axes._position, **kwargs) - def cla(self): - super().cla() + def clear(self): + super().clear() martist.setp(self.get_children(), visible=False) self._get_lines = self._parent_axes._get_lines - - @_api.deprecated("3.5") - def get_images_artists(self): - artists = [] - images = [] - - for a in self.get_children(): - if not a.get_visible(): - continue - if isinstance(a, mimage.AxesImage): - images.append(a) - else: - artists.append(a) - - return images, artists + self._parent_axes.callbacks._connect_picklable( + "xlim_changed", self._sync_lims) + self._parent_axes.callbacks._connect_picklable( + "ylim_changed", self._sync_lims) def pick(self, mouseevent): # This most likely goes to Artist.pick (depending on axes_class given @@ -71,27 +56,18 @@ def set_viewlim_mode(self, mode): def get_viewlim_mode(self): return self._viewlim_mode - @_api.deprecated("3.4", alternative="apply_aspect") - def update_viewlim(self): - return self._update_viewlim - - def _update_viewlim(self): # Inline after deprecation elapses. - viewlim = self._parent_axes.viewLim.frozen() + def _sync_lims(self, parent): + viewlim = parent.viewLim.frozen() mode = self.get_viewlim_mode() if mode is None: pass elif mode == "equal": - self.axes.viewLim.set(viewlim) + self.viewLim.set(viewlim) elif mode == "transform": - self.axes.viewLim.set( - viewlim.transformed(self.transAux.inverted())) + self.viewLim.set(viewlim.transformed(self.transAux.inverted())) else: _api.check_in_list([None, "equal", "transform"], mode=mode) - def apply_aspect(self, position=None): - self._update_viewlim() - super().apply_aspect() - # end of aux_transform support @@ -100,99 +76,49 @@ def apply_aspect(self, position=None): ParasiteAxes = parasite_axes_class_factory(Axes) -@_api.deprecated("3.4", alternative="ParasiteAxesBase") -class ParasiteAxesAuxTransBase: - def __init__(self, parent_axes, aux_transform, viewlim_mode=None, - **kwargs): - # Explicit wrapper for deprecation to work. - super().__init__(parent_axes, aux_transform, - viewlim_mode=viewlim_mode, **kwargs) - - def _set_lim_and_transforms(self): - self.transAxes = self._parent_axes.transAxes - self.transData = self.transAux + self._parent_axes.transData - self._xaxis_transform = mtransforms.blended_transform_factory( - self.transData, self.transAxes) - self._yaxis_transform = mtransforms.blended_transform_factory( - self.transAxes, self.transData) - - def set_viewlim_mode(self, mode): - _api.check_in_list([None, "equal", "transform"], mode=mode) - self._viewlim_mode = mode - - def get_viewlim_mode(self): - return self._viewlim_mode - - @_api.deprecated("3.4", alternative="apply_aspect") - def update_viewlim(self): - return self._update_viewlim() - - def _update_viewlim(self): # Inline after deprecation elapses. - viewlim = self._parent_axes.viewLim.frozen() - mode = self.get_viewlim_mode() - if mode is None: - pass - elif mode == "equal": - self.axes.viewLim.set(viewlim) - elif mode == "transform": - self.axes.viewLim.set( - viewlim.transformed(self.transAux.inverted())) - else: - _api.check_in_list([None, "equal", "transform"], mode=mode) - - def apply_aspect(self, position=None): - self._update_viewlim() - super().apply_aspect() - - -@_api.deprecated("3.4", alternative="parasite_axes_class_factory") -@functools.lru_cache(None) -def parasite_axes_auxtrans_class_factory(axes_class): - if not issubclass(axes_class, ParasiteAxesBase): - parasite_axes_class = parasite_axes_class_factory(axes_class) - else: - parasite_axes_class = axes_class - return type("%sParasiteAuxTrans" % parasite_axes_class.__name__, - (ParasiteAxesAuxTransBase, parasite_axes_class), - {'name': 'parasite_axes'}) - - -# Also deprecated. -with _api.suppress_matplotlib_deprecation_warning(): - ParasiteAxesAuxTrans = parasite_axes_auxtrans_class_factory(ParasiteAxes) - - class HostAxesBase: def __init__(self, *args, **kwargs): self.parasites = [] super().__init__(*args, **kwargs) - def get_aux_axes(self, tr=None, viewlim_mode="equal", axes_class=Axes): + def get_aux_axes( + self, tr=None, viewlim_mode="equal", axes_class=None, **kwargs): """ Add a parasite axes to this host. Despite this method's name, this should actually be thought of as an ``add_parasite_axes`` method. - *tr* may be `.Transform`, in which case the following relation will - hold: ``parasite.transData = tr + host.transData``. Alternatively, it - may be None (the default), no special relationship will hold between - the parasite's and the host's ``transData``. + .. versionchanged:: 3.7 + Defaults to same base axes class as host axes. + + Parameters + ---------- + tr : `~matplotlib.transforms.Transform` or None, default: None + If a `.Transform`, the following relation will hold: + ``parasite.transData = tr + host.transData``. + If None, the parasite's and the host's ``transData`` are unrelated. + viewlim_mode : {"equal", "transform", None}, default: "equal" + How the parasite's view limits are set: directly equal to the + parent axes ("equal"), equal after application of *tr* + ("transform"), or independently (None). + axes_class : subclass type of `~matplotlib.axes.Axes`, optional + The `~.axes.Axes` subclass that is instantiated. If None, the base + class of the host axes is used. + kwargs + Other parameters are forwarded to the parasite axes constructor. """ + if axes_class is None: + axes_class = self._base_axes_class parasite_axes_class = parasite_axes_class_factory(axes_class) - ax2 = parasite_axes_class(self, tr, viewlim_mode=viewlim_mode) + ax2 = parasite_axes_class( + self, tr, viewlim_mode=viewlim_mode, **kwargs) # note that ax2.transData == tr + ax1.transData # Anything you draw in ax2 will match the ticks and grids of ax1. self.parasites.append(ax2) ax2._remove_method = self.parasites.remove return ax2 - def _get_legend_handles(self, legend_handler_map=None): - all_handles = super()._get_legend_handles() - for ax in self.parasites: - all_handles.extend(ax._get_legend_handles(legend_handler_map)) - return all_handles - def draw(self, renderer): orig_children_len = len(self._children) @@ -210,12 +136,12 @@ def draw(self, renderer): self._children.extend(ax.get_children()) super().draw(renderer) - self._children = self._children[:orig_children_len] + del self._children[orig_children_len:] - def cla(self): + def clear(self): + super().clear() for ax in self.parasites: - ax.cla() - super().cla() + ax.clear() def pick(self, mouseevent): super().pick(mouseevent) @@ -289,7 +215,7 @@ def _remove_any_twin(self, ax): self.axis[tuple(restore)].set_visible(True) self.axis[tuple(restore)].toggle(ticklabels=False, label=False) - def get_tightbbox(self, renderer, call_axes_locator=True, + def get_tightbbox(self, renderer=None, call_axes_locator=True, bbox_extra_artists=None): bbs = [ *[ax.get_tightbbox(renderer, call_axes_locator=call_axes_locator) @@ -300,16 +226,9 @@ def get_tightbbox(self, renderer, call_axes_locator=True, return Bbox.union([b for b in bbs if b.width != 0 or b.height != 0]) -host_axes_class_factory = cbook._make_class_factory( - HostAxesBase, "{}HostAxes", "_base_axes_class") -HostAxes = host_axes_class_factory(Axes) -SubplotHost = subplot_class_factory(HostAxes) - - -def host_subplot_class_factory(axes_class): - host_axes_class = host_axes_class_factory(axes_class) - subplot_host_class = subplot_class_factory(host_axes_class) - return subplot_host_class +host_axes_class_factory = host_subplot_class_factory = \ + cbook._make_class_factory(HostAxesBase, "{}HostAxes", "_base_axes_class") +HostAxes = SubplotHost = host_axes_class_factory(Axes) def host_axes(*args, axes_class=Axes, figure=None, **kwargs): @@ -318,12 +237,12 @@ def host_axes(*args, axes_class=Axes, figure=None, **kwargs): Parameters ---------- - figure : `matplotlib.figure.Figure` + figure : `~matplotlib.figure.Figure` Figure to which the axes will be added. Defaults to the current figure `.pyplot.gcf()`. *args, **kwargs - Will be passed on to the underlying ``Axes`` object creation. + Will be passed on to the underlying `~.axes.Axes` object creation. """ import matplotlib.pyplot as plt host_axes_class = host_axes_class_factory(axes_class) @@ -331,28 +250,7 @@ def host_axes(*args, axes_class=Axes, figure=None, **kwargs): figure = plt.gcf() ax = host_axes_class(figure, *args, **kwargs) figure.add_axes(ax) - plt.draw_if_interactive() return ax -def host_subplot(*args, axes_class=Axes, figure=None, **kwargs): - """ - Create a subplot that can act as a host to parasitic axes. - - Parameters - ---------- - figure : `matplotlib.figure.Figure` - Figure to which the subplot will be added. Defaults to the current - figure `.pyplot.gcf()`. - - *args, **kwargs - Will be passed on to the underlying ``Axes`` object creation. - """ - import matplotlib.pyplot as plt - host_subplot_class = host_subplot_class_factory(axes_class) - if figure is None: - figure = plt.gcf() - ax = host_subplot_class(figure, *args, **kwargs) - figure.add_subplot(ax) - plt.draw_if_interactive() - return ax +host_subplot = host_axes diff --git a/lib/mpl_toolkits/tests/__init__.py b/lib/mpl_toolkits/axes_grid1/tests/__init__.py similarity index 100% rename from lib/mpl_toolkits/tests/__init__.py rename to lib/mpl_toolkits/axes_grid1/tests/__init__.py diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/anchored_direction_arrows.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_direction_arrows.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/anchored_direction_arrows.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_direction_arrows.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/anchored_direction_arrows_many_args.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_direction_arrows_many_args.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/anchored_direction_arrows_many_args.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_direction_arrows_many_args.png diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_locator_base_call.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_locator_base_call.png new file mode 100644 index 000000000000..31c63d7df718 Binary files /dev/null and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/anchored_locator_base_call.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/fill_facecolor.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/fill_facecolor.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/fill_facecolor.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/fill_facecolor.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/image_grid.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/image_grid.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid.png diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid_each_left_label_mode_all.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid_each_left_label_mode_all.png new file mode 100644 index 000000000000..23abe8b9649d Binary files /dev/null and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid_each_left_label_mode_all.png differ diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid_single_bottom_label_mode_1.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid_single_bottom_label_mode_1.png new file mode 100644 index 000000000000..1a0f4cd1fc9a Binary files /dev/null and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/image_grid_single_bottom_label_mode_1.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid/imagegrid_cbar_mode.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/imagegrid_cbar_mode.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid/imagegrid_cbar_mode.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/imagegrid_cbar_mode.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/inset_axes.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_axes.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/inset_axes.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_axes.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/inset_locator.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/inset_locator.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/insetposition.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/insetposition.png new file mode 100644 index 000000000000..e8676cfd6c95 Binary files /dev/null and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/insetposition.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/inverted_zoomed_axes.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inverted_zoomed_axes.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/inverted_zoomed_axes.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inverted_zoomed_axes.png diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/rgb_axes.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/rgb_axes.png new file mode 100644 index 000000000000..5cf6dc7e35c0 Binary files /dev/null and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/rgb_axes.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/twin_axes_empty_and_removed.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/twin_axes_empty_and_removed.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/twin_axes_empty_and_removed.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/twin_axes_empty_and_removed.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/zoomed_axes.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/zoomed_axes.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axes_grid1/zoomed_axes.png rename to lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/zoomed_axes.png diff --git a/lib/mpl_toolkits/tests/conftest.py b/lib/mpl_toolkits/axes_grid1/tests/conftest.py similarity index 53% rename from lib/mpl_toolkits/tests/conftest.py rename to lib/mpl_toolkits/axes_grid1/tests/conftest.py index 1b48dac4100d..61c2de3e07ba 100644 --- a/lib/mpl_toolkits/tests/conftest.py +++ b/lib/mpl_toolkits/axes_grid1/tests/conftest.py @@ -1,2 +1,2 @@ -from matplotlib.testing.conftest import (mpl_test_settings, +from matplotlib.testing.conftest import (mpl_test_settings, # noqa pytest_configure, pytest_unconfigure) diff --git a/lib/mpl_toolkits/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py similarity index 66% rename from lib/mpl_toolkits/tests/test_axes_grid1.py rename to lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py index f8902ca72f28..8b84f0c4e671 100644 --- a/lib/mpl_toolkits/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -3,12 +3,13 @@ import matplotlib as mpl import matplotlib.pyplot as plt -from matplotlib import cbook +import matplotlib.ticker as mticker +from matplotlib import _api, cbook from matplotlib.backend_bases import MouseEvent from matplotlib.colors import LogNorm from matplotlib.transforms import Bbox, TransformedBbox from matplotlib.testing.decorators import ( - image_comparison, remove_ticks_and_titles) + check_figures_equal, image_comparison, remove_ticks_and_titles) from mpl_toolkits.axes_grid1 import ( axes_size as Size, @@ -16,9 +17,13 @@ Grid, AxesGrid, ImageGrid) from mpl_toolkits.axes_grid1.anchored_artists import ( AnchoredSizeBar, AnchoredDirectionArrows) -from mpl_toolkits.axes_grid1.axes_divider import HBoxDivider +from mpl_toolkits.axes_grid1.axes_divider import ( + Divider, HBoxDivider, make_axes_area_auto_adjustable, SubplotDivider, + VBoxDivider) +from mpl_toolkits.axes_grid1.axes_rgb import RGBAxes from mpl_toolkits.axes_grid1.inset_locator import ( - zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch) + zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch, + InsetPosition) import mpl_toolkits.axes_grid1.mpl_axes import pytest @@ -38,7 +43,6 @@ def test_divider_append_axes(): "right": divider.append_axes("right", 1.2, pad=0.1, sharey=ax), } fig.canvas.draw() - renderer = fig.canvas.get_renderer() bboxes = {k: axs[k].get_window_extent() for k in axs} dpi = fig.dpi assert bboxes["top"].height == pytest.approx(1.2 * dpi) @@ -55,7 +59,9 @@ def test_divider_append_axes(): assert bboxes["top"].x1 == bboxes["main"].x1 == bboxes["bottom"].x1 -@image_comparison(['twin_axes_empty_and_removed'], extensions=["png"], tol=1) +# Update style when regenerating the test image +@image_comparison(['twin_axes_empty_and_removed'], extensions=["png"], tol=1, + style=('classic', '_classic_test_patch')) def test_twin_axes_empty_and_removed(): # Purely cosmetic font changes (avoid overlap) mpl.rcParams.update( @@ -123,7 +129,6 @@ def test_inset_locator(): ny, nx = Z.shape Z2[30:30+ny, 30:30+nx] = Z - # extent = [-3, 4, -4, 3] ax.imshow(Z2, extent=extent, interpolation="nearest", origin="lower") @@ -165,7 +170,6 @@ def test_inset_axes(): ny, nx = Z.shape Z2[30:30+ny, 30:30+nx] = Z - # extent = [-3, 4, -4, 3] ax.imshow(Z2, extent=extent, interpolation="nearest", origin="lower") @@ -207,23 +211,22 @@ def test_inset_axes_complete(): ins = inset_axes(ax, width=2., height=2., borderpad=0) fig.canvas.draw() assert_array_almost_equal( - ins.get_position().extents, - np.array(((0.9*figsize[0]-2.)/figsize[0], - (0.9*figsize[1]-2.)/figsize[1], 0.9, 0.9))) + ins.get_position().extents, + [(0.9*figsize[0]-2.)/figsize[0], (0.9*figsize[1]-2.)/figsize[1], + 0.9, 0.9]) ins = inset_axes(ax, width="40%", height="30%", borderpad=0) fig.canvas.draw() assert_array_almost_equal( - ins.get_position().extents, - np.array((.9-.8*.4, .9-.8*.3, 0.9, 0.9))) + ins.get_position().extents, [.9-.8*.4, .9-.8*.3, 0.9, 0.9]) ins = inset_axes(ax, width=1., height=1.2, bbox_to_anchor=(200, 100), loc=3, borderpad=0) fig.canvas.draw() assert_array_almost_equal( - ins.get_position().extents, - np.array((200./dpi/figsize[0], 100./dpi/figsize[1], - (200./dpi+1)/figsize[0], (100./dpi+1.2)/figsize[1]))) + ins.get_position().extents, + [200/dpi/figsize[0], 100/dpi/figsize[1], + (200/dpi+1)/figsize[0], (100/dpi+1.2)/figsize[1]]) ins1 = inset_axes(ax, width="35%", height="60%", loc=3, borderpad=1) ins2 = inset_axes(ax, width="100%", height="100%", @@ -320,7 +323,9 @@ def test_fill_facecolor(): mark_inset(ax[3], axins, loc1=2, loc2=4, fc="g", ec="0.5", fill=False) -@image_comparison(['zoomed_axes.png', 'inverted_zoomed_axes.png']) +# Update style when regenerating the test image +@image_comparison(['zoomed_axes.png', 'inverted_zoomed_axes.png'], + style=('classic', '_classic_test_patch')) def test_zooming_with_inverted_axes(): fig, ax = plt.subplots() ax.plot([1, 2, 3], [1, 2, 3]) @@ -335,8 +340,10 @@ def test_zooming_with_inverted_axes(): inset_ax.axis([1.4, 1.1, 1.4, 1.1]) +# Update style when regenerating the test image @image_comparison(['anchored_direction_arrows.png'], - tol=0 if platform.machine() == 'x86_64' else 0.01) + tol=0 if platform.machine() == 'x86_64' else 0.01, + style=('classic', '_classic_test_patch')) def test_anchored_direction_arrows(): fig, ax = plt.subplots() ax.imshow(np.zeros((10, 10)), interpolation='nearest') @@ -345,7 +352,9 @@ def test_anchored_direction_arrows(): ax.add_artist(simple_arrow) -@image_comparison(['anchored_direction_arrows_many_args.png']) +# Update style when regenerating the test image +@image_comparison(['anchored_direction_arrows_many_args.png'], + style=('classic', '_classic_test_patch')) def test_anchored_direction_arrows_many_args(): fig, ax = plt.subplots() ax.imshow(np.ones((10, 10))) @@ -368,6 +377,49 @@ def test_axes_locatable_position(): 0.03621495327102808) +@image_comparison(['image_grid_each_left_label_mode_all.png'], style='mpl20', + savefig_kwarg={'bbox_inches': 'tight'}) +def test_image_grid_each_left_label_mode_all(): + imdata = np.arange(100).reshape((10, 10)) + + fig = plt.figure(1, (3, 3)) + grid = ImageGrid(fig, (1, 1, 1), nrows_ncols=(3, 2), axes_pad=(0.5, 0.3), + cbar_mode="each", cbar_location="left", cbar_size="15%", + label_mode="all") + # 3-tuple rect => SubplotDivider + assert isinstance(grid.get_divider(), SubplotDivider) + assert grid.get_axes_pad() == (0.5, 0.3) + assert grid.get_aspect() # True by default for ImageGrid + for ax, cax in zip(grid, grid.cbar_axes): + im = ax.imshow(imdata, interpolation='none') + cax.colorbar(im) + + +@image_comparison(['image_grid_single_bottom_label_mode_1.png'], style='mpl20', + savefig_kwarg={'bbox_inches': 'tight'}) +def test_image_grid_single_bottom(): + imdata = np.arange(100).reshape((10, 10)) + + fig = plt.figure(1, (2.5, 1.5)) + grid = ImageGrid(fig, (0, 0, 1, 1), nrows_ncols=(1, 3), + axes_pad=(0.2, 0.15), cbar_mode="single", + cbar_location="bottom", cbar_size="10%", label_mode="1") + # 4-tuple rect => Divider, isinstance will give True for SubplotDivider + assert type(grid.get_divider()) is Divider + for i in range(3): + im = grid[i].imshow(imdata, interpolation='none') + grid.cbar_axes[0].colorbar(im) + + +def test_image_grid_label_mode_deprecation_warning(): + imdata = np.arange(9).reshape((3, 3)) + + fig = plt.figure() + with pytest.warns(_api.MatplotlibDeprecationWarning, + match="Passing an undefined label_mode"): + grid = ImageGrid(fig, (0, 0, 1, 1), (2, 1), label_mode="foo") + + @image_comparison(['image_grid.png'], remove_text=True, style='mpl20', savefig_kwarg={'bbox_inches': 'tight'}) @@ -377,10 +429,9 @@ def test_image_grid(): fig = plt.figure(1, (4, 4)) grid = ImageGrid(fig, 111, nrows_ncols=(2, 2), axes_pad=0.1) - + assert grid.get_axes_pad() == (0.1, 0.1) for i in range(4): grid[i].imshow(im, interpolation='nearest') - grid[i].set_title('test {0}{0}'.format(i)) def test_gettightbbox(): @@ -480,6 +531,29 @@ def test_hbox_divider(): assert p2.width / p1.width == pytest.approx((4 / 5) ** 2) +def test_vbox_divider(): + arr1 = np.arange(20).reshape((4, 5)) + arr2 = np.arange(20).reshape((5, 4)) + + fig, (ax1, ax2) = plt.subplots(1, 2) + ax1.imshow(arr1) + ax2.imshow(arr2) + + pad = 0.5 # inches. + divider = VBoxDivider( + fig, 111, # Position of combined axes. + horizontal=[Size.AxesX(ax1), Size.Scaled(1), Size.AxesX(ax2)], + vertical=[Size.AxesY(ax1), Size.Fixed(pad), Size.AxesY(ax2)]) + ax1.set_axes_locator(divider.new_locator(0)) + ax2.set_axes_locator(divider.new_locator(2)) + + fig.canvas.draw() + p1 = ax1.get_position() + p2 = ax2.get_position() + assert p1.width == p2.width + assert p1.height / p2.height == pytest.approx((4 / 5) ** 2) + + def test_axes_class_tuple(): fig = plt.figure() axes_class = (mpl_toolkits.axes_grid1.mpl_axes.Axes, {}) @@ -493,6 +567,7 @@ def test_grid_axes_lists(): assert_array_equal(grid, grid.axes_all) assert_array_equal(grid.axes_row, np.transpose(grid.axes_column)) assert_array_equal(grid, np.ravel(grid.axes_row), "row") + assert grid.get_geometry() == (2, 3) grid = Grid(fig, 111, (2, 3), direction="column") assert_array_equal(grid, np.ravel(grid.axes_column), "column") @@ -506,3 +581,158 @@ def test_grid_axes_position(direction): assert loc[1]._nx > loc[0]._nx and loc[2]._ny < loc[0]._ny assert loc[0]._nx == loc[2]._nx and loc[0]._ny == loc[1]._ny assert loc[3]._nx == loc[1]._nx and loc[3]._ny == loc[2]._ny + + +@pytest.mark.parametrize('rect, ngrids, error, message', ( + ((1, 1), None, TypeError, "Incorrect rect format"), + (111, -1, ValueError, "ngrids must be positive"), + (111, 7, ValueError, "ngrids must be positive"), +)) +def test_grid_errors(rect, ngrids, error, message): + fig = plt.figure() + with pytest.raises(error, match=message): + Grid(fig, rect, (2, 3), ngrids=ngrids) + + +@pytest.mark.parametrize('anchor, error, message', ( + (None, TypeError, "anchor must be str"), + ("CC", ValueError, "'CC' is not a valid value for anchor"), + ((1, 1, 1), TypeError, "anchor must be str"), +)) +def test_divider_errors(anchor, error, message): + fig = plt.figure() + with pytest.raises(error, match=message): + Divider(fig, [0, 0, 1, 1], [Size.Fixed(1)], [Size.Fixed(1)], + anchor=anchor) + + +@check_figures_equal(extensions=["png"]) +def test_mark_inset_unstales_viewlim(fig_test, fig_ref): + inset, full = fig_test.subplots(1, 2) + full.plot([0, 5], [0, 5]) + inset.set(xlim=(1, 2), ylim=(1, 2)) + # Check that mark_inset unstales full's viewLim before drawing the marks. + mark_inset(full, inset, 1, 4) + + inset, full = fig_ref.subplots(1, 2) + full.plot([0, 5], [0, 5]) + inset.set(xlim=(1, 2), ylim=(1, 2)) + mark_inset(full, inset, 1, 4) + # Manually unstale the full's viewLim. + fig_ref.canvas.draw() + + +def test_auto_adjustable(): + fig = plt.figure() + ax = fig.add_axes([0, 0, 1, 1]) + pad = 0.1 + make_axes_area_auto_adjustable(ax, pad=pad) + fig.canvas.draw() + tbb = ax.get_tightbbox() + assert tbb.x0 == pytest.approx(pad * fig.dpi) + assert tbb.x1 == pytest.approx(fig.bbox.width - pad * fig.dpi) + assert tbb.y0 == pytest.approx(pad * fig.dpi) + assert tbb.y1 == pytest.approx(fig.bbox.height - pad * fig.dpi) + + +# Update style when regenerating the test image +@image_comparison(['rgb_axes.png'], remove_text=True, + style=('classic', '_classic_test_patch')) +def test_rgb_axes(): + fig = plt.figure() + ax = RGBAxes(fig, (0.1, 0.1, 0.8, 0.8), pad=0.1) + rng = np.random.default_rng(19680801) + r = rng.random((5, 5)) + g = rng.random((5, 5)) + b = rng.random((5, 5)) + ax.imshow_rgb(r, g, b, interpolation='none') + + +# Update style when regenerating the test image +@image_comparison(['insetposition.png'], remove_text=True, + style=('classic', '_classic_test_patch')) +def test_insetposition(): + fig, ax = plt.subplots(figsize=(2, 2)) + ax_ins = plt.axes([0, 0, 1, 1]) + ip = InsetPosition(ax, [0.2, 0.25, 0.5, 0.4]) + ax_ins.set_axes_locator(ip) + + +# The original version of this test relied on mpl_toolkits's slightly different +# colorbar implementation; moving to matplotlib's own colorbar implementation +# caused the small image comparison error. +@image_comparison(['imagegrid_cbar_mode.png'], + remove_text=True, style='mpl20', tol=0.3) +def test_imagegrid_cbar_mode_edge(): + # Remove this line when this test image is regenerated. + plt.rcParams['pcolormesh.snap'] = False + + X, Y = np.meshgrid(np.linspace(0, 6, 30), np.linspace(0, 6, 30)) + arr = np.sin(X) * np.cos(Y) + 1j*(np.sin(3*Y) * np.cos(Y/2.)) + + fig = plt.figure(figsize=(18, 9)) + + positions = (241, 242, 243, 244, 245, 246, 247, 248) + directions = ['row']*4 + ['column']*4 + cbar_locations = ['left', 'right', 'top', 'bottom']*2 + + for position, direction, location in zip( + positions, directions, cbar_locations): + grid = ImageGrid(fig, position, + nrows_ncols=(2, 2), + direction=direction, + cbar_location=location, + cbar_size='20%', + cbar_mode='edge') + ax1, ax2, ax3, ax4, = grid + + ax1.imshow(arr.real, cmap='nipy_spectral') + ax2.imshow(arr.imag, cmap='hot') + ax3.imshow(np.abs(arr), cmap='jet') + ax4.imshow(np.arctan2(arr.imag, arr.real), cmap='hsv') + + # In each row/column, the "first" colorbars must be overwritten by the + # "second" ones. To achieve this, clear out the axes first. + for ax in grid: + ax.cax.cla() + cb = ax.cax.colorbar(ax.images[0]) + + +def test_imagegrid(): + fig = plt.figure() + grid = ImageGrid(fig, 111, nrows_ncols=(1, 1)) + ax = grid[0] + im = ax.imshow([[1, 2]], norm=mpl.colors.LogNorm()) + cb = ax.cax.colorbar(im) + assert isinstance(cb.locator, mticker.LogLocator) + + +def test_removal(): + import matplotlib.pyplot as plt + import mpl_toolkits.axisartist as AA + fig = plt.figure() + ax = host_subplot(111, axes_class=AA.Axes, figure=fig) + col = ax.fill_between(range(5), 0, range(5)) + fig.canvas.draw() + col.remove() + fig.canvas.draw() + + +@image_comparison(['anchored_locator_base_call.png'], style="mpl20") +def test_anchored_locator_base_call(): + fig = plt.figure(figsize=(3, 3)) + fig1, fig2 = fig.subfigures(nrows=2, ncols=1) + + ax = fig1.subplots() + ax.set(aspect=1, xlim=(-15, 15), ylim=(-20, 5)) + ax.set(xticks=[], yticks=[]) + + Z = cbook.get_sample_data( + "axes_grid/bivariate_normal.npy", np_load=True + ) + extent = (-3, 4, -4, 3) + + axins = zoomed_inset_axes(ax, zoom=2, loc="upper left") + axins.set(xticks=[], yticks=[]) + + axins.imshow(Z, extent=extent, origin="lower") diff --git a/lib/mpl_toolkits/axisartist/__init__.py b/lib/mpl_toolkits/axisartist/__init__.py index 0699b0165d84..47242cf7f0c5 100644 --- a/lib/mpl_toolkits/axisartist/__init__.py +++ b/lib/mpl_toolkits/axisartist/__init__.py @@ -1,4 +1,3 @@ -from matplotlib import _api from .axislines import ( Axes, AxesZero, AxisArtistHelper, AxisArtistHelperRectlinear, GridHelperBase, GridHelperRectlinear, Subplot, SubplotZero) @@ -6,12 +5,9 @@ from .grid_helper_curvelinear import GridHelperCurveLinear from .floating_axes import FloatingAxes, FloatingSubplot from mpl_toolkits.axes_grid1.parasite_axes import ( - host_axes_class_factory, parasite_axes_class_factory, - parasite_axes_auxtrans_class_factory, subplot_class_factory) + host_axes_class_factory, parasite_axes_class_factory) ParasiteAxes = parasite_axes_class_factory(Axes) HostAxes = host_axes_class_factory(Axes) -SubplotHost = subplot_class_factory(HostAxes) -with _api.suppress_matplotlib_deprecation_warning(): - ParasiteAxesAuxTrans = parasite_axes_auxtrans_class_factory(ParasiteAxes) +SubplotHost = HostAxes diff --git a/lib/mpl_toolkits/axisartist/angle_helper.py b/lib/mpl_toolkits/axisartist/angle_helper.py index 7f49a657ba52..1786cd70bcdb 100644 --- a/lib/mpl_toolkits/axisartist/angle_helper.py +++ b/lib/mpl_toolkits/axisartist/angle_helper.py @@ -284,7 +284,7 @@ def __call__(self, direction, factor, values): return r else: # factor > 3600. - return [r"$%s^{\circ}$" % (str(v),) for v in ss*values] + return [r"$%s^{\circ}$" % v for v in ss*values] class FormatterHMS(FormatterDMS): diff --git a/lib/mpl_toolkits/axisartist/axes_divider.py b/lib/mpl_toolkits/axisartist/axes_divider.py index 3b177838f896..a01d4e27df93 100644 --- a/lib/mpl_toolkits/axisartist/axes_divider.py +++ b/lib/mpl_toolkits/axisartist/axes_divider.py @@ -1,2 +1,2 @@ -from mpl_toolkits.axes_grid1.axes_divider import ( +from mpl_toolkits.axes_grid1.axes_divider import ( # noqa Divider, AxesLocator, SubplotDivider, AxesDivider, make_axes_locatable) diff --git a/lib/mpl_toolkits/axisartist/axes_grid.py b/lib/mpl_toolkits/axisartist/axes_grid.py index d90097228329..27877a238b7d 100644 --- a/lib/mpl_toolkits/axisartist/axes_grid.py +++ b/lib/mpl_toolkits/axisartist/axes_grid.py @@ -1,13 +1,7 @@ -from matplotlib import _api import mpl_toolkits.axes_grid1.axes_grid as axes_grid_orig from .axislines import Axes -@_api.deprecated("3.5") -class CbarAxes(axes_grid_orig.CbarAxesBase, Axes): - pass - - class Grid(axes_grid_orig.Grid): _defaultAxesClass = Axes diff --git a/lib/mpl_toolkits/axisartist/axes_rgb.py b/lib/mpl_toolkits/axisartist/axes_rgb.py index 8c1d24bc3787..20c1f7fd233b 100644 --- a/lib/mpl_toolkits/axisartist/axes_rgb.py +++ b/lib/mpl_toolkits/axisartist/axes_rgb.py @@ -1,6 +1,11 @@ -from mpl_toolkits.axes_grid1.axes_rgb import make_rgb_axes, RGBAxes as _RGBAxes +from mpl_toolkits.axes_grid1.axes_rgb import ( # noqa + make_rgb_axes, RGBAxes as _RGBAxes) from .axislines import Axes class RGBAxes(_RGBAxes): + """ + Subclass of `~.axes_grid1.axes_rgb.RGBAxes` with + ``_defaultAxesClass`` = `.axislines.Axes`. + """ _defaultAxesClass = Axes diff --git a/lib/mpl_toolkits/axisartist/axis_artist.py b/lib/mpl_toolkits/axisartist/axis_artist.py index 15e719508584..74df999ef24e 100644 --- a/lib/mpl_toolkits/axisartist/axis_artist.py +++ b/lib/mpl_toolkits/axisartist/axis_artist.py @@ -43,11 +43,11 @@ ticklabel), which gives 0 for bottom axis. =================== ====== ======== ====== ======== -Parameter left bottom right top +Property left bottom right top =================== ====== ======== ====== ======== -ticklabels location left right right left +ticklabel location left right right left axislabel location left right right left -ticklabels angle 90 0 -90 180 +ticklabel angle 90 0 -90 180 axislabel angle 180 0 0 180 ticklabel va center baseline center baseline axislabel va center top center bottom @@ -75,7 +75,8 @@ import numpy as np -from matplotlib import _api, cbook, rcParams +import matplotlib as mpl +from matplotlib import _api, cbook import matplotlib.artist as martist import matplotlib.colors as mcolors import matplotlib.text as mtext @@ -105,13 +106,13 @@ def get_attribute_from_ref_artist(self, attr_name): class Ticks(AttributeCopier, Line2D): """ - Ticks are derived from Line2D, and note that ticks themselves + Ticks are derived from `.Line2D`, and note that ticks themselves are markers. Thus, you should use set_mec, set_mew, etc. To change the tick size (length), you need to use - set_ticksize. To change the direction of the ticks (ticks are + `set_ticksize`. To change the direction of the ticks (ticks are in opposite direction of ticklabels by default), use - set_tick_out(False). + ``set_tick_out(False)`` """ def __init__(self, ticksize, tick_out=False, *, axis=None, **kwargs): @@ -201,8 +202,8 @@ def draw(self, renderer): class LabelBase(mtext.Text): """ - A base class for AxisLabel and TickLabels. The position and angle - of the text are calculated by to offset_ref_angle, + A base class for `.AxisLabel` and `.TickLabels`. The position and + angle of the text are calculated by the offset_ref_angle, text_ref_angle, and offset_radius attributes. """ @@ -216,26 +217,16 @@ def __init__(self, *args, **kwargs): self.set_rotation_mode("anchor") self._text_follow_ref_angle = True - def _set_ref_angle(self, a): - self._ref_angle = a - - def _get_ref_angle(self): - return self._ref_angle - - def _get_text_ref_angle(self): + @property + def _text_ref_angle(self): if self._text_follow_ref_angle: - return self._get_ref_angle()+90 + return self._ref_angle + 90 else: - return 0 # self.get_ref_angle() - - def _get_offset_ref_angle(self): - return self._get_ref_angle() + return 0 - def _set_offset_radius(self, offset_radius): - self._offset_radius = offset_radius - - def _get_offset_radius(self): - return self._offset_radius + @property + def _offset_ref_angle(self): + return self._ref_angle _get_opposite_direction = {"left": "right", "right": "left", @@ -249,31 +240,30 @@ def draw(self, renderer): # save original and adjust some properties tr = self.get_transform() angle_orig = self.get_rotation() - text_ref_angle = self._get_text_ref_angle() - offset_ref_angle = self._get_offset_ref_angle() - theta = np.deg2rad(offset_ref_angle) - dd = self._get_offset_radius() + theta = np.deg2rad(self._offset_ref_angle) + dd = self._offset_radius dx, dy = dd * np.cos(theta), dd * np.sin(theta) self.set_transform(tr + Affine2D().translate(dx, dy)) - self.set_rotation(text_ref_angle+angle_orig) + self.set_rotation(self._text_ref_angle + angle_orig) super().draw(renderer) # restore original properties self.set_transform(tr) self.set_rotation(angle_orig) - def get_window_extent(self, renderer): + def get_window_extent(self, renderer=None): + if renderer is None: + renderer = self.figure._get_renderer() + # save original and adjust some properties tr = self.get_transform() angle_orig = self.get_rotation() - text_ref_angle = self._get_text_ref_angle() - offset_ref_angle = self._get_offset_ref_angle() - theta = np.deg2rad(offset_ref_angle) - dd = self._get_offset_radius() + theta = np.deg2rad(self._offset_ref_angle) + dd = self._offset_radius dx, dy = dd * np.cos(theta), dd * np.sin(theta) self.set_transform(tr + Affine2D().translate(dx, dy)) - self.set_rotation(text_ref_angle+angle_orig) + self.set_rotation(self._text_ref_angle + angle_orig) bbox = super().get_window_extent(renderer).frozen() # restore original properties self.set_transform(tr) @@ -284,17 +274,17 @@ def get_window_extent(self, renderer): class AxisLabel(AttributeCopier, LabelBase): """ - Axis Label. Derived from Text. The position of the text is updated + Axis label. Derived from `.Text`. The position of the text is updated in the fly, so changing text position has no effect. Otherwise, the - properties can be changed as a normal Text. + properties can be changed as a normal `.Text`. - To change the pad between ticklabels and axis label, use set_pad. + To change the pad between tick labels and axis label, use `set_pad`. """ def __init__(self, *args, axis_direction="bottom", axis=None, **kwargs): self._axis = axis self._pad = 5 - self._extra_pad = 0 + self._external_pad = 0 # in pixels LabelBase.__init__(self, *args, **kwargs) self.set_axis_direction(axis_direction) @@ -303,7 +293,12 @@ def set_pad(self, pad): Set the internal pad in points. The actual pad will be the sum of the internal pad and the - external pad (the latter is set automatically by the AxisArtist). + external pad (the latter is set automatically by the `.AxisArtist`). + + Parameters + ---------- + pad : float + The internal pad in points. """ self._pad = pad @@ -315,19 +310,12 @@ def get_pad(self): """ return self._pad - def _set_external_pad(self, p): - """Set external pad in pixels.""" - self._extra_pad = p - - def _get_external_pad(self): - """Return external pad in pixels.""" - return self._extra_pad - def get_ref_artist(self): # docstring inherited return self._axis.get_label() def get_text(self): + # docstring inherited t = super().get_text() if t == "__from_axes__": return self._axis.get_label().get_text() @@ -339,6 +327,13 @@ def get_text(self): top=("bottom", "center")) def set_default_alignment(self, d): + """ + Set the default alignment. See `set_axis_direction` for details. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ va, ha = _api.check_getitem(self._default_alignments, d=d) self.set_va(va) self.set_ha(ha) @@ -349,6 +344,13 @@ def set_default_alignment(self, d): top=180) def set_default_angle(self, d): + """ + Set the default angle. See `set_axis_direction` for details. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ self.set_rotation(_api.check_getitem(self._default_angles, d=d)) def set_axis_direction(self, d): @@ -357,7 +359,7 @@ def set_axis_direction(self, d): according to the matplotlib convention. ===================== ========== ========= ========== ========== - property left bottom right top + Property left bottom right top ===================== ========== ========= ========== ========== axislabel angle 180 0 0 180 axislabel va center top center bottom @@ -367,6 +369,10 @@ def set_axis_direction(self, d): Note that the text angles are actually relative to (90 + angle of the direction to the ticklabel), which gives 0 for bottom axis. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} """ self.set_default_alignment(d) self.set_default_angle(d) @@ -378,19 +384,19 @@ def draw(self, renderer): if not self.get_visible(): return - pad = renderer.points_to_pixels(self.get_pad()) - r = self._get_external_pad() + pad - self._set_offset_radius(r) + self._offset_radius = \ + self._external_pad + renderer.points_to_pixels(self.get_pad()) super().draw(renderer) - def get_window_extent(self, renderer): + def get_window_extent(self, renderer=None): + if renderer is None: + renderer = self.figure._get_renderer() if not self.get_visible(): return - pad = renderer.points_to_pixels(self.get_pad()) - r = self._get_external_pad() + pad - self._set_offset_radius(r) + r = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r bb = super().get_window_extent(renderer) @@ -399,14 +405,14 @@ def get_window_extent(self, renderer): class TickLabels(AxisLabel): # mtext.Text """ - Tick Labels. While derived from Text, this single artist draws all - ticklabels. As in AxisLabel, the position of the text is updated + Tick labels. While derived from `.Text`, this single artist draws all + ticklabels. As in `.AxisLabel`, the position of the text is updated in the fly, so changing text position has no effect. Otherwise, - the properties can be changed as a normal Text. Unlike the - ticklabels of the mainline matplotlib, properties of single - ticklabel alone cannot modified. + the properties can be changed as a normal `.Text`. Unlike the + ticklabels of the mainline Matplotlib, properties of a single + ticklabel alone cannot be modified. - To change the pad between ticks and ticklabels, use set_pad. + To change the pad between ticks and ticklabels, use `~.AxisLabel.set_pad`. """ def __init__(self, *, axis_direction="bottom", **kwargs): @@ -421,14 +427,14 @@ def get_ref_artist(self): def set_axis_direction(self, label_direction): """ Adjust the text angle and text alignment of ticklabels - according to the matplotlib convention. + according to the Matplotlib convention. The *label_direction* must be one of [left, right, bottom, top]. ===================== ========== ========= ========== ========== - property left bottom right top + Property left bottom right top ===================== ========== ========= ========== ========== - ticklabels angle 90 0 -90 180 + ticklabel angle 90 0 -90 180 ticklabel va center baseline center baseline ticklabel ha right center right center ===================== ========== ========= ========== ========== @@ -436,6 +442,11 @@ def set_axis_direction(self, label_direction): Note that the text angles are actually relative to (90 + angle of the direction to the ticklabel), which gives 0 for bottom axis. + + Parameters + ---------- + label_direction : {"left", "bottom", "right", "top"} + """ self.set_default_alignment(label_direction) self.set_default_angle(label_direction) @@ -513,20 +524,19 @@ def _get_ticklabels_offsets(self, renderer, label_direction): def draw(self, renderer): if not self.get_visible(): - self._axislabel_pad = self._get_external_pad() + self._axislabel_pad = self._external_pad return r, total_width = self._get_ticklabels_offsets(renderer, self._axis_direction) - pad = (self._get_external_pad() - + renderer.points_to_pixels(self.get_pad())) - self._set_offset_radius(r+pad) + pad = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + pad for (x, y), a, l in self._locs_angles_labels: if not l.strip(): continue - self._set_ref_angle(a) # + add_angle + self._ref_angle = a self.set_x(x) self.set_y(y) self.set_text(l) @@ -538,10 +548,12 @@ def draw(self, renderer): def set_locs_angles_labels(self, locs_angles_labels): self._locs_angles_labels = locs_angles_labels - def get_window_extents(self, renderer): + def get_window_extents(self, renderer=None): + if renderer is None: + renderer = self.figure._get_renderer() if not self.get_visible(): - self._axislabel_pad = self._get_external_pad() + self._axislabel_pad = self._external_pad return [] bboxes = [] @@ -549,12 +561,11 @@ def get_window_extents(self, renderer): r, total_width = self._get_ticklabels_offsets(renderer, self._axis_direction) - pad = self._get_external_pad() + \ - renderer.points_to_pixels(self.get_pad()) - self._set_offset_radius(r+pad) + pad = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + pad for (x, y), a, l in self._locs_angles_labels: - self._set_ref_angle(a) # + add_angle + self._ref_angle = a self.set_x(x) self.set_y(y) self.set_text(l) @@ -586,10 +597,16 @@ def get_texts_widths_heights_descents(self, renderer): class GridlinesCollection(LineCollection): def __init__(self, *args, which="major", axis="both", **kwargs): """ + Collection of grid lines. + Parameters ---------- which : {"major", "minor"} + Which grid to consider. axis : {"both", "x", "y"} + Which axis to consider. + *args, **kwargs : + Passed to `.LineCollection`. """ self._which = which self._axis = axis @@ -597,38 +614,51 @@ def __init__(self, *args, which="major", axis="both", **kwargs): self.set_grid_helper(None) def set_which(self, which): + """ + Select major or minor grid lines. + + Parameters + ---------- + which : {"major", "minor"} + """ self._which = which def set_axis(self, axis): + """ + Select axis. + + Parameters + ---------- + axis : {"both", "x", "y"} + """ self._axis = axis def set_grid_helper(self, grid_helper): + """ + Set grid helper. + + Parameters + ---------- + grid_helper : `.GridHelperBase` subclass + """ self._grid_helper = grid_helper def draw(self, renderer): if self._grid_helper is not None: self._grid_helper.update_lim(self.axes) gl = self._grid_helper.get_gridlines(self._which, self._axis) - if gl: - self.set_segments([np.transpose(l) for l in gl]) - else: - self.set_segments([]) + self.set_segments([np.transpose(l) for l in gl]) super().draw(renderer) class AxisArtist(martist.Artist): """ An artist which draws axis (a line along which the n-th axes coord - is constant) line, ticks, ticklabels, and axis label. + is constant) line, ticks, tick labels, and axis label. """ zorder = 2.5 - @_api.deprecated("3.4") - @_api.classproperty - def ZORDER(cls): - return cls.zorder - @property def LABELPAD(self): return self.label.get_pad() @@ -685,18 +715,18 @@ def __init__(self, axes, def set_axis_direction(self, axis_direction): """ - Adjust the direction, text angle, text alignment of - ticklabels, labels following the matplotlib convention for - the rectangle axes. + Adjust the direction, text angle, and text alignment of tick labels + and axis labels following the Matplotlib convention for the rectangle + axes. The *axis_direction* must be one of [left, right, bottom, top]. ===================== ========== ========= ========== ========== - property left bottom right top + Property left bottom right top ===================== ========== ========= ========== ========== - ticklabels location "-" "+" "+" "-" - axislabel location "-" "+" "+" "-" - ticklabels angle 90 0 -90 180 + ticklabel direction "-" "+" "+" "-" + axislabel direction "-" "+" "+" "-" + ticklabel angle 90 0 -90 180 ticklabel va center baseline center baseline ticklabel ha right center right center axislabel angle 180 0 0 180 @@ -708,6 +738,10 @@ def set_axis_direction(self, axis_direction): the increasing coordinate. Also, the text angles are actually relative to (90 + angle of the direction to the ticklabel), which gives 0 for bottom axis. + + Parameters + ---------- + axis_direction : {"left", "bottom", "right", "top"} """ self.major_ticklabels.set_axis_direction(axis_direction) self.label.set_axis_direction(axis_direction) @@ -721,9 +755,9 @@ def set_axis_direction(self, axis_direction): def set_ticklabel_direction(self, tick_direction): r""" - Adjust the direction of the ticklabel. + Adjust the direction of the tick labels. - Note that the *label_direction*\s '+' and '-' are relative to the + Note that the *tick_direction*\s '+' and '-' are relative to the direction of the increasing coordinate. Parameters @@ -740,7 +774,7 @@ def invert_ticklabel_direction(self): def set_axislabel_direction(self, label_direction): r""" - Adjust the direction of the axislabel. + Adjust the direction of the axis label. Note that the *label_direction*\s '+' and '-' are relative to the direction of the increasing coordinate. @@ -780,6 +814,7 @@ def set_axisline_style(self, axisline_style=None, **kwargs): Examples -------- The following two commands are equal: + >>> set_axisline_style("->,size=1.5") >>> set_axisline_style("->", size=1.5) """ @@ -808,11 +843,11 @@ def _init_line(self): if axisline_style is None: self.line = PathPatch( self._axis_artist_helper.get_line(self.axes), - color=rcParams['axes.edgecolor'], + color=mpl.rcParams['axes.edgecolor'], fill=False, - linewidth=rcParams['axes.linewidth'], - capstyle=rcParams['lines.solid_capstyle'], - joinstyle=rcParams['lines.solid_joinstyle'], + linewidth=mpl.rcParams['axes.linewidth'], + capstyle=mpl.rcParams['lines.solid_capstyle'], + joinstyle=mpl.rcParams['lines.solid_joinstyle'], transform=tran) else: self.line = axisline_style(self, transform=tran) @@ -831,14 +866,16 @@ def _init_ticks(self, **kwargs): self.major_ticks = Ticks( kwargs.get( - "major_tick_size", rcParams[f"{axis_name}tick.major.size"]), + "major_tick_size", + mpl.rcParams[f"{axis_name}tick.major.size"]), axis=self.axis, transform=trans) self.minor_ticks = Ticks( kwargs.get( - "minor_tick_size", rcParams[f"{axis_name}tick.minor.size"]), + "minor_tick_size", + mpl.rcParams[f"{axis_name}tick.minor.size"]), axis=self.axis, transform=trans) - size = rcParams[f"{axis_name}tick.labelsize"] + size = mpl.rcParams[f"{axis_name}tick.labelsize"] self.major_ticklabels = TickLabels( axis=self.axis, axis_direction=self._axis_direction, @@ -846,7 +883,7 @@ def _init_ticks(self, **kwargs): transform=trans, fontsize=size, pad=kwargs.get( - "major_tick_pad", rcParams[f"{axis_name}tick.major.pad"]), + "major_tick_pad", mpl.rcParams[f"{axis_name}tick.major.pad"]), ) self.minor_ticklabels = TickLabels( axis=self.axis, @@ -855,7 +892,7 @@ def _init_ticks(self, **kwargs): transform=trans, fontsize=size, pad=kwargs.get( - "minor_tick_pad", rcParams[f"{axis_name}tick.minor.pad"]), + "minor_tick_pad", mpl.rcParams[f"{axis_name}tick.minor.pad"]), ) def _get_tick_info(self, tick_iter): @@ -880,19 +917,21 @@ def _get_tick_info(self, tick_iter): return ticks_loc_angle, ticklabels_loc_angle_label - def _update_ticks(self, renderer): + def _update_ticks(self, renderer=None): # set extra pad for major and minor ticklabels: use ticksize of # majorticks even for minor ticks. not clear what is best. + if renderer is None: + renderer = self.figure._get_renderer() + dpi_cor = renderer.points_to_pixels(1.) if self.major_ticks.get_visible() and self.major_ticks.get_tick_out(): - self.major_ticklabels._set_external_pad( - self.major_ticks._ticksize * dpi_cor) - self.minor_ticklabels._set_external_pad( - self.major_ticks._ticksize * dpi_cor) + ticklabel_pad = self.major_ticks._ticksize * dpi_cor + self.major_ticklabels._external_pad = ticklabel_pad + self.minor_ticklabels._external_pad = ticklabel_pad else: - self.major_ticklabels._set_external_pad(0) - self.minor_ticklabels._set_external_pad(0) + self.major_ticklabels._external_pad = 0 + self.minor_ticklabels._external_pad = 0 majortick_iter, minortick_iter = \ self._axis_artist_helper.get_tick_iterators(self.axes) @@ -928,7 +967,7 @@ def _init_offsetText(self, direction): "", xy=(x, y), xycoords="axes fraction", xytext=(0, 0), textcoords="offset points", - color=rcParams['xtick.color'], + color=mpl.rcParams['xtick.color'], horizontalalignment=ha, verticalalignment=va, ) self.offsetText.set_transform(IdentityTransform()) @@ -952,8 +991,8 @@ def _init_label(self, **kwargs): self.label = AxisLabel( 0, 0, "__from_axes__", color="auto", - fontsize=kwargs.get("labelsize", rcParams['axes.labelsize']), - fontweight=rcParams['axes.labelweight'], + fontsize=kwargs.get("labelsize", mpl.rcParams['axes.labelsize']), + fontweight=mpl.rcParams['axes.labelweight'], axis=self.axis, transform=tr, axis_direction=self._axis_direction, @@ -978,7 +1017,7 @@ def _update_label(self, renderer): axislabel_pad = max(self.major_ticklabels._axislabel_pad, self.minor_ticklabels._axislabel_pad) - self.label._set_external_pad(axislabel_pad) + self.label._external_pad = axislabel_pad xy, angle_tangent = \ self._axis_artist_helper.get_axislabel_pos_angle(self.axes) @@ -988,7 +1027,7 @@ def _update_label(self, renderer): angle_label = angle_tangent - 90 x, y = xy - self.label._set_ref_angle(angle_label+self._axislabel_add_angle) + self.label._ref_angle = angle_label + self._axislabel_add_angle self.label.set(x=x, y=y) def _draw_label(self, renderer): @@ -996,19 +1035,26 @@ def _draw_label(self, renderer): self.label.draw(renderer) def set_label(self, s): + # docstring inherited self.label.set_text(s) - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer=None): if not self.get_visible(): return self._axis_artist_helper.update_lim(self.axes) self._update_ticks(renderer) self._update_label(renderer) + + self.line.set_path(self._axis_artist_helper.get_line(self.axes)) + if self.get_axisline_style() is not None: + self.line.set_line_mutation_scale(self.major_ticklabels.get_size()) + bb = [ *self.major_ticklabels.get_window_extents(renderer), *self.minor_ticklabels.get_window_extents(renderer), self.label.get_window_extent(renderer), self.offsetText.get_window_extent(renderer), + self.line.get_window_extent(renderer), ] bb = [b for b in bb if b and (b.width != 0 or b.height != 0)] if bb: @@ -1042,7 +1088,7 @@ def toggle(self, all=None, ticks=None, ticklabels=None, label=None): To turn all on but (axis) label off :: - axis.toggle(all=True, label=False)) + axis.toggle(all=True, label=False) """ if all: diff --git a/lib/mpl_toolkits/axisartist/axisline_style.py b/lib/mpl_toolkits/axisartist/axisline_style.py index db4b0c144c5e..5ae188021bb8 100644 --- a/lib/mpl_toolkits/axisartist/axisline_style.py +++ b/lib/mpl_toolkits/axisartist/axisline_style.py @@ -1,10 +1,14 @@ +""" +Provides classes to style the axis lines. +""" import math import numpy as np +import matplotlib as mpl from matplotlib.patches import _Style, FancyArrowPatch -from matplotlib.transforms import IdentityTransform from matplotlib.path import Path +from matplotlib.transforms import IdentityTransform class _FancyAxislineStyle: @@ -54,10 +58,10 @@ def set_path(self, path): def draw(self, renderer): """ Draw the axis line. - 1) transform the path to the display coordinate. - 2) extend the path to make a room for arrow - 3) update the path of the FancyArrowPatch. - 4) draw + 1) Transform the path to the display coordinate. + 2) Extend the path to make a room for arrow. + 3) Update the path of the FancyArrowPatch. + 4) Draw. """ path_in_disp = self._line_transform.transform_path(self._line_path) mutation_size = self.get_mutation_scale() # line_mutation_scale() @@ -66,16 +70,31 @@ def draw(self, renderer): self._path_original = extended_path FancyArrowPatch.draw(self, renderer) + def get_window_extent(self, renderer=None): + + path_in_disp = self._line_transform.transform_path(self._line_path) + mutation_size = self.get_mutation_scale() # line_mutation_scale() + extended_path = self._extend_path(path_in_disp, + mutation_size=mutation_size) + self._path_original = extended_path + return FancyArrowPatch.get_window_extent(self, renderer) + class FilledArrow(SimpleArrow): - """The artist class that will be returned for SimpleArrow style.""" + """The artist class that will be returned for FilledArrow style.""" _ARROW_STYLE = "-|>" + def __init__(self, axis_artist, line_path, transform, + line_mutation_scale, facecolor): + super().__init__(axis_artist, line_path, transform, + line_mutation_scale) + self.set_facecolor(facecolor) + class AxislineStyle(_Style): """ A container class which defines style classes for AxisArtists. - An instance of any axisline style class is an callable object, + An instance of any axisline style class is a callable object, whose call signature is :: __call__(self, axis_artist, path, transform) @@ -140,6 +159,35 @@ def new_line(self, axis_artist, transform): _style_list["->"] = SimpleArrow class FilledArrow(SimpleArrow): + """ + An arrow with a filled head. + """ + ArrowAxisClass = _FancyAxislineStyle.FilledArrow + def __init__(self, size=1, facecolor=None): + """ + Parameters + ---------- + size : float + Size of the arrow as a fraction of the ticklabel size. + facecolor : color, default: :rc:`axes.edgecolor` + Fill color. + + .. versionadded:: 3.7 + """ + + if facecolor is None: + facecolor = mpl.rcParams['axes.edgecolor'] + self.size = size + self._facecolor = facecolor + super().__init__(size=size) + + def new_line(self, axis_artist, transform): + linepath = Path([(0, 0), (0, 1)]) + axisline = self.ArrowAxisClass(axis_artist, linepath, transform, + line_mutation_scale=self.size, + facecolor=self._facecolor) + return axisline + _style_list["-|>"] = FilledArrow diff --git a/lib/mpl_toolkits/axisartist/axislines.py b/lib/mpl_toolkits/axisartist/axislines.py index 65c506341a2c..36c1a004b78d 100644 --- a/lib/mpl_toolkits/axisartist/axislines.py +++ b/lib/mpl_toolkits/axisartist/axislines.py @@ -20,8 +20,8 @@ instance responsible to draw left y-axis. The default Axes.axis contains "bottom", "left", "top" and "right". -AxisArtist can be considered as a container artist and -has following children artists which will draw ticks, labels, etc. +AxisArtist can be considered as a container artist and has the following +children artists which will draw ticks, labels, etc. * line * major_ticks, major_ticklabels @@ -41,104 +41,105 @@ import numpy as np -from matplotlib import _api, rcParams +import matplotlib as mpl +from matplotlib import _api import matplotlib.axes as maxes from matplotlib.path import Path from mpl_toolkits.axes_grid1 import mpl_axes -from .axisline_style import AxislineStyle +from .axisline_style import AxislineStyle # noqa from .axis_artist import AxisArtist, GridlinesCollection class AxisArtistHelper: """ - AxisArtistHelper should define - following method with given APIs. Note that the first axes argument - will be axes attribute of the caller artist.:: + Axis helpers should define the methods listed below. The *axes* argument + will be the axes attribute of the caller artist. + :: - # LINE (spinal line?) - - def get_line(self, axes): - # path : Path - return path + # Construct the spine. def get_line_transform(self, axes): - # ... - # trans : transform - return trans + return transform - # LABEL + def get_line(self, axes): + return path - def get_label_pos(self, axes): - # x, y : position - return (x, y), trans + # Construct the label. + def get_axislabel_transform(self, axes): + return transform - def get_label_offset_transform(self, - axes, - pad_points, fontprops, renderer, - bboxes, - ): - # va : vertical alignment - # ha : horizontal alignment - # a : angle - return trans, va, ha, a + def get_axislabel_pos_angle(self, axes): + return (x, y), angle - # TICK + # Construct the ticks. def get_tick_transform(self, axes): - return trans + return transform def get_tick_iterators(self, axes): - # iter : iterable object that yields (c, angle, l) where - # c, angle, l is position, tick angle, and label - + # A pair of iterables (one for major ticks, one for minor ticks) + # that yield (tick_position, tick_angle, tick_label). return iter_major, iter_minor """ class _Base: """Base class for axis helper.""" - def __init__(self): - self.delta1, self.delta2 = 0.00001, 0.00001 def update_lim(self, axes): pass - class Fixed(_Base): - """Helper class for a fixed (in the axes coordinate) axis.""" - - _default_passthru_pt = dict(left=(0, 0), - right=(1, 0), - bottom=(0, 0), - top=(0, 1)) + delta1 = _api.deprecated("3.6")( + property(lambda self: 0.00001, lambda self, value: None)) + delta2 = _api.deprecated("3.6")( + property(lambda self: 0.00001, lambda self, value: None)) - def __init__(self, loc, nth_coord=None): - """ - nth_coord = along which coordinate value varies - in 2D, nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + def _to_xy(self, values, const): """ - _api.check_in_list(["left", "right", "bottom", "top"], loc=loc) - self._loc = loc + Create a (*values.shape, 2)-shape array representing (x, y) pairs. - if nth_coord is None: - if loc in ["left", "right"]: - nth_coord = 1 - elif loc in ["bottom", "top"]: - nth_coord = 0 + *values* go into the coordinate determined by ``self.nth_coord``. + The other coordinate is filled with the constant *const*. - self.nth_coord = nth_coord + Example:: - super().__init__() + >>> self.nth_coord = 0 + >>> self._to_xy([1, 2, 3], const=0) + array([[1, 0], + [2, 0], + [3, 0]]) + """ + if self.nth_coord == 0: + return np.stack(np.broadcast_arrays(values, const), axis=-1) + elif self.nth_coord == 1: + return np.stack(np.broadcast_arrays(const, values), axis=-1) + else: + raise ValueError("Unexpected nth_coord") - self.passthru_pt = self._default_passthru_pt[loc] + class Fixed(_Base): + """Helper class for a fixed (in the axes coordinate) axis.""" - _verts = np.array([[0., 0.], - [1., 1.]]) - fixed_coord = 1 - nth_coord - _verts[:, fixed_coord] = self.passthru_pt[fixed_coord] + passthru_pt = _api.deprecated("3.7")(property( + lambda self: {"left": (0, 0), "right": (1, 0), + "bottom": (0, 0), "top": (0, 1)}[self._loc])) + def __init__(self, loc, nth_coord=None): + """``nth_coord = 0``: x-axis; ``nth_coord = 1``: y-axis.""" + self.nth_coord = ( + nth_coord if nth_coord is not None else + _api.check_getitem( + {"bottom": 0, "top": 0, "left": 1, "right": 1}, loc=loc)) + if (nth_coord == 0 and loc not in ["left", "right"] + or nth_coord == 1 and loc not in ["bottom", "top"]): + _api.warn_deprecated( + "3.7", message=f"{loc=!r} is incompatible with " + "{nth_coord=}; support is deprecated since %(since)s") + self._loc = loc + self._pos = {"bottom": 0, "top": 1, "left": 0, "right": 1}[loc] + super().__init__() # axis line in transAxes - self._path = Path(_verts) + self._path = Path(self._to_xy((0, 1), const=self._pos)) def get_nth_coord(self): return self.nth_coord @@ -204,38 +205,31 @@ def __init__(self, axes, loc, nth_coord=None): def get_tick_iterators(self, axes): """tick_loc, tick_angle, tick_label""" - - loc = self._loc - - if loc in ["bottom", "top"]: + if self._loc in ["bottom", "top"]: angle_normal, angle_tangent = 90, 0 - else: + else: # "left", "right" angle_normal, angle_tangent = 0, 90 major = self.axis.major - majorLocs = major.locator() - majorLabels = major.formatter.format_ticks(majorLocs) + major_locs = major.locator() + major_labels = major.formatter.format_ticks(major_locs) minor = self.axis.minor - minorLocs = minor.locator() - minorLabels = minor.formatter.format_ticks(minorLocs) + minor_locs = minor.locator() + minor_labels = minor.formatter.format_ticks(minor_locs) tick_to_axes = self.get_tick_transform(axes) - axes.transAxes def _f(locs, labels): - for x, l in zip(locs, labels): - - c = list(self.passthru_pt) # copy - c[self.nth_coord] = x - + for loc, label in zip(locs, labels): + c = self._to_xy(loc, const=self._pos) # check if the tick point is inside axes c2 = tick_to_axes.transform(c) - if (0 - self.delta1 - <= c2[self.nth_coord] - <= 1 + self.delta2): - yield c, angle_normal, angle_tangent, l + if mpl.transforms._interval_contains_close( + (0, 1), c2[self.nth_coord]): + yield c, angle_normal, angle_tangent, label - return _f(majorLocs, majorLabels), _f(minorLocs, minorLabels) + return _f(major_locs, major_labels), _f(minor_locs, minor_labels) class Floating(AxisArtistHelper.Floating): def __init__(self, axes, nth_coord, @@ -245,15 +239,10 @@ def __init__(self, axes, nth_coord, self.axis = [axes.xaxis, axes.yaxis][self.nth_coord] def get_line(self, axes): - _verts = np.array([[0., 0.], - [1., 1.]]) - fixed_coord = 1 - self.nth_coord data_to_axes = axes.transData - axes.transAxes p = data_to_axes.transform([self._value, self._value]) - _verts[:, fixed_coord] = p[fixed_coord] - - return Path(_verts) + return Path(self._to_xy((0, 1), const=p[fixed_coord])) def get_line_transform(self, axes): return axes.transAxes @@ -268,13 +257,12 @@ def get_axislabel_pos_angle(self, axes): get_label_transform() returns a transform of (transAxes+offset) """ angle = [0, 90][self.nth_coord] - _verts = [0.5, 0.5] fixed_coord = 1 - self.nth_coord data_to_axes = axes.transData - axes.transAxes p = data_to_axes.transform([self._value, self._value]) - _verts[fixed_coord] = p[fixed_coord] - if 0 <= _verts[fixed_coord] <= 1: - return _verts, angle + verts = self._to_xy(0.5, const=p[fixed_coord]) + if 0 <= verts[fixed_coord] <= 1: + return verts, angle else: return None, None @@ -289,64 +277,53 @@ def get_tick_iterators(self, axes): angle_normal, angle_tangent = 0, 90 major = self.axis.major - majorLocs = major.locator() - majorLabels = major.formatter.format_ticks(majorLocs) + major_locs = major.locator() + major_labels = major.formatter.format_ticks(major_locs) minor = self.axis.minor - minorLocs = minor.locator() - minorLabels = minor.formatter.format_ticks(minorLocs) + minor_locs = minor.locator() + minor_labels = minor.formatter.format_ticks(minor_locs) data_to_axes = axes.transData - axes.transAxes def _f(locs, labels): - for x, l in zip(locs, labels): - c = [self._value, self._value] - c[self.nth_coord] = x + for loc, label in zip(locs, labels): + c = self._to_xy(loc, const=self._value) c1, c2 = data_to_axes.transform(c) - if (0 <= c1 <= 1 and 0 <= c2 <= 1 - and 0 - self.delta1 - <= [c1, c2][self.nth_coord] - <= 1 + self.delta2): - yield c, angle_normal, angle_tangent, l + if 0 <= c1 <= 1 and 0 <= c2 <= 1: + yield c, angle_normal, angle_tangent, label - return _f(majorLocs, majorLabels), _f(minorLocs, minorLabels) + return _f(major_locs, major_labels), _f(minor_locs, minor_labels) class GridHelperBase: def __init__(self): - self._force_update = True # Remove together with invalidate()/valid(). self._old_limits = None super().__init__() def update_lim(self, axes): x1, x2 = axes.get_xlim() y1, y2 = axes.get_ylim() - if self._force_update or self._old_limits != (x1, x2, y1, y2): + if self._old_limits != (x1, x2, y1, y2): self._update_grid(x1, y1, x2, y2) - self._force_update = False self._old_limits = (x1, x2, y1, y2) def _update_grid(self, x1, y1, x2, y2): """Cache relevant computations when the axes limits have changed.""" - @_api.deprecated("3.4") - def invalidate(self): - self._force_update = True - - @_api.deprecated("3.4") - def valid(self): - return not self._force_update - def get_gridlines(self, which, axis): """ Return list of grid lines as a list of paths (list of points). - *which* : "major" or "minor" - *axis* : "both", "x" or "y" + Parameters + ---------- + which : {"both", "major", "minor"} + axis : {"both", "x", "y"} """ return [] + @_api.deprecated("3.6") def new_gridlines(self, ax): """ Create and return a new GridlineCollection instance. @@ -355,10 +332,10 @@ def new_gridlines(self, ax): *axis* : "both", "x" or "y" """ - gridlines = GridlinesCollection(None, transform=ax.transData, - colors=rcParams['grid.color'], - linestyles=rcParams['grid.linestyle'], - linewidths=rcParams['grid.linewidth']) + gridlines = GridlinesCollection( + None, transform=ax.transData, colors=mpl.rcParams['grid.color'], + linestyles=mpl.rcParams['grid.linestyle'], + linewidths=mpl.rcParams['grid.linewidth']) ax._set_artist_props(gridlines) gridlines.set_grid_helper(self) @@ -421,23 +398,27 @@ def get_gridlines(self, which="major", axis="both"): """ Return list of gridline coordinates in data coordinates. - *which* : "major" or "minor" - *axis* : "both", "x" or "y" + Parameters + ---------- + which : {"both", "major", "minor"} + axis : {"both", "x", "y"} """ + _api.check_in_list(["both", "major", "minor"], which=which) + _api.check_in_list(["both", "x", "y"], axis=axis) gridlines = [] - if axis in ["both", "x"]: + if axis in ("both", "x"): locs = [] y1, y2 = self.axes.get_ylim() - if which in ["both", "major"]: + if which in ("both", "major"): locs.extend(self.axes.xaxis.major.locator()) - if which in ["both", "minor"]: + if which in ("both", "minor"): locs.extend(self.axes.xaxis.minor.locator()) for x in locs: gridlines.append([[x, x], [y1, y2]]) - if axis in ["both", "y"]: + if axis in ("both", "y"): x1, x2 = self.axes.get_xlim() locs = [] if self.axes.yaxis._major_tick_kw["gridOn"]: @@ -477,25 +458,11 @@ def toggle_axisline(self, b=None): self.xaxis.set_visible(True) self.yaxis.set_visible(True) - def _init_axis_artists(self, axes=None): - if axes is None: - axes = self - - self._axislines = mpl_axes.Axes.AxisDict(self) - new_fixed_axis = self.get_grid_helper().new_fixed_axis - for loc in ["bottom", "top", "left", "right"]: - self._axislines[loc] = new_fixed_axis(loc=loc, axes=axes, - axis_direction=loc) - - for axisline in [self._axislines["top"], self._axislines["right"]]: - axisline.label.set_visible(False) - axisline.major_ticklabels.set_visible(False) - axisline.minor_ticklabels.set_visible(False) - @property def axis(self): return self._axislines + @_api.deprecated("3.6") def new_gridlines(self, grid_helper=None): """ Create and return a new GridlineCollection instance. @@ -510,25 +477,37 @@ def new_gridlines(self, grid_helper=None): gridlines = grid_helper.new_gridlines(self) return gridlines - def _init_gridlines(self, grid_helper=None): - # It is done inside the cla. - self.gridlines = self.new_gridlines(grid_helper) + def clear(self): + # docstring inherited - def cla(self): - # gridlines need to b created before cla() since cla calls grid() - self._init_gridlines() - super().cla() + # Init gridlines before clear() as clear() calls grid(). + self.gridlines = gridlines = GridlinesCollection( + None, transform=self.transData, + colors=mpl.rcParams['grid.color'], + linestyles=mpl.rcParams['grid.linestyle'], + linewidths=mpl.rcParams['grid.linewidth']) + self._set_artist_props(gridlines) + gridlines.set_grid_helper(self.get_grid_helper()) - # the clip_path should be set after Axes.cla() since that's - # when a patch is created. - self.gridlines.set_clip_path(self.axes.patch) + super().clear() - self._init_axis_artists() + # clip_path is set after Axes.clear(): that's when a patch is created. + gridlines.set_clip_path(self.axes.patch) + + # Init axis artists. + self._axislines = mpl_axes.Axes.AxisDict(self) + new_fixed_axis = self.get_grid_helper().new_fixed_axis + self._axislines.update({ + loc: new_fixed_axis(loc=loc, axes=self, axis_direction=loc) + for loc in ["bottom", "top", "left", "right"]}) + for axisline in [self._axislines["top"], self._axislines["right"]]: + axisline.label.set_visible(False) + axisline.major_ticklabels.set_visible(False) + axisline.minor_ticklabels.set_visible(False) def get_grid_helper(self): return self._grid_helper - @_api.rename_parameter("3.5", "b", "visible") def grid(self, visible=None, which='major', axis="both", **kwargs): """ Toggle the gridlines, and optionally set the properties of the lines. @@ -555,10 +534,6 @@ def get_children(self): children.extend(super().get_children()) return children - @_api.deprecated("3.4") - def invalidate_grid_helper(self): - self._grid_helper.invalidate() - def new_fixed_axis(self, loc, offset=None): gh = self.get_grid_helper() axis = gh.new_fixed_axis(loc, @@ -577,32 +552,21 @@ def new_floating_axis(self, nth_coord, value, axis_direction="bottom"): return axis -Subplot = maxes.subplot_class_factory(Axes) - - class AxesZero(Axes): - def _init_axis_artists(self): - super()._init_axis_artists() - - new_floating_axis = self._grid_helper.new_floating_axis - xaxis_zero = new_floating_axis(nth_coord=0, - value=0., - axis_direction="bottom", - axes=self) - - xaxis_zero.line.set_clip_path(self.patch) - xaxis_zero.set_visible(False) - self._axislines["xzero"] = xaxis_zero - - yaxis_zero = new_floating_axis(nth_coord=1, - value=0., - axis_direction="left", - axes=self) - - yaxis_zero.line.set_clip_path(self.patch) - yaxis_zero.set_visible(False) - self._axislines["yzero"] = yaxis_zero - - -SubplotZero = maxes.subplot_class_factory(AxesZero) + def clear(self): + super().clear() + new_floating_axis = self.get_grid_helper().new_floating_axis + self._axislines.update( + xzero=new_floating_axis( + nth_coord=0, value=0., axis_direction="bottom", axes=self), + yzero=new_floating_axis( + nth_coord=1, value=0., axis_direction="left", axes=self), + ) + for k in ["xzero", "yzero"]: + self._axislines[k].line.set_clip_path(self.patch) + self._axislines[k].set_visible(False) + + +Subplot = Axes +SubplotZero = AxesZero diff --git a/lib/mpl_toolkits/axisartist/clip_path.py b/lib/mpl_toolkits/axisartist/clip_path.py deleted file mode 100644 index 53b75f073a44..000000000000 --- a/lib/mpl_toolkits/axisartist/clip_path.py +++ /dev/null @@ -1,121 +0,0 @@ -import numpy as np -from math import degrees -from matplotlib import _api -import math - - -_api.warn_deprecated("3.5", name=__name__, obj_type="module") - - -def atan2(dy, dx): - if dx == 0 and dy == 0: - _api.warn_external("dx and dy are 0") - return 0 - else: - return math.atan2(dy, dx) - - -# FIXME : The current algorithm seems to return incorrect angle when the line -# ends at the boundary. -def clip(xlines, ylines, x0, clip="right", xdir=True, ydir=True): - - clipped_xlines = [] - clipped_ylines = [] - - _pos_angles = [] - - xsign = 1 if xdir else -1 - ysign = 1 if ydir else -1 - - for x, y in zip(xlines, ylines): - - if clip in ["up", "right"]: - b = (x < x0).astype("i") - db = b[1:] - b[:-1] - else: - b = (x > x0).astype("i") - db = b[1:] - b[:-1] - - if b[0]: - ns = 0 - else: - ns = -1 - segx, segy = [], [] - for (i,) in np.argwhere(db): - c = db[i] - if c == -1: - dx = (x0 - x[i]) - dy = (y[i+1] - y[i]) * (dx / (x[i+1] - x[i])) - y0 = y[i] + dy - clipped_xlines.append(np.concatenate([segx, x[ns:i+1], [x0]])) - clipped_ylines.append(np.concatenate([segy, y[ns:i+1], [y0]])) - ns = -1 - segx, segy = [], [] - - if dx == 0. and dy == 0: - dx = x[i+1] - x[i] - dy = y[i+1] - y[i] - - a = degrees(atan2(ysign*dy, xsign*dx)) - _pos_angles.append((x0, y0, a)) - - elif c == 1: - dx = (x0 - x[i]) - dy = (y[i+1] - y[i]) * (dx / (x[i+1] - x[i])) - y0 = y[i] + dy - segx, segy = [x0], [y0] - ns = i+1 - - if dx == 0. and dy == 0: - dx = x[i+1] - x[i] - dy = y[i+1] - y[i] - - a = degrees(atan2(ysign*dy, xsign*dx)) - _pos_angles.append((x0, y0, a)) - - if ns != -1: - clipped_xlines.append(np.concatenate([segx, x[ns:]])) - clipped_ylines.append(np.concatenate([segy, y[ns:]])) - - return clipped_xlines, clipped_ylines, _pos_angles - - -def clip_line_to_rect(xline, yline, bbox): - - x0, y0, x1, y1 = bbox.extents - - xdir = x1 > x0 - ydir = y1 > y0 - - if x1 > x0: - lx1, ly1, c_right_ = clip([xline], [yline], x1, - clip="right", xdir=xdir, ydir=ydir) - lx2, ly2, c_left_ = clip(lx1, ly1, x0, - clip="left", xdir=xdir, ydir=ydir) - else: - lx1, ly1, c_right_ = clip([xline], [yline], x0, - clip="right", xdir=xdir, ydir=ydir) - lx2, ly2, c_left_ = clip(lx1, ly1, x1, - clip="left", xdir=xdir, ydir=ydir) - - if y1 > y0: - ly3, lx3, c_top_ = clip(ly2, lx2, y1, - clip="right", xdir=ydir, ydir=xdir) - ly4, lx4, c_bottom_ = clip(ly3, lx3, y0, - clip="left", xdir=ydir, ydir=xdir) - else: - ly3, lx3, c_top_ = clip(ly2, lx2, y0, - clip="right", xdir=ydir, ydir=xdir) - ly4, lx4, c_bottom_ = clip(ly3, lx3, y1, - clip="left", xdir=ydir, ydir=xdir) - - c_left = [((x, y), (a + 90) % 180 - 90) for x, y, a in c_left_ - if bbox.containsy(y)] - c_bottom = [((x, y), (90 - a) % 180) for y, x, a in c_bottom_ - if bbox.containsx(x)] - c_right = [((x, y), (a + 90) % 180 + 90) for x, y, a in c_right_ - if bbox.containsy(y)] - c_top = [((x, y), (90 - a) % 180 + 180) for y, x, a in c_top_ - if bbox.containsx(x)] - - return list(zip(lx4, ly4)), [c_left, c_bottom, c_right, c_top] diff --git a/lib/mpl_toolkits/axisartist/floating_axes.py b/lib/mpl_toolkits/axisartist/floating_axes.py index 84690ff3259b..82d0f5a89da7 100644 --- a/lib/mpl_toolkits/axisartist/floating_axes.py +++ b/lib/mpl_toolkits/axisartist/floating_axes.py @@ -5,12 +5,14 @@ # TODO : # see if tick_iterator method can be simplified by reusing the parent method. +import functools + import numpy as np +import matplotlib as mpl from matplotlib import _api, cbook import matplotlib.patches as mpatches from matplotlib.path import Path -import matplotlib.axes as maxes from mpl_toolkits.axes_grid1.parasite_axes import host_axes_class_factory @@ -51,82 +53,50 @@ def get_tick_iterators(self, axes): grid_finder = self.grid_helper.grid_finder lat_levs, lat_n, lat_factor = self._grid_info["lat_info"] - lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] + yy0 = lat_levs / lat_factor - lon_levs, lat_levs = np.asarray(lon_levs), np.asarray(lat_levs) - if lat_factor is not None: - yy0 = lat_levs / lat_factor - dy = 0.001 / lat_factor - else: - yy0 = lat_levs - dy = 0.001 - - if lon_factor is not None: - xx0 = lon_levs / lon_factor - dx = 0.001 / lon_factor - else: - xx0 = lon_levs - dx = 0.001 + lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] + xx0 = lon_levs / lon_factor extremes = self.grid_helper._extremes xmin, xmax = sorted(extremes[:2]) ymin, ymax = sorted(extremes[2:]) - def transform_xy(x, y): + def trf_xy(x, y): trf = grid_finder.get_transform() + axes.transData - return trf.transform(np.column_stack([x, y])).T + return trf.transform(np.column_stack(np.broadcast_arrays(x, y))).T if self.nth_coord == 0: mask = (ymin <= yy0) & (yy0 <= ymax) - yy0 = yy0[mask] - xx0 = np.full_like(yy0, self.value) - xx1, yy1 = transform_xy(xx0, yy0) - - xx00 = xx0.astype(float, copy=True) - xx00[xx0 + dx > xmax] -= dx - xx1a, yy1a = transform_xy(xx00, yy0) - xx1b, yy1b = transform_xy(xx00 + dx, yy0) - - yy00 = yy0.astype(float, copy=True) - yy00[yy0 + dy > ymax] -= dy - xx2a, yy2a = transform_xy(xx0, yy00) - xx2b, yy2b = transform_xy(xx0, yy00 + dy) - + (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \ + grid_helper_curvelinear._value_and_jacobian( + trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax)) labels = self._grid_info["lat_labels"] - labels = [l for l, m in zip(labels, mask) if m] elif self.nth_coord == 1: mask = (xmin <= xx0) & (xx0 <= xmax) - xx0 = xx0[mask] - yy0 = np.full_like(xx0, self.value) - xx1, yy1 = transform_xy(xx0, yy0) + (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \ + grid_helper_curvelinear._value_and_jacobian( + trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax)) + labels = self._grid_info["lon_labels"] - yy00 = yy0.astype(float, copy=True) - yy00[yy0 + dy > ymax] -= dy - xx1a, yy1a = transform_xy(xx0, yy00) - xx1b, yy1b = transform_xy(xx0, yy00 + dy) + labels = [l for l, m in zip(labels, mask) if m] - xx00 = xx0.astype(float, copy=True) - xx00[xx0 + dx > xmax] -= dx - xx2a, yy2a = transform_xy(xx00, yy0) - xx2b, yy2b = transform_xy(xx00 + dx, yy0) + angle_normal = np.arctan2(dyy1, dxx1) + angle_tangent = np.arctan2(dyy2, dxx2) + mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal + angle_normal[mm] = angle_tangent[mm] + np.pi / 2 - labels = self._grid_info["lon_labels"] - labels = [l for l, m in zip(labels, mask) if m] + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + in_01 = functools.partial( + mpl.transforms._interval_contains_close, (0, 1)) def f1(): - dd = np.arctan2(yy1b - yy1a, xx1b - xx1a) # angle normal - dd2 = np.arctan2(yy2b - yy2a, xx2b - xx2a) # angle tangent - mm = (yy1b - yy1a == 0) & (xx1b - xx1a == 0) # mask not defined dd - dd[mm] = dd2[mm] + np.pi / 2 - - tick_to_axes = self.get_tick_transform(axes) - axes.transAxes - for x, y, d, d2, lab in zip(xx1, yy1, dd, dd2, labels): + for x, y, normal, tangent, lab \ + in zip(xx1, yy1, angle_normal, angle_tangent, labels): c2 = tick_to_axes.transform((x, y)) - delta = 0.00001 - if 0-delta <= c2[0] <= 1+delta and 0-delta <= c2[1] <= 1+delta: - d1, d2 = np.rad2deg([d, d2]) - yield [x, y], d1, d2, lab + if in_01(c2[0]) and in_01(c2[1]): + yield [x, y], *np.rad2deg([normal, tangent]), lab return f1(), iter([]) @@ -238,31 +208,25 @@ def _update_grid(self, x1, y1, x2, y2): lon_min, lon_max = sorted(extremes[:2]) lat_min, lat_max = sorted(extremes[2:]) + grid_info["extremes"] = lon_min, lon_max, lat_min, lat_max # extremes + lon_levs, lon_n, lon_factor = \ grid_finder.grid_locator1(lon_min, lon_max) + lon_levs = np.asarray(lon_levs) lat_levs, lat_n, lat_factor = \ grid_finder.grid_locator2(lat_min, lat_max) - grid_info["extremes"] = lon_min, lon_max, lat_min, lat_max # extremes + lat_levs = np.asarray(lat_levs) grid_info["lon_info"] = lon_levs, lon_n, lon_factor grid_info["lat_info"] = lat_levs, lat_n, lat_factor - grid_info["lon_labels"] = grid_finder.tick_formatter1("bottom", - lon_factor, - lon_levs) - - grid_info["lat_labels"] = grid_finder.tick_formatter2("bottom", - lat_factor, - lat_levs) + grid_info["lon_labels"] = grid_finder.tick_formatter1( + "bottom", lon_factor, lon_levs) + grid_info["lat_labels"] = grid_finder.tick_formatter2( + "bottom", lat_factor, lat_levs) - if lon_factor is None: - lon_values = np.asarray(lon_levs[:lon_n]) - else: - lon_values = np.asarray(lon_levs[:lon_n]/lon_factor) - if lat_factor is None: - lat_values = np.asarray(lat_levs[:lat_n]) - else: - lat_values = np.asarray(lat_levs[:lat_n]/lat_factor) + lon_values = lon_levs[:lon_n] / lon_factor + lat_values = lat_levs[:lat_n] / lat_factor lon_lines, lat_lines = grid_finder._get_raw_grid_lines( lon_values[(lon_min < lon_values) & (lon_values < lon_max)], @@ -287,37 +251,12 @@ def get_gridlines(self, which="major", axis="both"): grid_lines.extend(self._grid_info["lat_lines"]) return grid_lines - @_api.deprecated("3.5") - def get_boundary(self): - """ - Return (N, 2) array of (x, y) coordinate of the boundary. - """ - x0, x1, y0, y1 = self._extremes - tr = self._aux_trans - - xx = np.linspace(x0, x1, 100) - yy0 = np.full_like(xx, y0) - yy1 = np.full_like(xx, y1) - yy = np.linspace(y0, y1, 100) - xx0 = np.full_like(yy, x0) - xx1 = np.full_like(yy, x1) - - xxx = np.concatenate([xx[:-1], xx1[:-1], xx[-1:0:-1], xx0]) - yyy = np.concatenate([yy0[:-1], yy[:-1], yy1[:-1], yy[::-1]]) - t = tr.transform(np.array([xxx, yyy]).transpose()) - - return t - class FloatingAxesBase: - def __init__(self, *args, **kwargs): - grid_helper = kwargs.get("grid_helper", None) - if grid_helper is None: - raise ValueError("FloatingAxes requires grid_helper argument") - if not hasattr(grid_helper, "get_boundary"): - raise ValueError("grid_helper must implement get_boundary method") - super().__init__(*args, **kwargs) + def __init__(self, *args, grid_helper, **kwargs): + _api.check_isinstance(GridHelperCurveLinear, grid_helper=grid_helper) + super().__init__(*args, grid_helper=grid_helper, **kwargs) self.set_aspect(1.) self.adjust_axes_lim() @@ -331,8 +270,8 @@ def _gen_axes_patch(self): patch.get_path()._interpolation_steps = 100 return patch - def cla(self): - super().cla() + def clear(self): + super().clear() self.patch.set_transform( self.get_grid_helper().grid_finder.get_transform() + self.transData) @@ -357,4 +296,4 @@ def adjust_axes_lim(self): FloatingAxesBase, "Floating{}") FloatingAxes = floatingaxes_class_factory( host_axes_class_factory(axislines.Axes)) -FloatingSubplot = maxes.subplot_class_factory(FloatingAxes) +FloatingSubplot = FloatingAxes diff --git a/lib/mpl_toolkits/axisartist/grid_finder.py b/lib/mpl_toolkits/axisartist/grid_finder.py index 22a139e40e85..4468eb65f3e7 100644 --- a/lib/mpl_toolkits/axisartist/grid_finder.py +++ b/lib/mpl_toolkits/axisartist/grid_finder.py @@ -130,7 +130,7 @@ def __init__(self, tick_formatter2=None): """ transform : transform from the image coordinate (which will be - the transData of the axes to the world coordinate. + the transData of the axes to the world coordinate). or transform = (transform_xy, inv_transform_xy) @@ -166,7 +166,9 @@ def get_grid_info(self, x1, y1, x2, y2): lon_min, lon_max, lat_min, lat_max = extremes lon_levs, lon_n, lon_factor = self.grid_locator1(lon_min, lon_max) + lon_levs = np.asarray(lon_levs) lat_levs, lat_n, lat_factor = self.grid_locator2(lat_min, lat_max) + lat_levs = np.asarray(lat_levs) lon_values = lon_levs[:lon_n] / lon_factor lat_values = lat_levs[:lat_n] / lat_factor @@ -263,16 +265,16 @@ def inv_transform_xy(self, x, y): return self._aux_transform.inverted().transform( np.column_stack([x, y])).T - def update(self, **kw): - for k in kw: + def update(self, **kwargs): + for k, v in kwargs.items(): if k in ["extreme_finder", "grid_locator1", "grid_locator2", "tick_formatter1", "tick_formatter2"]: - setattr(self, k, kw[k]) + setattr(self, k, v) else: - raise ValueError("Unknown update property '%s'" % k) + raise ValueError(f"Unknown update property {k!r}") class MaxNLocator(mticker.MaxNLocator): diff --git a/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py index 21427fa7fcdd..200c6d89ab92 100644 --- a/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py @@ -1,11 +1,13 @@ """ An experimental support for curvilinear grid. """ + +import functools from itertools import chain import numpy as np -from matplotlib import _api +import matplotlib as mpl from matplotlib.path import Path from matplotlib.transforms import Affine2D, IdentityTransform from .axislines import AxisArtistHelper, GridHelperBase @@ -13,6 +15,31 @@ from .grid_finder import GridFinder +def _value_and_jacobian(func, xs, ys, xlims, ylims): + """ + Compute *func* and its derivatives along x and y at positions *xs*, *ys*, + while ensuring that finite difference calculations don't try to evaluate + values outside of *xlims*, *ylims*. + """ + eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime + val = func(xs, ys) + # Take the finite difference step in the direction where the bound is the + # furthest; the step size is min of epsilon and distance to that bound. + xlo, xhi = sorted(xlims) + dxlo = xs - xlo + dxhi = xhi - xs + xeps = (np.take([-1, 1], dxhi >= dxlo) + * np.minimum(eps, np.maximum(dxlo, dxhi))) + val_dx = func(xs + xeps, ys) + ylo, yhi = sorted(ylims) + dylo = ys - ylo + dyhi = yhi - ys + yeps = (np.take([-1, 1], dyhi >= dylo) + * np.minimum(eps, np.maximum(dylo, dyhi))) + val_dy = func(xs, ys + yeps) + return (val, (val_dx - val) / xeps, (val_dy - val) / yeps) + + class FixedAxisArtistHelper(AxisArtistHelper.Fixed): """ Helper class for a fixed axis. @@ -36,15 +63,6 @@ def __init__(self, grid_helper, side, nth_coord_ticks=None): def update_lim(self, axes): self.grid_helper.update_lim(axes) - @_api.deprecated("3.5") - def change_tick_coord(self, coord_number=None): - if coord_number is None: - self.nth_coord_ticks = 1 - self.nth_coord_ticks - elif coord_number in [0, 1]: - self.nth_coord_ticks = coord_number - else: - raise Exception("wrong coord number") - def get_tick_transform(self, axes): return axes.transData @@ -63,8 +81,6 @@ def get_tick_iterators(self, axes): class FloatingAxisArtistHelper(AxisArtistHelper.Floating): - grid_info = _api.deprecate_privatize_attribute("3.5") - def __init__(self, grid_helper, nth_coord, value, axis_direction=None): """ nth_coord = along which coordinate value varies. @@ -117,8 +133,8 @@ def update_lim(self, axes): self._grid_info = { "extremes": (lon_min, lon_max, lat_min, lat_max), - "lon_info": (lon_levs, lon_n, lon_factor), - "lat_info": (lat_levs, lat_n, lat_factor), + "lon_info": (lon_levs, lon_n, np.asarray(lon_factor)), + "lat_info": (lat_levs, lat_n, np.asarray(lat_factor)), "lon_labels": grid_finder.tick_formatter1( "bottom", lon_factor, lon_levs), "lat_labels": grid_finder.tick_formatter2( @@ -130,31 +146,23 @@ def get_axislabel_transform(self, axes): return Affine2D() # axes.transData def get_axislabel_pos_angle(self, axes): + def trf_xy(x, y): + trf = self.grid_helper.grid_finder.get_transform() + axes.transData + return trf.transform([x, y]).T - extremes = self._grid_info["extremes"] - + xmin, xmax, ymin, ymax = self._grid_info["extremes"] if self.nth_coord == 0: xx0 = self.value - yy0 = (extremes[2] + extremes[3]) / 2 - dxx = 0 - dyy = abs(extremes[2] - extremes[3]) / 1000 + yy0 = (ymin + ymax) / 2 elif self.nth_coord == 1: - xx0 = (extremes[0] + extremes[1]) / 2 + xx0 = (xmin + xmax) / 2 yy0 = self.value - dxx = abs(extremes[0] - extremes[1]) / 1000 - dyy = 0 - - grid_finder = self.grid_helper.grid_finder - (xx1,), (yy1,) = grid_finder.transform_xy([xx0], [yy0]) - - data_to_axes = axes.transData - axes.transAxes - p = data_to_axes.transform([xx1, yy1]) - + xy1, dxy1_dx, dxy1_dy = _value_and_jacobian( + trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax)) + p = axes.transAxes.inverted().transform(xy1) if 0 <= p[0] <= 1 and 0 <= p[1] <= 1: - xx1c, yy1c = axes.transData.transform([xx1, yy1]) - (xx2,), (yy2,) = grid_finder.transform_xy([xx0 + dxx], [yy0 + dyy]) - xx2c, yy2c = axes.transData.transform([xx2, yy2]) - return (xx1c, yy1c), np.rad2deg(np.arctan2(yy2c-yy1c, xx2c-xx1c)) + d = [dxy1_dy, dxy1_dx][self.nth_coord] + return xy1, np.rad2deg(np.arctan2(*d[::-1])) else: return None, None @@ -164,79 +172,48 @@ def get_tick_transform(self, axes): def get_tick_iterators(self, axes): """tick_loc, tick_angle, tick_label, (optionally) tick_label""" - grid_finder = self.grid_helper.grid_finder - lat_levs, lat_n, lat_factor = self._grid_info["lat_info"] - lat_levs = np.asarray(lat_levs) yy0 = lat_levs / lat_factor - dy = 0.01 / lat_factor lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] - lon_levs = np.asarray(lon_levs) xx0 = lon_levs / lon_factor - dx = 0.01 / lon_factor e0, e1 = self._extremes - if self.nth_coord == 0: - mask = (e0 <= yy0) & (yy0 <= e1) - # xx0, yy0 = xx0[mask], yy0[mask] - yy0 = yy0[mask] - elif self.nth_coord == 1: - mask = (e0 <= xx0) & (xx0 <= e1) - # xx0, yy0 = xx0[mask], yy0[mask] - xx0 = xx0[mask] - - def transform_xy(x, y): - trf = grid_finder.get_transform() + axes.transData - return trf.transform(np.column_stack([x, y])).T + def trf_xy(x, y): + trf = self.grid_helper.grid_finder.get_transform() + axes.transData + return trf.transform(np.column_stack(np.broadcast_arrays(x, y))).T # find angles if self.nth_coord == 0: - xx0 = np.full_like(yy0, self.value) - - xx1, yy1 = transform_xy(xx0, yy0) - - xx00 = xx0.copy() - xx00[xx0 + dx > e1] -= dx - xx1a, yy1a = transform_xy(xx00, yy0) - xx1b, yy1b = transform_xy(xx00+dx, yy0) - - xx2a, yy2a = transform_xy(xx0, yy0) - xx2b, yy2b = transform_xy(xx0, yy0+dy) - + mask = (e0 <= yy0) & (yy0 <= e1) + (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian( + trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1)) labels = self._grid_info["lat_labels"] - labels = [l for l, m in zip(labels, mask) if m] elif self.nth_coord == 1: - yy0 = np.full_like(xx0, self.value) + mask = (e0 <= xx0) & (xx0 <= e1) + (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian( + trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1)) + labels = self._grid_info["lon_labels"] - xx1, yy1 = transform_xy(xx0, yy0) + labels = [l for l, m in zip(labels, mask) if m] - xx1a, yy1a = transform_xy(xx0, yy0) - xx1b, yy1b = transform_xy(xx0, yy0+dy) + angle_normal = np.arctan2(dyy1, dxx1) + angle_tangent = np.arctan2(dyy2, dxx2) + mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal + angle_normal[mm] = angle_tangent[mm] + np.pi / 2 - xx00 = xx0.copy() - xx00[xx0 + dx > e1] -= dx - xx2a, yy2a = transform_xy(xx00, yy0) - xx2b, yy2b = transform_xy(xx00+dx, yy0) - - labels = self._grid_info["lon_labels"] - labels = [l for l, m in zip(labels, mask) if m] + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + in_01 = functools.partial( + mpl.transforms._interval_contains_close, (0, 1)) def f1(): - dd = np.arctan2(yy1b-yy1a, xx1b-xx1a) # angle normal - dd2 = np.arctan2(yy2b-yy2a, xx2b-xx2a) # angle tangent - mm = (yy1b == yy1a) & (xx1b == xx1a) # mask where dd not defined - dd[mm] = dd2[mm] + np.pi / 2 - - tick_to_axes = self.get_tick_transform(axes) - axes.transAxes - for x, y, d, d2, lab in zip(xx1, yy1, dd, dd2, labels): + for x, y, normal, tangent, lab \ + in zip(xx1, yy1, angle_normal, angle_tangent, labels): c2 = tick_to_axes.transform((x, y)) - delta = 0.00001 - if 0-delta <= c2[0] <= 1+delta and 0-delta <= c2[1] <= 1+delta: - d1, d2 = np.rad2deg([d, d2]) - yield [x, y], d1, d2, lab + if in_01(c2[0]) and in_01(c2[1]): + yield [x, y], *np.rad2deg([normal, tangent]), lab return f1(), iter([]) @@ -250,8 +227,6 @@ def get_line(self, axes): class GridHelperCurveLinear(GridHelperBase): - grid_info = _api.deprecate_privatize_attribute("3.5") - def __init__(self, aux_trans, extreme_finder=None, grid_locator1=None, @@ -278,10 +253,10 @@ def __init__(self, aux_trans, tick_formatter1, tick_formatter2) - def update_grid_finder(self, aux_trans=None, **kw): + def update_grid_finder(self, aux_trans=None, **kwargs): if aux_trans is not None: self.grid_finder.update_transform(aux_trans) - self.grid_finder.update(**kw) + self.grid_finder.update(**kwargs) self._old_limits = None # Force revalidation. def new_fixed_axis(self, loc, diff --git a/lib/mpl_toolkits/axisartist/parasite_axes.py b/lib/mpl_toolkits/axisartist/parasite_axes.py index b4d1955bb1b5..4ebd6acc03be 100644 --- a/lib/mpl_toolkits/axisartist/parasite_axes.py +++ b/lib/mpl_toolkits/axisartist/parasite_axes.py @@ -1,12 +1,7 @@ -from matplotlib import _api from mpl_toolkits.axes_grid1.parasite_axes import ( - host_axes_class_factory, parasite_axes_class_factory, - parasite_axes_auxtrans_class_factory, subplot_class_factory) + host_axes_class_factory, parasite_axes_class_factory) from .axislines import Axes ParasiteAxes = parasite_axes_class_factory(Axes) -HostAxes = host_axes_class_factory(Axes) -SubplotHost = subplot_class_factory(HostAxes) -with _api.suppress_matplotlib_deprecation_warning(): - ParasiteAxesAuxTrans = parasite_axes_auxtrans_class_factory(ParasiteAxes) +HostAxes = SubplotHost = host_axes_class_factory(Axes) diff --git a/lib/mpl_toolkits/axisartist/tests/__init__.py b/lib/mpl_toolkits/axisartist/tests/__init__.py new file mode 100644 index 000000000000..5b6390f4fe26 --- /dev/null +++ b/lib/mpl_toolkits/axisartist/tests/__init__.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +# Check that the test directories exist +if not (Path(__file__).parent / "baseline_images").exists(): + raise IOError( + 'The baseline image directory does not exist. ' + 'This is most likely because the test data is not installed. ' + 'You may need to install matplotlib from source to get the ' + 'test data.') diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist_labelbase.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist_labelbase.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist_labelbase.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist_labelbase.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist_ticklabels.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist_ticklabels.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist_ticklabels.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist_ticklabels.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist_ticks.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist_ticks.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axis_artist/axis_artist_ticks.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axis_artist/axis_artist_ticks.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axislines/ParasiteAxesAuxTrans_meshplot.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/ParasiteAxesAuxTrans_meshplot.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axislines/ParasiteAxesAuxTrans_meshplot.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/ParasiteAxesAuxTrans_meshplot.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axislines/Subplot.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/Subplot.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axislines/Subplot.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/Subplot.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_axislines/SubplotZero.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/SubplotZero.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_axislines/SubplotZero.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/SubplotZero.png diff --git a/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style.png new file mode 100644 index 000000000000..31c194bd8af6 Binary files /dev/null and b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style.png differ diff --git a/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_size_color.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_size_color.png new file mode 100644 index 000000000000..046928cba3c7 Binary files /dev/null and b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_size_color.png differ diff --git a/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png new file mode 100644 index 000000000000..77314c1695a0 Binary files /dev/null and b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png differ diff --git a/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/subplotzero_ylabel.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/subplotzero_ylabel.png new file mode 100644 index 000000000000..9dc9e4a1540d Binary files /dev/null and b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/subplotzero_ylabel.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_floating_axes/curvelinear3.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_floating_axes/curvelinear3.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_floating_axes/curvelinear3.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_floating_axes/curvelinear3.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_floating_axes/curvelinear4.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_floating_axes/curvelinear4.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_floating_axes/curvelinear4.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_floating_axes/curvelinear4.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_grid_helper_curvelinear/axis_direction.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_grid_helper_curvelinear/axis_direction.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_grid_helper_curvelinear/axis_direction.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_grid_helper_curvelinear/axis_direction.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_grid_helper_curvelinear/custom_transform.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_grid_helper_curvelinear/custom_transform.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_grid_helper_curvelinear/custom_transform.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_grid_helper_curvelinear/custom_transform.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_grid_helper_curvelinear/polar_box.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_grid_helper_curvelinear/polar_box.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_axisartist_grid_helper_curvelinear/polar_box.png rename to lib/mpl_toolkits/axisartist/tests/baseline_images/test_grid_helper_curvelinear/polar_box.png diff --git a/lib/mpl_toolkits/axisartist/tests/conftest.py b/lib/mpl_toolkits/axisartist/tests/conftest.py new file mode 100644 index 000000000000..61c2de3e07ba --- /dev/null +++ b/lib/mpl_toolkits/axisartist/tests/conftest.py @@ -0,0 +1,2 @@ +from matplotlib.testing.conftest import (mpl_test_settings, # noqa + pytest_configure, pytest_unconfigure) diff --git a/lib/mpl_toolkits/tests/test_axisartist_angle_helper.py b/lib/mpl_toolkits/axisartist/tests/test_angle_helper.py similarity index 100% rename from lib/mpl_toolkits/tests/test_axisartist_angle_helper.py rename to lib/mpl_toolkits/axisartist/tests/test_angle_helper.py diff --git a/lib/mpl_toolkits/tests/test_axisartist_axis_artist.py b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py similarity index 95% rename from lib/mpl_toolkits/tests/test_axisartist_axis_artist.py rename to lib/mpl_toolkits/axisartist/tests/test_axis_artist.py index 1bebbfd9b81d..391fd116ea86 100644 --- a/lib/mpl_toolkits/tests/test_axisartist_axis_artist.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py @@ -34,8 +34,8 @@ def test_labelbase(): ax.plot([0.5], [0.5], "o") label = LabelBase(0.5, 0.5, "Test") - label._set_ref_angle(-90) - label._set_offset_radius(offset_radius=50) + label._ref_angle = -90 + label._offset_radius = 50 label.set_rotation(-90) label.set(ha="center", va="top") ax.add_artist(label) @@ -67,8 +67,8 @@ def test_ticklabels(): ax.plot([0.5], [0.5], "s") axislabel = AxisLabel(0.5, 0.5, "Test") - axislabel._set_offset_radius(20) - axislabel._set_ref_angle(0) + axislabel._offset_radius = 20 + axislabel._ref_angle = 0 axislabel.set_axis_direction("bottom") ax.add_artist(axislabel) diff --git a/lib/mpl_toolkits/tests/test_axisartist_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py similarity index 50% rename from lib/mpl_toolkits/tests/test_axisartist_axislines.py rename to lib/mpl_toolkits/axisartist/tests/test_axislines.py index 0502a0f23879..123123069623 100644 --- a/lib/mpl_toolkits/tests/test_axisartist_axislines.py +++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py @@ -1,14 +1,10 @@ import numpy as np -from matplotlib import _api import matplotlib.pyplot as plt from matplotlib.testing.decorators import image_comparison from matplotlib.transforms import IdentityTransform -from mpl_toolkits.axisartist.axislines import SubplotZero, Subplot -from mpl_toolkits.axisartist import ( - Axes, SubplotHost, ParasiteAxes, ParasiteAxesAuxTrans) - -import pytest +from mpl_toolkits.axisartist.axislines import AxesZero, SubplotZero, Subplot +from mpl_toolkits.axisartist import Axes, SubplotHost @image_comparison(['SubplotZero.png'], style='default') @@ -61,10 +57,9 @@ def test_Axes(): fig.canvas.draw() -@pytest.mark.parametrize('parasite_cls', [ParasiteAxes, ParasiteAxesAuxTrans]) @image_comparison(['ParasiteAxesAuxTrans_meshplot.png'], remove_text=True, style='default', tol=0.075) -def test_ParasiteAxesAuxTrans(parasite_cls): +def test_ParasiteAxesAuxTrans(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False @@ -86,9 +81,7 @@ def test_ParasiteAxesAuxTrans(parasite_cls): ax1 = SubplotHost(fig, 1, 3, i+1) fig.add_subplot(ax1) - with _api.suppress_matplotlib_deprecation_warning(): - ax2 = parasite_cls(ax1, IdentityTransform()) - ax1.parasites.append(ax2) + ax2 = ax1.get_aux_axes(IdentityTransform(), viewlim_mode=None) if name.startswith('pcolor'): getattr(ax2, name)(xx, yy, data[:-1, :-1]) else: @@ -97,3 +90,61 @@ def test_ParasiteAxesAuxTrans(parasite_cls): ax1.set_ylim((0, 5)) ax2.contour(xx, yy, data, colors='k') + + +@image_comparison(['axisline_style.png'], remove_text=True, style='mpl20') +def test_axisline_style(): + fig = plt.figure(figsize=(2, 2)) + ax = fig.add_subplot(axes_class=AxesZero) + ax.axis["xzero"].set_axisline_style("-|>") + ax.axis["xzero"].set_visible(True) + ax.axis["yzero"].set_axisline_style("->") + ax.axis["yzero"].set_visible(True) + + for direction in ("left", "right", "bottom", "top"): + ax.axis[direction].set_visible(False) + + +@image_comparison(['axisline_style_size_color.png'], remove_text=True, + style='mpl20') +def test_axisline_style_size_color(): + fig = plt.figure(figsize=(2, 2)) + ax = fig.add_subplot(axes_class=AxesZero) + ax.axis["xzero"].set_axisline_style("-|>", size=2.0, facecolor='r') + ax.axis["xzero"].set_visible(True) + ax.axis["yzero"].set_axisline_style("->, size=1.5") + ax.axis["yzero"].set_visible(True) + + for direction in ("left", "right", "bottom", "top"): + ax.axis[direction].set_visible(False) + + +@image_comparison(['axisline_style_tight.png'], remove_text=True, + style='mpl20') +def test_axisline_style_tight(): + fig = plt.figure(figsize=(2, 2)) + ax = fig.add_subplot(axes_class=AxesZero) + ax.axis["xzero"].set_axisline_style("-|>", size=5, facecolor='g') + ax.axis["xzero"].set_visible(True) + ax.axis["yzero"].set_axisline_style("->, size=8") + ax.axis["yzero"].set_visible(True) + + for direction in ("left", "right", "bottom", "top"): + ax.axis[direction].set_visible(False) + + fig.tight_layout() + + +@image_comparison(['subplotzero_ylabel.png'], style='mpl20') +def test_subplotzero_ylabel(): + fig = plt.figure() + ax = fig.add_subplot(111, axes_class=SubplotZero) + + ax.set(xlim=(-3, 7), ylim=(-3, 7), xlabel="x", ylabel="y") + + zero_axis = ax.axis["xzero", "yzero"] + zero_axis.set_visible(True) # they are hidden by default + + ax.axis["left", "right", "bottom", "top"].set_visible(False) + + zero_axis.set_axisline_style("->") diff --git a/lib/mpl_toolkits/tests/test_axisartist_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py similarity index 63% rename from lib/mpl_toolkits/tests/test_axisartist_floating_axes.py rename to lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index c8b770152d9a..d489f492d4d3 100644 --- a/lib/mpl_toolkits/tests/test_axisartist_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -6,8 +6,7 @@ from matplotlib.testing.decorators import image_comparison from mpl_toolkits.axisartist.axislines import Subplot from mpl_toolkits.axisartist.floating_axes import ( - FloatingSubplot, - GridHelperCurveLinear) + FloatingAxes, GridHelperCurveLinear) from mpl_toolkits.axisartist.grid_finder import FixedLocator from mpl_toolkits.axisartist import angle_helper @@ -26,37 +25,27 @@ def test_curvelinear3(): tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + mprojections.PolarAxes.PolarTransform()) - - grid_locator1 = angle_helper.LocatorDMS(15) - tick_formatter1 = angle_helper.FormatterDMS() - - grid_locator2 = FixedLocator([2, 4, 6, 8, 10]) - - grid_helper = GridHelperCurveLinear(tr, - extremes=(0, 360, 10, 3), - grid_locator1=grid_locator1, - grid_locator2=grid_locator2, - tick_formatter1=tick_formatter1, - tick_formatter2=None) - - ax1 = FloatingSubplot(fig, 111, grid_helper=grid_helper) - fig.add_subplot(ax1) + grid_helper = GridHelperCurveLinear( + tr, + extremes=(0, 360, 10, 3), + grid_locator1=angle_helper.LocatorDMS(15), + grid_locator2=FixedLocator([2, 4, 6, 8, 10]), + tick_formatter1=angle_helper.FormatterDMS(), + tick_formatter2=None) + ax1 = fig.add_subplot(axes_class=FloatingAxes, grid_helper=grid_helper) r_scale = 10 tr2 = mtransforms.Affine2D().scale(1, 1 / r_scale) + tr - grid_locator2 = FixedLocator([30, 60, 90]) - grid_helper2 = GridHelperCurveLinear(tr2, - extremes=(0, 360, - 10 * r_scale, 3 * r_scale), - grid_locator2=grid_locator2) + grid_helper2 = GridHelperCurveLinear( + tr2, + extremes=(0, 360, 10 * r_scale, 3 * r_scale), + grid_locator2=FixedLocator([30, 60, 90])) ax1.axis["right"] = axis = grid_helper2.new_fixed_axis("right", axes=ax1) ax1.axis["left"].label.set_text("Test 1") ax1.axis["right"].label.set_text("Test 2") - - for an in ["left", "right"]: - ax1.axis[an].set_visible(False) + ax1.axis["left", "right"].set_visible(False) axis = grid_helper.new_floating_axis(1, 7, axes=ax1, axis_direction="bottom") @@ -85,27 +74,18 @@ def test_curvelinear4(): tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + mprojections.PolarAxes.PolarTransform()) - - grid_locator1 = angle_helper.LocatorDMS(5) - tick_formatter1 = angle_helper.FormatterDMS() - - grid_locator2 = FixedLocator([2, 4, 6, 8, 10]) - - grid_helper = GridHelperCurveLinear(tr, - extremes=(120, 30, 10, 0), - grid_locator1=grid_locator1, - grid_locator2=grid_locator2, - tick_formatter1=tick_formatter1, - tick_formatter2=None) - - ax1 = FloatingSubplot(fig, 111, grid_helper=grid_helper) - fig.add_subplot(ax1) + grid_helper = GridHelperCurveLinear( + tr, + extremes=(120, 30, 10, 0), + grid_locator1=angle_helper.LocatorDMS(5), + grid_locator2=FixedLocator([2, 4, 6, 8, 10]), + tick_formatter1=angle_helper.FormatterDMS(), + tick_formatter2=None) + ax1 = fig.add_subplot(axes_class=FloatingAxes, grid_helper=grid_helper) ax1.axis["left"].label.set_text("Test 1") ax1.axis["right"].label.set_text("Test 2") - - for an in ["top"]: - ax1.axis[an].set_visible(False) + ax1.axis["top"].set_visible(False) axis = grid_helper.new_floating_axis(1, 70, axes=ax1, axis_direction="bottom") diff --git a/lib/mpl_toolkits/tests/test_axisartist_grid_finder.py b/lib/mpl_toolkits/axisartist/tests/test_grid_finder.py similarity index 100% rename from lib/mpl_toolkits/tests/test_axisartist_grid_finder.py rename to lib/mpl_toolkits/axisartist/tests/test_grid_finder.py diff --git a/lib/mpl_toolkits/tests/test_axisartist_grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py similarity index 92% rename from lib/mpl_toolkits/tests/test_axisartist_grid_helper_curvelinear.py rename to lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py index 9a501daa2e7b..037836afec6a 100644 --- a/lib/mpl_toolkits/tests/test_axisartist_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -6,9 +6,8 @@ from matplotlib.transforms import Affine2D, Transform from matplotlib.testing.decorators import image_comparison -from mpl_toolkits.axes_grid1.parasite_axes import ParasiteAxes from mpl_toolkits.axisartist import SubplotHost -from mpl_toolkits.axes_grid1.parasite_axes import host_subplot_class_factory +from mpl_toolkits.axes_grid1.parasite_axes import host_axes_class_factory from mpl_toolkits.axisartist import angle_helper from mpl_toolkits.axisartist.axislines import Axes from mpl_toolkits.axisartist.grid_helper_curvelinear import \ @@ -59,15 +58,14 @@ def inverted(self): fig = plt.figure() - SubplotHost = host_subplot_class_factory(Axes) + SubplotHost = host_axes_class_factory(Axes) tr = MyTransform(1) grid_helper = GridHelperCurveLinear(tr) ax1 = SubplotHost(fig, 1, 1, 1, grid_helper=grid_helper) fig.add_subplot(ax1) - ax2 = ParasiteAxes(ax1, tr, viewlim_mode="equal") - ax1.parasites.append(ax2) + ax2 = ax1.get_aux_axes(tr, viewlim_mode="equal") ax2.plot([3, 6], [5.0, 10.]) ax1.set_aspect(1.) @@ -77,9 +75,9 @@ def inverted(self): ax1.grid(True) -@image_comparison(['polar_box.png'], style='default', tol=0.04) +# Remove tol & kerning_factor when this test image is regenerated. +@image_comparison(['polar_box.png'], style='default', tol=0.27) def test_polar_box(): - # Remove this line when this test image is regenerated. plt.rcParams['text.kerning_factor'] = 6 fig = plt.figure(figsize=(5, 5)) @@ -127,10 +125,9 @@ def test_polar_box(): axis.get_helper().set_extremes(-180, 90) # A parasite axes with given transform - ax2 = ParasiteAxes(ax1, tr, viewlim_mode="equal") + ax2 = ax1.get_aux_axes(tr, viewlim_mode="equal") assert ax2.transData == tr + ax1.transData # Anything you draw in ax2 will match the ticks and grids of ax1. - ax1.parasites.append(ax2) ax2.plot(np.linspace(0, 30, 50), np.linspace(10, 10, 50)) ax1.set_aspect(1.) @@ -140,9 +137,9 @@ def test_polar_box(): ax1.grid(True) -@image_comparison(['axis_direction.png'], style='default', tol=0.071) +# Remove tol & kerning_factor when this test image is regenerated. +@image_comparison(['axis_direction.png'], style='default', tol=0.12) def test_axis_direction(): - # Remove this line when this test image is regenerated. plt.rcParams['text.kerning_factor'] = 6 fig = plt.figure(figsize=(5, 5)) diff --git a/lib/mpl_toolkits/mplot3d/__init__.py b/lib/mpl_toolkits/mplot3d/__init__.py index 30e682aea018..a089fbd6b70e 100644 --- a/lib/mpl_toolkits/mplot3d/__init__.py +++ b/lib/mpl_toolkits/mplot3d/__init__.py @@ -1 +1,3 @@ from .axes3d import Axes3D + +__all__ = ['Axes3D'] diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 2ba5fe7dc738..14511f4a8c2d 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -11,8 +11,10 @@ import numpy as np +from contextlib import contextmanager + from matplotlib import ( - _api, artist, cbook, colors as mcolors, lines, text as mtext, + artist, cbook, colors as mcolors, lines, text as mtext, path as mpath) from matplotlib.collections import ( LineCollection, PolyCollection, PatchCollection, PathCollection) @@ -77,7 +79,7 @@ class Text3D(mtext.Text): Parameters ---------- - x, y, z + x, y, z : float The position of the text. text : str The text string to display. @@ -108,8 +110,8 @@ def set_position_3d(self, xyz, zdir=None): xyz : (float, float, float) The position in 3D space. zdir : {'x', 'y', 'z', None, 3-tuple} - The direction of the text. If unspecified, the zdir will not be - changed. + The direction of the text. If unspecified, the *zdir* will not be + changed. See `.get_dir_vector` for a description of the values. """ super().set_position(xyz[:2]) self.set_z(xyz[2]) @@ -128,6 +130,17 @@ def set_z(self, z): self.stale = True def set_3d_properties(self, z=0, zdir='z'): + """ + Set the *z* position and direction of the text. + + Parameters + ---------- + z : float + The z-position in 3D space. + zdir : {'x', 'y', 'z', 3-tuple} + The direction of the text. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ self._z = z self._dir_vec = get_dir_vector(zdir) self.stale = True @@ -145,14 +158,24 @@ def draw(self, renderer): mtext.Text.draw(self, renderer) self.stale = False - def get_tightbbox(self, renderer): + def get_tightbbox(self, renderer=None): # Overwriting the 2d Text behavior which is not valid for 3d. # For now, just return None to exclude from layout calculation. return None def text_2d_to_3d(obj, z=0, zdir='z'): - """Convert a Text to a Text3D object.""" + """ + Convert a `.Text` to a `.Text3D` object. + + Parameters + ---------- + z : float + The z-position in 3D space. + zdir : {'x', 'y', 'z', 3-tuple} + The direction of the text. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ obj.__class__ = Text3D obj.set_3d_properties(z, zdir) @@ -164,14 +187,37 @@ class Line3D(lines.Line2D): def __init__(self, xs, ys, zs, *args, **kwargs): """ - Keyword arguments are passed onto :func:`~matplotlib.lines.Line2D`. + + Parameters + ---------- + xs : array-like + The x-data to be plotted. + ys : array-like + The y-data to be plotted. + zs : array-like + The z-data to be plotted. + + Additional arguments are passed onto :func:`~matplotlib.lines.Line2D`. """ super().__init__([], [], *args, **kwargs) - self._verts3d = xs, ys, zs + self.set_data_3d(xs, ys, zs) def set_3d_properties(self, zs=0, zdir='z'): + """ + Set the *z* position and direction of the line. + + Parameters + ---------- + zs : float or array of floats + The location along the *zdir* axis in 3D space to position the + line. + zdir : {'x', 'y', 'z'} + Plane to plot line orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ xs = self.get_xdata() ys = self.get_ydata() + zs = cbook._to_unmasked_float_array(zs).ravel() zs = np.broadcast_to(zs, len(xs)) self._verts3d = juggle_axes(xs, ys, zs, zdir) self.stale = True @@ -194,9 +240,11 @@ def set_data_3d(self, *args): Accepts x, y, z arguments or a single array-like (x, y, z) """ if len(args) == 1: - self._verts3d = args[0] - else: - self._verts3d = args + args = args[0] + for name, xyz in zip('xyz', args): + if not np.iterable(xyz): + raise RuntimeError(f'{name} must be a sequence') + self._verts3d = args self.stale = True def get_data_3d(self): @@ -220,7 +268,17 @@ def draw(self, renderer): def line_2d_to_3d(line, zs=0, zdir='z'): - """Convert a 2D line to 3D.""" + """ + Convert a `.Line2D` to a `.Line3D` object. + + Parameters + ---------- + zs : float + The location along the *zdir* axis in 3D space to position the line. + zdir : {'x', 'y', 'z'} + Plane to plot line orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ line.__class__ = Line3D line.set_3d_properties(zs, zdir) @@ -297,8 +355,7 @@ def set_segments(self, segments): self._segments3d = segments super().set_segments([]) - @_api.delete_parameter('3.4', 'renderer') - def do_3d_projection(self, renderer=None): + def do_3d_projection(self): """ Project the points according to renderer matrix. """ @@ -313,17 +370,9 @@ def do_3d_projection(self, renderer=None): minz = min(minz, min(zs)) return minz - @artist.allow_rasterization - @_api.delete_parameter('3.4', 'project', - alternative='Line3DCollection.do_3d_projection') - def draw(self, renderer, project=False): - if project: - self.do_3d_projection() - super().draw(renderer) - def line_collection_2d_to_3d(col, zs=0, zdir='z'): - """Convert a LineCollection to a Line3DCollection object.""" + """Convert a `.LineCollection` to a `.Line3DCollection` object.""" segments3d = _paths_to_3d_segments(col.get_paths(), zs, zdir) col.__class__ = Line3DCollection col.set_segments(segments3d) @@ -335,10 +384,34 @@ class Patch3D(Patch): """ def __init__(self, *args, zs=(), zdir='z', **kwargs): + """ + Parameters + ---------- + verts : + zs : float + The location along the *zdir* axis in 3D space to position the + patch. + zdir : {'x', 'y', 'z'} + Plane to plot patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ super().__init__(*args, **kwargs) self.set_3d_properties(zs, zdir) def set_3d_properties(self, verts, zs=0, zdir='z'): + """ + Set the *z* position and direction of the patch. + + Parameters + ---------- + verts : + zs : float + The location along the *zdir* axis in 3D space to position the + patch. + zdir : {'x', 'y', 'z'} + Plane to plot patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ zs = np.broadcast_to(zs, len(verts)) self._segment3d = [juggle_axes(x, y, z, zdir) for ((x, y), z) in zip(verts, zs)] @@ -346,8 +419,7 @@ def set_3d_properties(self, verts, zs=0, zdir='z'): def get_path(self): return self._path2d - @_api.delete_parameter('3.4', 'renderer') - def do_3d_projection(self, renderer=None): + def do_3d_projection(self): s = self._segment3d xs, ys, zs = zip(*s) vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, @@ -362,16 +434,39 @@ class PathPatch3D(Patch3D): """ def __init__(self, path, *, zs=(), zdir='z', **kwargs): + """ + Parameters + ---------- + path : + zs : float + The location along the *zdir* axis in 3D space to position the + path patch. + zdir : {'x', 'y', 'z', 3-tuple} + Plane to plot path patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ # Not super().__init__! Patch.__init__(self, **kwargs) self.set_3d_properties(path, zs, zdir) def set_3d_properties(self, path, zs=0, zdir='z'): + """ + Set the *z* position and direction of the path patch. + + Parameters + ---------- + path : + zs : float + The location along the *zdir* axis in 3D space to position the + path patch. + zdir : {'x', 'y', 'z', 3-tuple} + Plane to plot path patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir) self._code3d = path.codes - @_api.delete_parameter('3.4', 'renderer') - def do_3d_projection(self, renderer=None): + def do_3d_projection(self): s = self._segment3d xs, ys, zs = zip(*s) vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, @@ -389,14 +484,14 @@ def _get_patch_verts(patch): def patch_2d_to_3d(patch, z=0, zdir='z'): - """Convert a Patch to a Patch3D object.""" + """Convert a `.Patch` to a `.Patch3D` object.""" verts = _get_patch_verts(patch) patch.__class__ = Patch3D patch.set_3d_properties(verts, z, zdir) def pathpatch_2d_to_3d(pathpatch, z=0, zdir='z'): - """Convert a PathPatch to a PathPatch3D object.""" + """Convert a `.PathPatch` to a `.PathPatch3D` object.""" path = pathpatch.get_path() trans = pathpatch.get_patch_transform() @@ -421,10 +516,9 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): :class:`~matplotlib.collections.PatchCollection`. In addition, keywords *zs=0* and *zdir='z'* are available. - Also, the keyword argument *depthshade* is available to - indicate whether or not to shade the patches in order to - give the appearance of depth (default is *True*). - This is typically desired in scatter plots. + Also, the keyword argument *depthshade* is available to indicate + whether to shade the patches in order to give the appearance of depth + (default is *True*). This is typically desired in scatter plots. """ self._depthshade = depthshade super().__init__(*args, **kwargs) @@ -452,6 +546,19 @@ def set_sort_zpos(self, val): self.stale = True def set_3d_properties(self, zs, zdir): + """ + Set the *z* positions and direction of the patches. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the patches in the collection + along the *zdir* axis. + zdir : {'x', 'y', 'z'} + Plane to plot patches orthogonal to. + All patches must have the same direction. + See `.get_dir_vector` for a description of the values. + """ # Force the collection to initialize the face and edgecolors # just in case it is a scalarmappable with a colormap. self.update_scalarmappable() @@ -466,8 +573,7 @@ def set_3d_properties(self, zs, zdir): self._vzs = None self.stale = True - @_api.delete_parameter('3.4', 'renderer') - def do_3d_projection(self, renderer=None): + def do_3d_projection(self): xs, ys, zs = self._offsets3d vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, self.axes.M) @@ -517,19 +623,20 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): :class:`~matplotlib.collections.PathCollection`. In addition, keywords *zs=0* and *zdir='z'* are available. - Also, the keyword argument *depthshade* is available to - indicate whether or not to shade the patches in order to - give the appearance of depth (default is *True*). - This is typically desired in scatter plots. + Also, the keyword argument *depthshade* is available to indicate + whether to shade the patches in order to give the appearance of depth + (default is *True*). This is typically desired in scatter plots. """ self._depthshade = depthshade self._in_draw = False super().__init__(*args, **kwargs) self.set_3d_properties(zs, zdir) + self._offset_zordered = None def draw(self, renderer): - with cbook._setattr_cm(self, _in_draw=True): - super().draw(renderer) + with self._use_zordered_offset(): + with cbook._setattr_cm(self, _in_draw=True): + super().draw(renderer) def set_sort_zpos(self, val): """Set the position to use for z-sorting.""" @@ -537,6 +644,19 @@ def set_sort_zpos(self, val): self.stale = True def set_3d_properties(self, zs, zdir): + """ + Set the *z* positions and direction of the paths. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the paths in the collection + along the *zdir* axis. + zdir : {'x', 'y', 'z'} + Plane to plot paths orthogonal to. + All paths must have the same direction. + See `.get_dir_vector` for a description of the values. + """ # Force the collection to initialize the face and edgecolors # just in case it is a scalarmappable with a colormap. self.update_scalarmappable() @@ -558,7 +678,7 @@ def set_3d_properties(self, zs, zdir): # # Grab the current sizes and linewidths to preserve them. self._sizes3d = self._sizes - self._linewidths3d = self._linewidths + self._linewidths3d = np.array(self._linewidths) xs, ys, zs = self._offsets3d # Sort the points based on z coordinates @@ -576,7 +696,7 @@ def set_sizes(self, sizes, dpi=72.0): def set_linewidth(self, lw): super().set_linewidth(lw) if not self._in_draw: - self._linewidth3d = lw + self._linewidths3d = np.array(self._linewidths) def get_depthshade(self): return self._depthshade @@ -594,8 +714,7 @@ def set_depthshade(self, depthshade): self._depthshade = depthshade self.stale = True - @_api.delete_parameter('3.4', 'renderer') - def do_3d_projection(self, renderer=None): + def do_3d_projection(self): xs, ys, zs = self._offsets3d vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, self.axes.M) @@ -616,15 +735,32 @@ def do_3d_projection(self, renderer=None): if len(self._linewidths3d) > 1: self._linewidths = self._linewidths3d[z_markers_idx] + PathCollection.set_offsets(self, np.column_stack((vxs, vys))) + # Re-order items vzs = vzs[z_markers_idx] vxs = vxs[z_markers_idx] vys = vys[z_markers_idx] - PathCollection.set_offsets(self, np.column_stack((vxs, vys))) + # Store ordered offset for drawing purpose + self._offset_zordered = np.column_stack((vxs, vys)) return np.min(vzs) if vzs.size else np.nan + @contextmanager + def _use_zordered_offset(self): + if self._offset_zordered is None: + # Do nothing + yield + else: + # Swap offset with z-ordered offset + old_offset = self._offsets + super().set_offsets(self._offset_zordered) + try: + yield + finally: + self._offsets = old_offset + def _maybe_depth_shade_and_sort_colors(self, color_array): color_array = ( _zalpha(color_array, self._vzs) @@ -649,18 +785,17 @@ def get_edgecolor(self): def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): """ - Convert a :class:`~matplotlib.collections.PatchCollection` into a - :class:`Patch3DCollection` object - (or a :class:`~matplotlib.collections.PathCollection` into a - :class:`Path3DCollection` object). + Convert a `.PatchCollection` into a `.Patch3DCollection` object + (or a `.PathCollection` into a `.Path3DCollection` object). Parameters ---------- - za + zs : float or array of floats The location or locations to place the patches in the collection along the *zdir* axis. Default: 0. - zdir + zdir : {'x', 'y', 'z'} The axis in which to place the patches. Default: "z". + See `.get_dir_vector` for a description of the values. depthshade Whether to shade the patches to give a sense of depth. Default: *True*. @@ -695,16 +830,29 @@ class Poly3DCollection(PolyCollection): triangulation and thus generates consistent surfaces. """ - def __init__(self, verts, *args, zsort='average', **kwargs): + def __init__(self, verts, *args, zsort='average', shade=False, + lightsource=None, **kwargs): """ Parameters ---------- verts : list of (N, 3) array-like - Each element describes a polygon as a sequence of ``N_i`` points - ``(x, y, z)``. + The sequence of polygons [*verts0*, *verts1*, ...] where each + element *verts_i* defines the vertices of polygon *i* as a 2D + array-like of shape (N, 3). zsort : {'average', 'min', 'max'}, default: 'average' The calculation method for the z-order. See `~.Poly3DCollection.set_zsort` for details. + shade : bool, default: False + Whether to shade *facecolors* and *edgecolors*. When activating + *shade*, *facecolors* and/or *edgecolors* must be provided. + + .. versionadded:: 3.7 + + lightsource : `~matplotlib.colors.LightSource` + The lightsource to use when *shade* is True. + + .. versionadded:: 3.7 + *args, **kwargs All other parameters are forwarded to `.PolyCollection`. @@ -713,6 +861,23 @@ def __init__(self, verts, *args, zsort='average', **kwargs): Note that this class does a bit of magic with the _facecolors and _edgecolors properties. """ + if shade: + normals = _generate_normals(verts) + facecolors = kwargs.get('facecolors', None) + if facecolors is not None: + kwargs['facecolors'] = _shade_colors( + facecolors, normals, lightsource + ) + + edgecolors = kwargs.get('edgecolors', None) + if edgecolors is not None: + kwargs['edgecolors'] = _shade_colors( + edgecolors, normals, lightsource + ) + if facecolors is None and edgecolors in None: + raise ValueError( + "You must provide facecolors, edgecolors, or both for " + "shade to work.") super().__init__(verts, *args, **kwargs) if isinstance(verts, np.ndarray): if verts.ndim != 3: @@ -756,7 +921,19 @@ def get_vector(self, segments3d): self._segslices = [*map(slice, indices[:-1], indices[1:])] def set_verts(self, verts, closed=True): - """Set 3D vertices.""" + """ + Set 3D vertices. + + Parameters + ---------- + verts : list of (N, 3) array-like + The sequence of polygons [*verts0*, *verts1*, ...] where each + element *verts_i* defines the vertices of polygon *i* as a 2D + array-like of shape (N, 3). + closed : bool, default: True + Whether the polygon should be closed by adding a CLOSEPOLY + connection at the end. + """ self.get_vector(verts) # 2D verts will be updated at draw time super().set_verts([], False) @@ -786,8 +963,7 @@ def set_sort_zpos(self, val): self._sort_zpos = val self.stale = True - @_api.delete_parameter('3.4', 'renderer') - def do_3d_projection(self, renderer=None): + def do_3d_projection(self): """ Perform the 3D projection for this object. """ @@ -882,14 +1058,35 @@ def set_alpha(self, alpha): self.stale = True def get_facecolor(self): + # docstring inherited + # self._facecolors2d is not initialized until do_3d_projection + if not hasattr(self, '_facecolors2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() return self._facecolors2d def get_edgecolor(self): + # docstring inherited + # self._edgecolors2d is not initialized until do_3d_projection + if not hasattr(self, '_edgecolors2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() return self._edgecolors2d def poly_collection_2d_to_3d(col, zs=0, zdir='z'): - """Convert a PolyCollection to a Poly3DCollection object.""" + """ + Convert a `.PolyCollection` into a `.Poly3DCollection` object. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the polygons in the collection along + the *zdir* axis. Default: 0. + zdir : {'x', 'y', 'z'} + The axis in which to place the patches. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ segments_3d, codes = _paths_to_3d_segments_with_codes( col.get_paths(), zs, zdir) col.__class__ = Poly3DCollection @@ -899,9 +1096,10 @@ def poly_collection_2d_to_3d(col, zs=0, zdir='z'): def juggle_axes(xs, ys, zs, zdir): """ - Reorder coordinates so that 2D xs, ys can be plotted in the plane - orthogonal to zdir. zdir is normally x, y or z. However, if zdir - starts with a '-' it is interpreted as a compensation for rotate_axes. + Reorder coordinates so that 2D *xs*, *ys* can be plotted in the plane + orthogonal to *zdir*. *zdir* is normally 'x', 'y' or 'z'. However, if + *zdir* starts with a '-' it is interpreted as a compensation for + `rotate_axes`. """ if zdir == 'x': return zs, xs, ys @@ -915,20 +1113,14 @@ def juggle_axes(xs, ys, zs, zdir): def rotate_axes(xs, ys, zs, zdir): """ - Reorder coordinates so that the axes are rotated with zdir along + Reorder coordinates so that the axes are rotated with *zdir* along the original z axis. Prepending the axis with a '-' does the - inverse transform, so zdir can be x, -x, y, -y, z or -z + inverse transform, so *zdir* can be 'x', '-x', 'y', '-y', 'z' or '-z'. """ - if zdir == 'x': + if zdir in ('x', '-y'): return ys, zs, xs - elif zdir == '-x': - return zs, xs, ys - - elif zdir == 'y': + elif zdir in ('-x', 'y'): return zs, xs, ys - elif zdir == '-y': - return ys, zs, xs - else: return xs, ys, zs @@ -945,3 +1137,84 @@ def _zalpha(colors, zs): sats = 1 - norm(zs) * 0.7 rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) return np.column_stack([rgba[:, :3], rgba[:, 3] * sats]) + + +def _generate_normals(polygons): + """ + Compute the normals of a list of polygons, one normal per polygon. + + Normals point towards the viewer for a face with its vertices in + counterclockwise order, following the right hand rule. + + Uses three points equally spaced around the polygon. This method assumes + that the points are in a plane. Otherwise, more than one shade is required, + which is not supported. + + Parameters + ---------- + polygons : list of (M_i, 3) array-like, or (..., M, 3) array-like + A sequence of polygons to compute normals for, which can have + varying numbers of vertices. If the polygons all have the same + number of vertices and array is passed, then the operation will + be vectorized. + + Returns + ------- + normals : (..., 3) array + A normal vector estimated for the polygon. + """ + if isinstance(polygons, np.ndarray): + # optimization: polygons all have the same number of points, so can + # vectorize + n = polygons.shape[-2] + i1, i2, i3 = 0, n//3, 2*n//3 + v1 = polygons[..., i1, :] - polygons[..., i2, :] + v2 = polygons[..., i2, :] - polygons[..., i3, :] + else: + # The subtraction doesn't vectorize because polygons is jagged. + v1 = np.empty((len(polygons), 3)) + v2 = np.empty((len(polygons), 3)) + for poly_i, ps in enumerate(polygons): + n = len(ps) + i1, i2, i3 = 0, n//3, 2*n//3 + v1[poly_i, :] = ps[i1, :] - ps[i2, :] + v2[poly_i, :] = ps[i2, :] - ps[i3, :] + return np.cross(v1, v2) + + +def _shade_colors(color, normals, lightsource=None): + """ + Shade *color* using normal vectors given by *normals*, + assuming a *lightsource* (using default position if not given). + *color* can also be an array of the same length as *normals*. + """ + if lightsource is None: + # chosen for backwards-compatibility + lightsource = mcolors.LightSource(azdeg=225, altdeg=19.4712) + + with np.errstate(invalid="ignore"): + shade = ((normals / np.linalg.norm(normals, axis=1, keepdims=True)) + @ lightsource.direction) + mask = ~np.isnan(shade) + + if mask.any(): + # convert dot product to allowed shading fractions + in_norm = mcolors.Normalize(-1, 1) + out_norm = mcolors.Normalize(0.3, 1).inverse + + def norm(x): + return out_norm(in_norm(x)) + + shade[~mask] = 0 + + color = mcolors.to_rgba_array(color) + # shape of color should be (M, 4) (where M is number of faces) + # shape of shade should be (M,) + # colors should have final shape of (M, 4) + alpha = color[:, 3] + colors = norm(shade)[:, np.newaxis] * color + colors[:, 3] = alpha + else: + colors = np.asanyarray(color).copy() + + return colors diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 1cde3f3074f1..c180e6f2acc2 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -12,15 +12,14 @@ from collections import defaultdict import functools -import inspect import itertools import math -from numbers import Integral import textwrap import numpy as np -from matplotlib import _api, cbook, docstring, _preprocess_data +import matplotlib as mpl +from matplotlib import _api, cbook, _docstring, _preprocess_data import matplotlib.artist as martist import matplotlib.axes as maxes import matplotlib.collections as mcoll @@ -28,51 +27,75 @@ import matplotlib.image as mimage import matplotlib.lines as mlines import matplotlib.patches as mpatches -import matplotlib.scale as mscale import matplotlib.container as mcontainer import matplotlib.transforms as mtransforms -from matplotlib.axes import Axes, rcParams +from matplotlib.axes import Axes from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format from matplotlib.transforms import Bbox -from matplotlib.tri.triangulation import Triangulation +from matplotlib.tri._triangulation import Triangulation from . import art3d from . import proj3d from . import axis3d -@docstring.interpd -@cbook._define_aliases({ - "xlim3d": ["xlim"], "ylim3d": ["ylim"], "zlim3d": ["zlim"]}) +@_docstring.interpd +@_api.define_aliases({ + "xlim": ["xlim3d"], "ylim": ["ylim3d"], "zlim": ["zlim3d"]}) class Axes3D(Axes): """ - 3D axes object. + 3D Axes object. + + .. note:: + + As a user, you do not instantiate Axes directly, but use Axes creation + methods instead; e.g. from `.pyplot` or `.Figure`: + `~.pyplot.subplots`, `~.pyplot.subplot_mosaic` or `.Figure.add_axes`. """ name = '3d' _axis_names = ("x", "y", "z") Axes._shared_axes["z"] = cbook.Grouper() + dist = _api.deprecate_privatize_attribute("3.6") + vvec = _api.deprecate_privatize_attribute("3.7") + eye = _api.deprecate_privatize_attribute("3.7") + sx = _api.deprecate_privatize_attribute("3.7") + sy = _api.deprecate_privatize_attribute("3.7") + def __init__( self, fig, rect=None, *args, - azim=-60, elev=30, sharez=None, proj_type='persp', - box_aspect=None, computed_zorder=True, + elev=30, azim=-60, roll=0, sharez=None, proj_type='persp', + box_aspect=None, computed_zorder=True, focal_length=None, **kwargs): """ Parameters ---------- fig : Figure The parent figure. - rect : (float, float, float, float) + rect : tuple (left, bottom, width, height), default: None. The ``(left, bottom, width, height)`` axes position. - azim : float, default: -60 - Azimuthal viewing angle. elev : float, default: 30 - Elevation viewing angle. + The elevation angle in degrees rotates the camera above and below + the x-y plane, with a positive angle corresponding to a location + above the plane. + azim : float, default: -60 + The azimuthal angle in degrees rotates the camera about the z axis, + with a positive angle corresponding to a right-handed rotation. In + other words, a positive azimuth rotates the camera about the origin + from its location along the +x axis towards the +y axis. + roll : float, default: 0 + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. sharez : Axes3D, optional - Other axes to share z-limits with. + Other Axes to share z-limits with. proj_type : {'persp', 'ortho'} The projection type, default 'persp'. + box_aspect : 3-tuple of floats, default: None + Changes the physical dimensions of the Axes3D, such that the ratio + of the axis lengths in display units is x:y:z. + If None, defaults to 4:4:3 computed_zorder : bool, default: True If True, the draw order is computed based on the average position of the `.Artist`\\s along the view direction. @@ -82,14 +105,13 @@ def __init__( does not produce the desired result. Note however, that a manual zorder will only be correct for a limited view angle. If the figure is rotated by the user, it will look wrong from certain angles. - auto_add_to_figure : bool, default: True - Prior to Matplotlib 3.4 Axes3D would add themselves - to their host Figure on init. Other Axes class do not - do this. - - This behavior is deprecated in 3.4, the default will - change to False in 3.5. The keyword will be undocumented - and a non-False value will be an error in 3.6. + focal_length : float, default: None + For a projection type of 'persp', the focal length of the virtual + camera. Must be > 0. If None, defaults to 1. + For a projection type of 'ortho', must be set to either None + or infinity (numpy.inf). If None, defaults to infinity. + The focal length can be computed from a desired Field Of View via + the equation: focal_length = 1/tan(FOV/2) **kwargs Other optional keyword arguments: @@ -102,7 +124,8 @@ def __init__( self.initial_azim = azim self.initial_elev = elev - self.set_proj_type(proj_type) + self.initial_roll = roll + self.set_proj_type(proj_type, focal_length) self.computed_zorder = computed_zorder self.xy_viewLim = Bbox.unit() @@ -113,14 +136,18 @@ def __init__( # inhibit autoscale_view until the axes are defined # they can't be defined until Axes.__init__ has been called - self.view_init(self.initial_elev, self.initial_azim) + self.view_init(self.initial_elev, self.initial_azim, self.initial_roll) self._sharez = sharez if sharez is not None: self._shared_axes["z"].join(self, sharez) self._adjustable = 'datalim' - auto_add_to_figure = kwargs.pop('auto_add_to_figure', True) + if kwargs.pop('auto_add_to_figure', False): + raise AttributeError( + 'auto_add_to_figure is no longer supported for Axes3D. ' + 'Use fig.add_axes(ax) instead.' + ) super().__init__( fig, rect, frameon=True, box_aspect=box_aspect, *args, **kwargs @@ -135,14 +162,12 @@ def __init__( self.fmt_zdata = None self.mouse_init() - self.figure.canvas.callbacks._pickled_cids.update({ - self.figure.canvas.mpl_connect( - 'motion_notify_event', self._on_move), - self.figure.canvas.mpl_connect( - 'button_press_event', self._button_press), - self.figure.canvas.mpl_connect( - 'button_release_event', self._button_release), - }) + self.figure.canvas.callbacks._connect_picklable( + 'motion_notify_event', self._on_move) + self.figure.canvas.callbacks._connect_picklable( + 'button_press_event', self._button_press) + self.figure.canvas.callbacks._connect_picklable( + 'button_release_event', self._button_release) self.set_top_view() self.patch.set_linewidth(0) @@ -154,18 +179,6 @@ def __init__( # for bounding box calculations self.spines[:].set_visible(False) - if auto_add_to_figure: - _api.warn_deprecated( - "3.4", removal="3.6", message="Axes3D(fig) adding itself " - "to the figure is deprecated since %(since)s. " - "Pass the keyword argument auto_add_to_figure=False " - "and use fig.add_axes(ax) to suppress this warning. " - "The default value of auto_add_to_figure will change to " - "False in mpl3.5 and True values will " - "no longer work %(removal)s. This is consistent with " - "other Axes classes.") - fig.add_axes(self) - def set_axis_off(self): self._axis3don = False self.stale = True @@ -176,7 +189,7 @@ def set_axis_on(self): def convert_zunits(self, z): """ - For artists in an axes, if the zaxis has units support, + For artists in an Axes, if the zaxis has units support, convert *z* using zaxis unit type """ return self.zaxis.convert_units(z) @@ -184,25 +197,20 @@ def convert_zunits(self, z): def set_top_view(self): # this happens to be the right view for the viewing coordinates # moved up and to the left slightly to fit labels and axes - xdwl = 0.95 / self.dist - xdw = 0.9 / self.dist - ydwl = 0.95 / self.dist - ydw = 0.9 / self.dist - # This is purposely using the 2D Axes's set_xlim and set_ylim, - # because we are trying to place our viewing pane. - super().set_xlim(-xdwl, xdw, auto=None) - super().set_ylim(-ydwl, ydw, auto=None) + xdwl = 0.95 / self._dist + xdw = 0.9 / self._dist + ydwl = 0.95 / self._dist + ydw = 0.9 / self._dist + # Set the viewing pane. + self.viewLim.intervalx = (-xdwl, xdw) + self.viewLim.intervaly = (-ydwl, ydw) + self.stale = True def _init_axis(self): """Init 3D axes; overrides creation of regular X/Y axes.""" - self.xaxis = axis3d.XAxis('x', self.xy_viewLim.intervalx, - self.xy_dataLim.intervalx, self) - self.yaxis = axis3d.YAxis('y', self.xy_viewLim.intervaly, - self.xy_dataLim.intervaly, self) - self.zaxis = axis3d.ZAxis('z', self.zz_viewLim.intervalx, - self.zz_dataLim.intervalx, self) - for ax in self.xaxis, self.yaxis, self.zaxis: - ax.init3d() + self.xaxis = axis3d.XAxis(self) + self.yaxis = axis3d.YAxis(self) + self.zaxis = axis3d.ZAxis(self) def get_zaxis(self): """Return the ``ZAxis`` (`~.axis3d.Axis`) instance.""" @@ -211,14 +219,18 @@ def get_zaxis(self): get_zgridlines = _axis_method_wrapper("zaxis", "get_gridlines") get_zticklines = _axis_method_wrapper("zaxis", "get_ticklines") - w_xaxis = _api.deprecated("3.1", alternative="xaxis", pending=True)( + w_xaxis = _api.deprecated("3.1", alternative="xaxis", removal="3.8")( property(lambda self: self.xaxis)) - w_yaxis = _api.deprecated("3.1", alternative="yaxis", pending=True)( + w_yaxis = _api.deprecated("3.1", alternative="yaxis", removal="3.8")( property(lambda self: self.yaxis)) - w_zaxis = _api.deprecated("3.1", alternative="zaxis", pending=True)( + w_zaxis = _api.deprecated("3.1", alternative="zaxis", removal="3.8")( property(lambda self: self.zaxis)) + @_api.deprecated("3.7") def unit_cube(self, vals=None): + return self._unit_cube(vals) + + def _unit_cube(self, vals=None): minx, maxx, miny, maxy, minz, maxz = vals or self.get_w_lims() return [(minx, miny, minz), (maxx, miny, minz), @@ -229,15 +241,23 @@ def unit_cube(self, vals=None): (maxx, maxy, maxz), (minx, maxy, maxz)] + @_api.deprecated("3.7") def tunit_cube(self, vals=None, M=None): + return self._tunit_cube(vals, M) + + def _tunit_cube(self, vals=None, M=None): if M is None: M = self.M - xyzs = self.unit_cube(vals) + xyzs = self._unit_cube(vals) tcube = proj3d.proj_points(xyzs, M) return tcube + @_api.deprecated("3.7") def tunit_edges(self, vals=None, M=None): - tc = self.tunit_cube(vals, M) + return self._tunit_edges(vals, M) + + def _tunit_edges(self, vals=None, M=None): + tc = self._tunit_cube(vals, M) edges = [(tc[0], tc[1]), (tc[1], tc[2]), (tc[2], tc[3]), @@ -258,27 +278,22 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): """ Set the aspect ratios. - Axes 3D does not current support any aspect but 'auto' which fills - the axes with the data limits. - - To simulate having equal aspect in data space, set the ratio - of your data limits to match the value of `.get_box_aspect`. - To control box aspect ratios use `~.Axes3D.set_box_aspect`. - Parameters ---------- - aspect : {'auto'} + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} Possible values: ========= ================================================== value description ========= ================================================== 'auto' automatic; fill the position rectangle with data. + 'equal' adapt all the axes to have equal aspect ratios. + 'equalxy' adapt the x and y axes to have equal aspect ratios. + 'equalxz' adapt the x and z axes to have equal aspect ratios. + 'equalyz' adapt the y and z axes to have equal aspect ratios. ========= ================================================== - adjustable : None - Currently ignored by Axes3D - + adjustable : None or {'box', 'datalim'}, optional If not *None*, this defines which parameter will be adjusted to meet the required aspect. See `.set_adjustable` for further details. @@ -286,7 +301,7 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): anchor : None or str or 2-tuple of float, optional If not *None*, this defines where the Axes will be drawn if there is extra space due to aspect constraints. The most common way to - to specify the anchor are abbreviations of cardinal directions: + specify the anchor are abbreviations of cardinal directions: ===== ===================== value description @@ -307,23 +322,73 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): -------- mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect """ - if aspect != 'auto': - raise NotImplementedError( - "Axes3D currently only supports the aspect argument " - f"'auto'. You passed in {aspect!r}." - ) + _api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'), + aspect=aspect) super().set_aspect( - aspect, adjustable=adjustable, anchor=anchor, share=share) + aspect='auto', adjustable=adjustable, anchor=anchor, share=share) + self._aspect = aspect + + if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_indices = self._equal_aspect_axis_indices(aspect) + + view_intervals = np.array([self.xaxis.get_view_interval(), + self.yaxis.get_view_interval(), + self.zaxis.get_view_interval()]) + ptp = np.ptp(view_intervals, axis=1) + if self._adjustable == 'datalim': + mean = np.mean(view_intervals, axis=1) + delta = max(ptp[ax_indices]) + scale = self._box_aspect[ptp == delta][0] + deltas = delta * self._box_aspect / scale + + for i, set_lim in enumerate((self.set_xlim3d, + self.set_ylim3d, + self.set_zlim3d)): + if i in ax_indices: + set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.) + else: # 'box' + # Change the box aspect such that the ratio of the length of + # the unmodified axis to the length of the diagonal + # perpendicular to it remains unchanged. + box_aspect = np.array(self._box_aspect) + box_aspect[ax_indices] = ptp[ax_indices] + remaining_ax_indices = {0, 1, 2}.difference(ax_indices) + if remaining_ax_indices: + remaining = remaining_ax_indices.pop() + old_diag = np.linalg.norm(self._box_aspect[ax_indices]) + new_diag = np.linalg.norm(box_aspect[ax_indices]) + box_aspect[remaining] *= new_diag / old_diag + self.set_box_aspect(box_aspect) + + def _equal_aspect_axis_indices(self, aspect): + """ + Get the indices for which of the x, y, z axes are constrained to have + equal aspect ratios. + + Parameters + ---------- + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} + See descriptions in docstring for `.set_aspect()`. + """ + ax_indices = [] # aspect == 'auto' + if aspect == 'equal': + ax_indices = [0, 1, 2] + elif aspect == 'equalxy': + ax_indices = [0, 1] + elif aspect == 'equalxz': + ax_indices = [0, 2] + elif aspect == 'equalyz': + ax_indices = [1, 2] + return ax_indices def set_box_aspect(self, aspect, *, zoom=1): """ - Set the axes box aspect. + Set the Axes box aspect. The box aspect is the ratio of height to width in display units for each face of the box when viewed perpendicular to - that face. This is not to be confused with the data aspect - (which for Axes3D is always 'auto'). The default ratios are - 4:4:3 (x:y:z). + that face. This is not to be confused with the data aspect (see + `~.Axes3D.set_aspect`). The default ratios are 4:4:3 (x:y:z). To simulate having equal aspect in data space, set the box aspect to match your data range in each dimension. @@ -335,22 +400,19 @@ def set_box_aspect(self, aspect, *, zoom=1): aspect : 3-tuple of floats or None Changes the physical dimensions of the Axes3D, such that the ratio of the axis lengths in display units is x:y:z. + If None, defaults to (4, 4, 3). - If None, defaults to 4:4:3 - - zoom : float - Control overall size of the Axes3D in the figure. + zoom : float, default: 1 + Control overall size of the Axes3D in the figure. Must be > 0. """ + if zoom <= 0: + raise ValueError(f'Argument zoom = {zoom} must be > 0') + if aspect is None: aspect = np.asarray((4, 4, 3), dtype=float) else: - orig_aspect = aspect aspect = np.asarray(aspect, dtype=float) - if aspect.shape != (3,): - raise ValueError( - "You must pass a 3-tuple that can be cast to floats. " - f"You passed {orig_aspect!r}" - ) + _api.check_shape((3,), aspect=aspect) # default scale tuned to match the mpl32 appearance. aspect *= 1.8294640721620434 * zoom / np.linalg.norm(aspect) @@ -365,7 +427,7 @@ def apply_aspect(self, position=None): # scales and box/datalim. Those are all irrelevant - all we need to do # is make sure our coordinate system is square. trans = self.get_figure().transSubfigure - bb = mtransforms.Bbox.from_bounds(0, 0, 1, 1).transformed(trans) + bb = mtransforms.Bbox.unit().transformed(trans) # this is the physical aspect of the panel (or figure): fig_aspect = bb.height / bb.width @@ -376,6 +438,8 @@ def apply_aspect(self, position=None): @martist.allow_rasterization def draw(self, renderer): + if not self.get_visible(): + return self._unstale_viewLim() # draw the background patch @@ -386,210 +450,96 @@ def draw(self, renderer): # this is duplicated from `axes._base._AxesBase.draw` # but must be called before any of the artist are drawn as # it adjusts the view limits and the size of the bounding box - # of the axes + # of the Axes locator = self.get_axes_locator() - if locator: - pos = locator(self, renderer) - self.apply_aspect(pos) - else: - self.apply_aspect() + self.apply_aspect(locator(self, renderer) if locator else None) # add the projection matrix to the renderer self.M = self.get_proj() - props3d = { - # To raise a deprecation, we need to wrap the attribute in a - # function, but binding that to an instance does not work, as you - # would end up with an instance-specific method. Properties are - # class-level attributes which *are* functions, so we do that - # instead. - # This dictionary comprehension creates deprecated properties for - # the attributes listed below, and they are temporarily attached to - # the _class_ in the `_setattr_cm` call. These can both be removed - # once the deprecation expires - name: _api.deprecated('3.4', name=name, - alternative=f'self.axes.{name}')( - property(lambda self, _value=getattr(self, name): _value)) - for name in ['M', 'vvec', 'eye', 'get_axis_position'] - } - - with cbook._setattr_cm(type(renderer), **props3d): - def do_3d_projection(artist): - """ - Call `do_3d_projection` on an *artist*, and warn if passing - *renderer*. - - Attempt to bind the empty signature first, so external Artists - can avoid the deprecation warning if they support the new - calling convention. - """ - try: - signature = inspect.signature(artist.do_3d_projection) - signature.bind() - # ValueError if `inspect.signature` cannot provide a signature - # and TypeError if the binding fails or the object does not - # appear to be callable - the next call will then re-raise. - except (ValueError, TypeError): - _api.warn_deprecated( - "3.4", - message="The 'renderer' parameter of " - "do_3d_projection() was deprecated in Matplotlib " - "%(since)s and will be removed %(removal)s.") - return artist.do_3d_projection(renderer) - else: - # Call this directly once the deprecation period expires. - return artist.do_3d_projection() - - collections_and_patches = ( - artist for artist in self._children - if isinstance(artist, (mcoll.Collection, mpatches.Patch))) - if self.computed_zorder: - # Calculate projection of collections and patches and zorder - # them. Make sure they are drawn above the grids. - zorder_offset = max(axis.get_zorder() - for axis in self._get_axis_list()) + 1 - collection_zorder = patch_zorder = zorder_offset - for artist in sorted(collections_and_patches, - key=do_3d_projection, - reverse=True): - if isinstance(artist, mcoll.Collection): - artist.zorder = collection_zorder - collection_zorder += 1 - elif isinstance(artist, mpatches.Patch): - artist.zorder = patch_zorder - patch_zorder += 1 - else: - for artist in collections_and_patches: - artist.do_3d_projection() - if self._axis3don: - # Draw panes first - for axis in self._get_axis_list(): - axis.draw_pane(renderer) - # Then axes - for axis in self._get_axis_list(): - axis.draw(renderer) + collections_and_patches = ( + artist for artist in self._children + if isinstance(artist, (mcoll.Collection, mpatches.Patch)) + and artist.get_visible()) + if self.computed_zorder: + # Calculate projection of collections and patches and zorder + # them. Make sure they are drawn above the grids. + zorder_offset = max(axis.get_zorder() + for axis in self._axis_map.values()) + 1 + collection_zorder = patch_zorder = zorder_offset + + for artist in sorted(collections_and_patches, + key=lambda artist: artist.do_3d_projection(), + reverse=True): + if isinstance(artist, mcoll.Collection): + artist.zorder = collection_zorder + collection_zorder += 1 + elif isinstance(artist, mpatches.Patch): + artist.zorder = patch_zorder + patch_zorder += 1 + else: + for artist in collections_and_patches: + artist.do_3d_projection() + + if self._axis3don: + # Draw panes first + for axis in self._axis_map.values(): + axis.draw_pane(renderer) + # Then axes + for axis in self._axis_map.values(): + axis.draw(renderer) - # Then rest - super().draw(renderer) + # Then rest + super().draw(renderer) def get_axis_position(self): vals = self.get_w_lims() - tc = self.tunit_cube(vals, self.M) + tc = self._tunit_cube(vals, self.M) xhigh = tc[1][2] > tc[2][2] yhigh = tc[3][2] > tc[2][2] zhigh = tc[0][2] > tc[2][2] return xhigh, yhigh, zhigh - def _unit_change_handler(self, axis_name, event=None): - # docstring inherited - if event is None: # Allow connecting `self._unit_change_handler(name)` - return functools.partial( - self._unit_change_handler, axis_name, event=object()) - _api.check_in_list(self._get_axis_map(), axis_name=axis_name) - self.relim() - self._request_autoscale_view(scalex=(axis_name == "x"), - scaley=(axis_name == "y"), - scalez=(axis_name == "z")) - def update_datalim(self, xys, **kwargs): - pass - - def get_autoscale_on(self): - # docstring inherited - return super().get_autoscale_on() and self.get_autoscalez_on() - - def get_autoscalez_on(self): - """Return whether the z-axis is autoscaled.""" - return self._autoscaleZon - - def set_autoscale_on(self, b): - # docstring inherited - super().set_autoscale_on(b) - self.set_autoscalez_on(b) - - def set_autoscalez_on(self, b): """ - Set whether the z-axis is autoscaled on the next draw or call to - `.Axes.autoscale_view`. - - Parameters - ---------- - b : bool + Not implemented in `~mpl_toolkits.mplot3d.axes3d.Axes3D`. """ - self._autoscaleZon = b + pass - def set_xmargin(self, m): - # docstring inherited - scalez = self._stale_viewlims["z"] - super().set_xmargin(m) - # Superclass is 2D and will call _request_autoscale_view with defaults - # for unknown Axis, which would be scalez=True, but it shouldn't be for - # this call, so restore it. - self._stale_viewlims["z"] = scalez - - def set_ymargin(self, m): - # docstring inherited - scalez = self._stale_viewlims["z"] - super().set_ymargin(m) - # Superclass is 2D and will call _request_autoscale_view with defaults - # for unknown Axis, which would be scalez=True, but it shouldn't be for - # this call, so restore it. - self._stale_viewlims["z"] = scalez + get_autoscalez_on = _axis_method_wrapper("zaxis", "_get_autoscale_on") + set_autoscalez_on = _axis_method_wrapper("zaxis", "_set_autoscale_on") def set_zmargin(self, m): """ Set padding of Z data limits prior to autoscaling. - *m* times the data interval will be added to each - end of that interval before it is used in autoscaling. + *m* times the data interval will be added to each end of that interval + before it is used in autoscaling. If *m* is negative, this will clip + the data range instead of expanding it. + + For example, if your data is in the range [0, 2], a margin of 0.1 will + result in a range [-0.2, 2.2]; a margin of -0.1 will result in a range + of [0.2, 1.8]. - accepts: float in range 0 to 1 + Parameters + ---------- + m : float greater than -0.5 """ - if m < 0 or m > 1: - raise ValueError("margin must be in range 0 to 1") + if m <= -0.5: + raise ValueError("margin must be greater than -0.5") self._zmargin = m - self._request_autoscale_view(scalex=False, scaley=False, scalez=True) + self._request_autoscale_view("z") self.stale = True def margins(self, *margins, x=None, y=None, z=None, tight=True): """ - Convenience method to set or retrieve autoscaling margins. - - Call signatures:: - - margins() - - returns xmargin, ymargin, zmargin - - :: - - margins(margin) - - margins(xmargin, ymargin, zmargin) + Set or retrieve autoscaling margins. - margins(x=xmargin, y=ymargin, z=zmargin) - - margins(..., tight=False) - - All forms above set the xmargin, ymargin and zmargin - parameters. All keyword parameters are optional. A single - positional argument specifies xmargin, ymargin and zmargin. - Passing both positional and keyword arguments for xmargin, - ymargin, and/or zmargin is invalid. - - The *tight* parameter - is passed to :meth:`autoscale_view`, which is executed after - a margin is changed; the default here is *True*, on the - assumption that when margins are specified, no additional - padding to match tick marks is usually desired. Setting - *tight* to *None* will preserve the previous setting. - - Specifying any margin changes only the autoscaling; for example, - if *xmargin* is not None, then *xmargin* times the X data - interval will be added to each end of that interval before - it is used in autoscaling. + See `.Axes.margins` for full documentation. Because this function + applies to 3D Axes, it also takes a *z* argument, and returns + ``(xmargin, ymargin, zmargin)``. """ - if margins and x is not None and y is not None and z is not None: + if margins and (x is not None or y is not None or z is not None): raise TypeError('Cannot pass both positional and keyword ' 'arguments for x, y, and/or z.') elif len(margins) == 1: @@ -620,10 +570,10 @@ def margins(self, *margins, x=None, y=None, z=None, tight=True): def autoscale(self, enable=True, axis='both', tight=None): """ Convenience method for simple axis view autoscaling. - See :meth:`matplotlib.axes.Axes.autoscale` for full explanation. - Note that this function behaves the same, but for all - three axes. Therefore, 'z' can be passed for *axis*, - and 'both' applies to all three axes. + + See `.Axes.autoscale` for full documentation. Because this function + applies to 3D Axes, *axis* can also be set to 'z', and setting *axis* + to 'both' autoscales all three axes. """ if enable is None: scalex = True @@ -631,19 +581,26 @@ def autoscale(self, enable=True, axis='both', tight=None): scalez = True else: if axis in ['x', 'both']: - self._autoscaleXon = scalex = bool(enable) + self.set_autoscalex_on(bool(enable)) + scalex = self.get_autoscalex_on() else: scalex = False if axis in ['y', 'both']: - self._autoscaleYon = scaley = bool(enable) + self.set_autoscaley_on(bool(enable)) + scaley = self.get_autoscaley_on() else: scaley = False if axis in ['z', 'both']: - self._autoscaleZon = scalez = bool(enable) + self.set_autoscalez_on(bool(enable)) + scalez = self.get_autoscalez_on() else: scalez = False - self._request_autoscale_view(tight=tight, scalex=scalex, scaley=scaley, - scalez=scalez) + if scalex: + self._request_autoscale_view("x", tight=tight) + if scaley: + self._request_autoscale_view("y", tight=tight) + if scalez: + self._request_autoscale_view("z", tight=tight) def auto_scale_xyz(self, X, Y, Z=None, had_data=None): # This updates the bounding boxes as to keep a record as to what the @@ -663,9 +620,9 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, scalez=True): """ Autoscale the view limits using the data limits. - See :meth:`matplotlib.axes.Axes.autoscale_view` for documentation. - Note that this function applies to the 3D axes, and as such - adds the *scalez* to the function arguments. + + See `.Axes.autoscale_view` for full documentation. Because this + function applies to 3D Axes, it also takes a *scalez* argument. """ # This method looks at the rectangular volume (see above) # of data and decides how to scale the view portal to fit it. @@ -682,7 +639,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, else: _tight = self._tight = bool(tight) - if scalex and self._autoscaleXon: + if scalex and self.get_autoscalex_on(): self._shared_axes["x"].clean() x0, x1 = self.xy_dataLim.intervalx xlocator = self.xaxis.get_major_locator() @@ -695,7 +652,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, x0, x1 = xlocator.view_limits(x0, x1) self.set_xbound(x0, x1) - if scaley and self._autoscaleYon: + if scaley and self.get_autoscaley_on(): self._shared_axes["y"].clean() y0, y1 = self.xy_dataLim.intervaly ylocator = self.yaxis.get_major_locator() @@ -708,7 +665,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True, y0, y1 = ylocator.view_limits(y0, y1) self.set_ybound(y0, y1) - if scalez and self._autoscaleZon: + if scalez and self.get_autoscalez_on(): self._shared_axes["z"].clean() z0, z1 = self.zz_dataLim.intervalx zlocator = self.zaxis.get_major_locator() @@ -728,219 +685,49 @@ def get_w_lims(self): minz, maxz = self.get_zlim3d() return minx, maxx, miny, maxy, minz, maxz - def set_xlim3d(self, left=None, right=None, emit=True, auto=False, - *, xmin=None, xmax=None): - """ - Set 3D x limits. - - See :meth:`matplotlib.axes.Axes.set_xlim` for full documentation. - """ - if right is None and np.iterable(left): - left, right = left - if xmin is not None: - if left is not None: - raise TypeError('Cannot pass both `xmin` and `left`') - left = xmin - if xmax is not None: - if right is not None: - raise TypeError('Cannot pass both `xmax` and `right`') - right = xmax - - self._process_unit_info([("x", (left, right))], convert=False) - left = self._validate_converted_limits(left, self.convert_xunits) - right = self._validate_converted_limits(right, self.convert_xunits) - - old_left, old_right = self.get_xlim() - if left is None: - left = old_left - if right is None: - right = old_right - - if left == right: - _api.warn_external( - f"Attempting to set identical left == right == {left} results " - f"in singular transformations; automatically expanding.") - reverse = left > right - left, right = self.xaxis.get_major_locator().nonsingular(left, right) - left, right = self.xaxis.limit_range_for_scale(left, right) - # cast to bool to avoid bad interaction between python 3.8 and np.bool_ - left, right = sorted([left, right], reverse=bool(reverse)) - self.xy_viewLim.intervalx = (left, right) - - # Mark viewlims as no longer stale without triggering an autoscale. - for ax in self._shared_axes["x"].get_siblings(self): - ax._stale_viewlims["x"] = False - if auto is not None: - self._autoscaleXon = bool(auto) - - if emit: - self.callbacks.process('xlim_changed', self) - # Call all of the other x-axes that are shared with this one - for other in self._shared_axes["x"].get_siblings(self): - if other is not self: - other.set_xlim(self.xy_viewLim.intervalx, - emit=False, auto=auto) - if other.figure != self.figure: - other.figure.canvas.draw_idle() - self.stale = True - return left, right - - def set_ylim3d(self, bottom=None, top=None, emit=True, auto=False, - *, ymin=None, ymax=None): - """ - Set 3D y limits. - - See :meth:`matplotlib.axes.Axes.set_ylim` for full documentation. - """ - if top is None and np.iterable(bottom): - bottom, top = bottom - if ymin is not None: - if bottom is not None: - raise TypeError('Cannot pass both `ymin` and `bottom`') - bottom = ymin - if ymax is not None: - if top is not None: - raise TypeError('Cannot pass both `ymax` and `top`') - top = ymax - - self._process_unit_info([("y", (bottom, top))], convert=False) - bottom = self._validate_converted_limits(bottom, self.convert_yunits) - top = self._validate_converted_limits(top, self.convert_yunits) - - old_bottom, old_top = self.get_ylim() - if bottom is None: - bottom = old_bottom - if top is None: - top = old_top - - if bottom == top: - _api.warn_external( - f"Attempting to set identical bottom == top == {bottom} " - f"results in singular transformations; automatically " - f"expanding.") - swapped = bottom > top - bottom, top = self.yaxis.get_major_locator().nonsingular(bottom, top) - bottom, top = self.yaxis.limit_range_for_scale(bottom, top) - if swapped: - bottom, top = top, bottom - self.xy_viewLim.intervaly = (bottom, top) - - # Mark viewlims as no longer stale without triggering an autoscale. - for ax in self._shared_axes["y"].get_siblings(self): - ax._stale_viewlims["y"] = False - if auto is not None: - self._autoscaleYon = bool(auto) - - if emit: - self.callbacks.process('ylim_changed', self) - # Call all of the other y-axes that are shared with this one - for other in self._shared_axes["y"].get_siblings(self): - if other is not self: - other.set_ylim(self.xy_viewLim.intervaly, - emit=False, auto=auto) - if other.figure != self.figure: - other.figure.canvas.draw_idle() - self.stale = True - return bottom, top - - def set_zlim3d(self, bottom=None, top=None, emit=True, auto=False, - *, zmin=None, zmax=None): + # set_xlim, set_ylim are directly inherited from base Axes. + @_api.make_keyword_only("3.6", "emit") + def set_zlim(self, bottom=None, top=None, emit=True, auto=False, + *, zmin=None, zmax=None): """ Set 3D z limits. - See :meth:`matplotlib.axes.Axes.set_ylim` for full documentation + See `.Axes.set_ylim` for full documentation """ if top is None and np.iterable(bottom): bottom, top = bottom if zmin is not None: if bottom is not None: - raise TypeError('Cannot pass both `zmin` and `bottom`') + raise TypeError("Cannot pass both 'bottom' and 'zmin'") bottom = zmin if zmax is not None: if top is not None: - raise TypeError('Cannot pass both `zmax` and `top`') + raise TypeError("Cannot pass both 'top' and 'zmax'") top = zmax + return self.zaxis._set_lim(bottom, top, emit=emit, auto=auto) - self._process_unit_info([("z", (bottom, top))], convert=False) - bottom = self._validate_converted_limits(bottom, self.convert_zunits) - top = self._validate_converted_limits(top, self.convert_zunits) - - old_bottom, old_top = self.get_zlim() - if bottom is None: - bottom = old_bottom - if top is None: - top = old_top - - if bottom == top: - _api.warn_external( - f"Attempting to set identical bottom == top == {bottom} " - f"results in singular transformations; automatically " - f"expanding.") - swapped = bottom > top - bottom, top = self.zaxis.get_major_locator().nonsingular(bottom, top) - bottom, top = self.zaxis.limit_range_for_scale(bottom, top) - if swapped: - bottom, top = top, bottom - self.zz_viewLim.intervalx = (bottom, top) - - # Mark viewlims as no longer stale without triggering an autoscale. - for ax in self._shared_axes["z"].get_siblings(self): - ax._stale_viewlims["z"] = False - if auto is not None: - self._autoscaleZon = bool(auto) - - if emit: - self.callbacks.process('zlim_changed', self) - # Call all of the other y-axes that are shared with this one - for other in self._shared_axes["z"].get_siblings(self): - if other is not self: - other.set_zlim(self.zz_viewLim.intervalx, - emit=False, auto=auto) - if other.figure != self.figure: - other.figure.canvas.draw_idle() - self.stale = True - return bottom, top + set_xlim3d = maxes.Axes.set_xlim + set_ylim3d = maxes.Axes.set_ylim + set_zlim3d = set_zlim - def get_xlim3d(self): + def get_xlim(self): + # docstring inherited return tuple(self.xy_viewLim.intervalx) - get_xlim3d.__doc__ = maxes.Axes.get_xlim.__doc__ - def get_ylim3d(self): + def get_ylim(self): + # docstring inherited return tuple(self.xy_viewLim.intervaly) - get_ylim3d.__doc__ = maxes.Axes.get_ylim.__doc__ - def get_zlim3d(self): + def get_zlim(self): """Get 3D z limits.""" return tuple(self.zz_viewLim.intervalx) - def get_zscale(self): - """ - Return the zaxis scale string %s - - """ % (", ".join(mscale.get_scale_names())) - return self.zaxis.get_scale() - - # We need to slightly redefine these to pass scalez=False - # to their calls of autoscale_view. - - def set_xscale(self, value, **kwargs): - self.xaxis._set_scale(value, **kwargs) - self.autoscale_view(scaley=False, scalez=False) - self._update_transScale() - self.stale = True - - def set_yscale(self, value, **kwargs): - self.yaxis._set_scale(value, **kwargs) - self.autoscale_view(scalex=False, scalez=False) - self._update_transScale() - self.stale = True - - def set_zscale(self, value, **kwargs): - self.zaxis._set_scale(value, **kwargs) - self.autoscale_view(scalex=False, scaley=False) - self._update_transScale() - self.stale = True + get_zscale = _axis_method_wrapper("zaxis", "get_scale") + # Redefine all three methods to overwrite their docstrings. + set_xscale = _axis_method_wrapper("xaxis", "_set_axes_scale") + set_yscale = _axis_method_wrapper("yaxis", "_set_axes_scale") + set_zscale = _axis_method_wrapper("zaxis", "_set_axes_scale") set_xscale.__doc__, set_yscale.__doc__, set_zscale.__doc__ = map( """ Set the {}-axis scale. @@ -963,7 +750,7 @@ def set_zscale(self, value, **kwargs): get_zminorticklabels = _axis_method_wrapper("zaxis", "get_minorticklabels") get_zticklabels = _axis_method_wrapper("zaxis", "get_ticklabels") set_zticklabels = _axis_method_wrapper( - "zaxis", "_set_ticklabels", + "zaxis", "set_ticklabels", doc_sub={"Axis.set_ticks": "Axes3D.set_zticks"}) zaxis_date = _axis_method_wrapper("zaxis", "axis_date") @@ -980,27 +767,56 @@ def clabel(self, *args, **kwargs): """Currently not implemented for 3D axes, and returns *None*.""" return None - def view_init(self, elev=None, azim=None, vertical_axis="z"): + def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z"): """ Set the elevation and azimuth of the axes in degrees (not radians). This can be used to rotate the axes programmatically. + To look normal to the primary planes, the following elevation and + azimuth angles can be used. A roll angle of 0, 90, 180, or 270 deg + will rotate these views while keeping the axes at right angles. + + ========== ==== ==== + view plane elev azim + ========== ==== ==== + XY 90 -90 + XZ 0 -90 + YZ 0 0 + -XY -90 90 + -XZ 0 90 + -YZ 0 180 + ========== ==== ==== + Parameters ---------- elev : float, default: None - The elevation angle in the vertical plane in degrees. - If None then the initial value as specified in the `Axes3D` + The elevation angle in degrees rotates the camera above the plane + pierced by the vertical axis, with a positive angle corresponding + to a location above that plane. For example, with the default + vertical axis of 'z', the elevation defines the angle of the camera + location above the x-y plane. + If None, then the initial value as specified in the `Axes3D` constructor is used. azim : float, default: None - The azimuth angle in the horizontal plane in degrees. - If None then the initial value as specified in the `Axes3D` + The azimuthal angle in degrees rotates the camera about the + vertical axis, with a positive angle corresponding to a + right-handed rotation. For example, with the default vertical axis + of 'z', a positive azimuth rotates the camera about the origin from + its location along the +x axis towards the +y axis. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + roll : float, default: None + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. + If None, then the initial value as specified in the `Axes3D` constructor is used. vertical_axis : {"z", "x", "y"}, default: "z" The axis to align vertically. *azim* rotates about this axis. """ - self.dist = 10 + self._dist = 10 # The camera distance from origin. Behaves like zoom if elev is None: self.elev = self.initial_elev @@ -1012,22 +828,42 @@ def view_init(self, elev=None, azim=None, vertical_axis="z"): else: self.azim = azim + if roll is None: + self.roll = self.initial_roll + else: + self.roll = roll + self._vertical_axis = _api.check_getitem( dict(x=0, y=1, z=2), vertical_axis=vertical_axis ) - def set_proj_type(self, proj_type): + def set_proj_type(self, proj_type, focal_length=None): """ Set the projection type. Parameters ---------- proj_type : {'persp', 'ortho'} - """ - self._projection = _api.check_getitem({ - 'persp': proj3d.persp_transformation, - 'ortho': proj3d.ortho_transformation, - }, proj_type=proj_type) + The projection type. + focal_length : float, default: None + For a projection type of 'persp', the focal length of the virtual + camera. Must be > 0. If None, defaults to 1. + The focal length can be computed from a desired Field Of View via + the equation: focal_length = 1/tan(FOV/2) + """ + _api.check_in_list(['persp', 'ortho'], proj_type=proj_type) + if proj_type == 'persp': + if focal_length is None: + focal_length = 1 + elif focal_length <= 0: + raise ValueError(f"focal_length = {focal_length} must be " + "greater than 0") + self._focal_length = focal_length + else: # 'ortho': + if focal_length not in (None, np.inf): + raise ValueError(f"focal_length = {focal_length} must be " + f"None for proj_type = {proj_type}") + self._focal_length = np.inf def _roll_to_vertical(self, arr): """Roll arrays to match the different vertical axis.""" @@ -1045,13 +881,13 @@ def get_proj(self): pb_aspect=box_aspect, ) - # Look into the middle of the new coordinates: + # Look into the middle of the world coordinates: R = 0.5 * box_aspect # elev stores the elevation angle in the z plane # azim stores the azimuth angle in the x,y plane - elev_rad = np.deg2rad(self.elev) - azim_rad = np.deg2rad(self.azim) + elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) + azim_rad = np.deg2rad(art3d._norm_angle(self.azim)) # Coordinates for a point that rotates around the box of data. # p0, p1 corresponds to rotating the box only around the @@ -1068,26 +904,39 @@ def get_proj(self): # The coordinates for the eye viewing point. The eye is looking # towards the middle of the box of data from a distance: - eye = R + self.dist * ps - - # TODO: Is this being used somewhere? Can it be removed? - self.eye = eye - self.vvec = R - eye - self.vvec = self.vvec / np.linalg.norm(self.vvec) - - # Define which axis should be vertical. A negative value - # indicates the plot is upside down and therefore the values - # have been reversed: - V = np.zeros(3) - V[self._vertical_axis] = -1 if abs(elev_rad) > 0.5 * np.pi else 1 - - viewM = proj3d.view_transformation(eye, R, V) - projM = self._projection(-self.dist, self.dist) + eye = R + self._dist * ps + + # vvec, self._vvec and self._eye are unused, remove when deprecated + vvec = R - eye + self._eye = eye + self._vvec = vvec / np.linalg.norm(vvec) + + # Calculate the viewing axes for the eye position + u, v, w = self._calc_view_axes(eye) + self._view_u = u # _view_u is towards the right of the screen + self._view_v = v # _view_v is towards the top of the screen + self._view_w = w # _view_w is out of the screen + + # Generate the view and projection transformation matrices + if self._focal_length == np.inf: + # Orthographic projection + viewM = proj3d._view_transformation_uvw(u, v, w, eye) + projM = proj3d.ortho_transformation(-self._dist, self._dist) + else: + # Perspective projection + # Scale the eye dist to compensate for the focal length zoom effect + eye_focal = R + self._dist * ps * self._focal_length + viewM = proj3d._view_transformation_uvw(u, v, w, eye_focal) + projM = proj3d.persp_transformation(-self._dist, + self._dist, + self._focal_length) + + # Combine all the transformation matrices to get the final projection M0 = np.dot(viewM, worldM) M = np.dot(projM, M0) return M - def mouse_init(self, rotate_btn=1, zoom_btn=3): + def mouse_init(self, rotate_btn=1, pan_btn=2, zoom_btn=3): """ Set the mouse buttons for 3D rotation and zooming. @@ -1095,6 +944,8 @@ def mouse_init(self, rotate_btn=1, zoom_btn=3): ---------- rotate_btn : int or list of int, default: 1 The mouse button or buttons to use for 3D rotation of the axes. + pan_btn : int or list of int, default: 2 + The mouse button or buttons to use to pan the 3D axes. zoom_btn : int or list of int, default: 3 The mouse button or buttons to use to zoom the 3D axes. """ @@ -1103,59 +954,57 @@ def mouse_init(self, rotate_btn=1, zoom_btn=3): # a regular list to avoid comparisons against None # which breaks in recent versions of numpy. self._rotate_btn = np.atleast_1d(rotate_btn).tolist() + self._pan_btn = np.atleast_1d(pan_btn).tolist() self._zoom_btn = np.atleast_1d(zoom_btn).tolist() def disable_mouse_rotation(self): - """Disable mouse buttons for 3D rotation and zooming.""" - self.mouse_init(rotate_btn=[], zoom_btn=[]) + """Disable mouse buttons for 3D rotation, panning, and zooming.""" + self.mouse_init(rotate_btn=[], pan_btn=[], zoom_btn=[]) def can_zoom(self): """ - Return whether this axes supports the zoom box button functionality. - - 3D axes objects do not use the zoom box button. + Return whether this Axes supports the zoom box button functionality. """ - return False + return True def can_pan(self): """ - Return whether this axes supports the pan/zoom button functionality. - - 3D axes objects do not use the pan/zoom button. + Return whether this Axes supports the pan button functionality. """ - return False + return True - def cla(self): - # docstring inherited. + def sharez(self, other): + """ + Share the z-axis with *other*. - super().cla() - self.zaxis.clear() + This is equivalent to passing ``sharez=other`` when constructing the + Axes, and cannot be used if the z-axis is already being shared with + another Axes. + """ + _api.check_isinstance(maxes._base._AxesBase, other=other) + if self._sharez is not None and other is not self._sharez: + raise ValueError("z-axis is already shared") + self._shared_axes["z"].join(self, other) + self._sharez = other + self.zaxis.major = other.zaxis.major # Ticker instances holding + self.zaxis.minor = other.zaxis.minor # locator and formatter. + z0, z1 = other.get_zlim() + self.set_zlim(z0, z1, emit=False, auto=other.get_autoscalez_on()) + self.zaxis._scale = other.zaxis._scale - if self._sharez is not None: - self.zaxis.major = self._sharez.zaxis.major - self.zaxis.minor = self._sharez.zaxis.minor - z0, z1 = self._sharez.get_zlim() - self.set_zlim(z0, z1, emit=False, auto=None) - self.zaxis._set_scale(self._sharez.zaxis.get_scale()) - else: - self.zaxis._set_scale('linear') - try: - self.set_zlim(0, 1) - except TypeError: - pass - - self._autoscaleZon = True - if self._projection is proj3d.ortho_transformation: - self._zmargin = rcParams['axes.zmargin'] + def clear(self): + # docstring inherited. + super().clear() + if self._focal_length == np.inf: + self._zmargin = mpl.rcParams['axes.zmargin'] else: self._zmargin = 0. - - self.grid(rcParams['axes3d.grid']) + self.grid(mpl.rcParams['axes3d.grid']) def _button_press(self, event): if event.inaxes == self: self.button_pressed = event.button - self.sx, self.sy = event.xdata, event.ydata + self._sx, self._sy = event.xdata, event.ydata toolbar = getattr(self.figure.canvas, "toolbar") if toolbar and toolbar._nav_stack() is None: self.figure.canvas.toolbar.push_current() @@ -1163,20 +1012,23 @@ def _button_press(self, event): def _button_release(self, event): self.button_pressed = None toolbar = getattr(self.figure.canvas, "toolbar") - if toolbar: + # backend_bases.release_zoom and backend_bases.release_pan call + # push_current, so check the navigation mode so we don't call it twice + if toolbar and self.get_navigate_mode() is None: self.figure.canvas.toolbar.push_current() def _get_view(self): # docstring inherited return (self.get_xlim(), self.get_ylim(), self.get_zlim(), - self.elev, self.azim) + self.elev, self.azim, self.roll) def _set_view(self, view): # docstring inherited - xlim, ylim, zlim, elev, azim = view + xlim, ylim, zlim, elev, azim, roll = view self.set(xlim=xlim, ylim=ylim, zlim=zlim) self.elev = elev self.azim = azim + self.roll = roll def format_zdata(self, z): """ @@ -1203,14 +1055,18 @@ def format_coord(self, xd, yd): if self.button_pressed in self._rotate_btn: # ignore xd and yd and display angles instead - return (f"azimuth={self.azim:.0f}\N{DEGREE SIGN}, " - f"elevation={self.elev:.0f}\N{DEGREE SIGN}" + norm_elev = art3d._norm_angle(self.elev) + norm_azim = art3d._norm_angle(self.azim) + norm_roll = art3d._norm_angle(self.roll) + return (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " + f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, " + f"roll={norm_roll:.0f}\N{DEGREE SIGN}" ).replace("-", "\N{MINUS SIGN}") # nearest edge - p0, p1 = min(self.tunit_edges(), + p0, p1 = min(self._tunit_edges(), key=lambda edge: proj3d._line2d_seg_dist( - edge[0], edge[1], (xd, yd))) + (xd, yd), edge[0][:2], edge[1][:2])) # scale the z value to match x0, y0, z0 = p0 @@ -1231,25 +1087,29 @@ def _on_move(self, event): """ Mouse moving. - By default, button-1 rotates and button-3 zooms; these buttons can be - modified via `mouse_init`. + By default, button-1 rotates, button-2 pans, and button-3 zooms; + these buttons can be modified via `mouse_init`. """ if not self.button_pressed: return + if self.get_navigate_mode() is not None: + # we don't want to rotate if we are zooming/panning + # from the toolbar + return + if self.M is None: return x, y = event.xdata, event.ydata # In case the mouse is out of bounds. - if x is None: + if x is None or event.inaxes != self: return - dx, dy = x - self.sx, y - self.sy + dx, dy = x - self._sx, y - self._sy w = self._pseudo_w h = self._pseudo_h - self.sx, self.sy = x, y # Rotation if self.button_pressed in self._rotate_btn: @@ -1257,46 +1117,207 @@ def _on_move(self, event): # get the x and y pixel coords if dx == 0 and dy == 0: return - self.elev = art3d._norm_angle(self.elev - (dy/h)*180) - self.azim = art3d._norm_angle(self.azim - (dx/w)*180) - self.get_proj() + + roll = np.deg2rad(self.roll) + delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll) + dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll) + self.elev = self.elev + delev + self.azim = self.azim + dazim self.stale = True - self.figure.canvas.draw_idle() - elif self.button_pressed == 2: - # pan view - # get the x and y pixel coords - if dx == 0 and dy == 0: - return - minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - dx = 1-((w - dx)/w) - dy = 1-((h - dy)/h) - elev, azim = np.deg2rad(self.elev), np.deg2rad(self.azim) - # project xv, yv, zv -> xw, yw, zw - dxx = (maxx-minx)*(dy*np.sin(elev)*np.cos(azim) + dx*np.sin(azim)) - dyy = (maxy-miny)*(-dx*np.cos(azim) + dy*np.sin(elev)*np.sin(azim)) - dzz = (maxz-minz)*(-dy*np.cos(elev)) - # pan - self.set_xlim3d(minx + dxx, maxx + dxx) - self.set_ylim3d(miny + dyy, maxy + dyy) - self.set_zlim3d(minz + dzz, maxz + dzz) - self.get_proj() - self.figure.canvas.draw_idle() + elif self.button_pressed in self._pan_btn: + # Start the pan event with pixel coordinates + px, py = self.transData.transform([self._sx, self._sy]) + self.start_pan(px, py, 2) + # pan view (takes pixel coordinate input) + self.drag_pan(2, None, event.x, event.y) + self.end_pan() # Zoom elif self.button_pressed in self._zoom_btn: - # zoom view - # hmmm..this needs some help from clipping.... - minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() - df = 1-((h - dy)/h) - dx = (maxx-minx)*df - dy = (maxy-miny)*df - dz = (maxz-minz)*df - self.set_xlim3d(minx - dx, maxx + dx) - self.set_ylim3d(miny - dy, maxy + dy) - self.set_zlim3d(minz - dz, maxz + dz) - self.get_proj() - self.figure.canvas.draw_idle() + # zoom view (dragging down zooms in) + scale = h/(h - dy) + self._scale_axis_limits(scale, scale, scale) + + # Store the event coordinates for the next time through. + self._sx, self._sy = x, y + # Always request a draw update at the end of interaction + self.figure.canvas.draw_idle() + + def drag_pan(self, button, key, x, y): + # docstring inherited + + # Get the coordinates from the move event + p = self._pan_start + (xdata, ydata), (xdata_start, ydata_start) = p.trans_inverse.transform( + [(x, y), (p.x, p.y)]) + self._sx, self._sy = xdata, ydata + # Calling start_pan() to set the x/y of this event as the starting + # move location for the next event + self.start_pan(x, y, button) + du, dv = xdata - xdata_start, ydata - ydata_start + dw = 0 + if key == 'x': + dv = 0 + elif key == 'y': + du = 0 + if du == 0 and dv == 0: + return + + # Transform the pan from the view axes to the data axes + R = np.array([self._view_u, self._view_v, self._view_w]) + R = -R / self._box_aspect * self._dist + duvw_projected = R.T @ np.array([du, dv, dw]) + + # Calculate pan distance + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + dx = (maxx - minx) * duvw_projected[0] + dy = (maxy - miny) * duvw_projected[1] + dz = (maxz - minz) * duvw_projected[2] + + # Set the new axis limits + self.set_xlim3d(minx + dx, maxx + dx) + self.set_ylim3d(miny + dy, maxy + dy) + self.set_zlim3d(minz + dz, maxz + dz) + + def _calc_view_axes(self, eye): + """ + Get the unit vectors for the viewing axes in data coordinates. + `u` is towards the right of the screen + `v` is towards the top of the screen + `w` is out of the screen + """ + elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) + roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) + + # Look into the middle of the world coordinates + R = 0.5 * self._roll_to_vertical(self._box_aspect) + + # Define which axis should be vertical. A negative value + # indicates the plot is upside down and therefore the values + # have been reversed: + V = np.zeros(3) + V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1 + + u, v, w = proj3d._view_axes(eye, R, V, roll_rad) + return u, v, w + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + """ + Zoom in or out of the bounding box. + + Will center the view in the center of the bounding box, and zoom by + the ratio of the size of the bounding box to the size of the Axes3D. + """ + (start_x, start_y, stop_x, stop_y) = bbox + if mode == 'x': + start_y = self.bbox.min[1] + stop_y = self.bbox.max[1] + elif mode == 'y': + start_x = self.bbox.min[0] + stop_x = self.bbox.max[0] + + # Clip to bounding box limits + start_x, stop_x = np.clip(sorted([start_x, stop_x]), + self.bbox.min[0], self.bbox.max[0]) + start_y, stop_y = np.clip(sorted([start_y, stop_y]), + self.bbox.min[1], self.bbox.max[1]) + + # Move the center of the view to the center of the bbox + zoom_center_x = (start_x + stop_x)/2 + zoom_center_y = (start_y + stop_y)/2 + + ax_center_x = (self.bbox.max[0] + self.bbox.min[0])/2 + ax_center_y = (self.bbox.max[1] + self.bbox.min[1])/2 + + self.start_pan(zoom_center_x, zoom_center_y, 2) + self.drag_pan(2, None, ax_center_x, ax_center_y) + self.end_pan() + + # Calculate zoom level + dx = abs(start_x - stop_x) + dy = abs(start_y - stop_y) + scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) + scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) + + # Keep aspect ratios equal + scale = max(scale_u, scale_v) + + # Zoom out + if direction == 'out': + scale = 1 / scale + + self._zoom_data_limits(scale, scale, scale) + + def _zoom_data_limits(self, scale_u, scale_v, scale_w): + """ + Zoom in or out of a 3D plot. + + Will scale the data limits by the scale factors. These will be + transformed to the x, y, z data axes based on the current view angles. + A scale factor > 1 zooms out and a scale factor < 1 zooms in. + + For an axes that has had its aspect ratio set to 'equal', 'equalxy', + 'equalyz', or 'equalxz', the relevant axes are constrained to zoom + equally. + + Parameters + ---------- + scale_u : float + Scale factor for the u view axis (view screen horizontal). + scale_v : float + Scale factor for the v view axis (view screen vertical). + scale_w : float + Scale factor for the w view axis (view screen depth). + """ + scale = np.array([scale_u, scale_v, scale_w]) + + # Only perform frame conversion if unequal scale factors + if not np.allclose(scale, scale_u): + # Convert the scale factors from the view frame to the data frame + R = np.array([self._view_u, self._view_v, self._view_w]) + S = scale * np.eye(3) + scale = np.linalg.norm(R.T @ S, axis=1) + + # Set the constrained scale factors to the factor closest to 1 + if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_idxs = self._equal_aspect_axis_indices(self._aspect) + min_ax_idxs = np.argmin(np.abs(scale[ax_idxs] - 1)) + scale[ax_idxs] = scale[ax_idxs][min_ax_idxs] + + self._scale_axis_limits(scale[0], scale[1], scale[2]) + + def _scale_axis_limits(self, scale_x, scale_y, scale_z): + """ + Keeping the center of the x, y, and z data axes fixed, scale their + limits by scale factors. A scale factor > 1 zooms out and a scale + factor < 1 zooms in. + + Parameters + ---------- + scale_x : float + Scale factor for the x data axis. + scale_y : float + Scale factor for the y data axis. + scale_z : float + Scale factor for the z data axis. + """ + # Get the axis limits and centers + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + cx = (maxx + minx)/2 + cy = (maxy + miny)/2 + cz = (maxz + minz)/2 + + # Scale the data range + dx = (maxx - minx)*scale_x + dy = (maxy - miny)*scale_y + dz = (maxz - minz)*scale_z + + # Set the scaled axis limits + self.set_xlim3d(cx - dx/2, cx + dx/2) + self.set_ylim3d(cy - dy/2, cy + dy/2) + self.set_zlim3d(cz - dz/2, cz + dz/2) def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): """ @@ -1330,7 +1351,6 @@ def set_frame_on(self, b): self._frameon = bool(b) self.stale = True - @_api.rename_parameter("3.5", "b", "visible") def grid(self, visible=True, **kwargs): """ Set / unset 3D grid. @@ -1338,8 +1358,8 @@ def grid(self, visible=True, **kwargs): .. note:: Currently, this function does not behave the same as - :meth:`matplotlib.axes.Axes.grid`, but it is intended to - eventually support that behavior. + `.axes.Axes.grid`, but it is intended to eventually support that + behavior. """ # TODO: Operate on each axes separately if len(kwargs): @@ -1347,41 +1367,14 @@ def grid(self, visible=True, **kwargs): self._draw_grid = visible self.stale = True - def locator_params(self, axis='both', tight=None, **kwargs): - """ - Convenience method for controlling tick locators. - - See :meth:`matplotlib.axes.Axes.locator_params` for full - documentation. Note that this is for Axes3D objects, - therefore, setting *axis* to 'both' will result in the - parameters being set for all three axes. Also, *axis* - can also take a value of 'z' to apply parameters to the - z axis. - """ - _x = axis in ['x', 'both'] - _y = axis in ['y', 'both'] - _z = axis in ['z', 'both'] - if _x: - self.xaxis.get_major_locator().set_params(**kwargs) - if _y: - self.yaxis.get_major_locator().set_params(**kwargs) - if _z: - self.zaxis.get_major_locator().set_params(**kwargs) - self._request_autoscale_view(tight=tight, scalex=_x, scaley=_y, - scalez=_z) - def tick_params(self, axis='both', **kwargs): """ Convenience method for changing the appearance of ticks and tick labels. - See :meth:`matplotlib.axes.Axes.tick_params` for more complete - documentation. - - The only difference is that setting *axis* to 'both' will - mean that the settings are applied to all three axes. Also, - the *axis* parameter also accepts a value of 'z', which - would mean to apply to only the z-axis. + See `.Axes.tick_params` for full documentation. Because this function + applies to 3D Axes, *axis* can also be set to 'z', and setting *axis* + to 'both' autoscales all three axes. Also, because of how Axes3D objects are drawn very differently from regular 2D axes, some of these settings may have @@ -1411,12 +1404,7 @@ def invert_zaxis(self): bottom, top = self.get_zlim() self.set_zlim(top, bottom, auto=None) - def zaxis_inverted(self): - """ - Returns True if the z-axis is inverted. - """ - bottom, top = self.get_zlim() - return top < bottom + zaxis_inverted = _axis_method_wrapper("zaxis", "get_inverted") def get_zbound(self): """ @@ -1450,9 +1438,11 @@ def set_zbound(self, lower=None, upper=None): def text(self, x, y, z, s, zdir=None, **kwargs): """ - Add text to the plot. kwargs will be passed on to Axes.text, - except for the *zdir* keyword, which sets the direction to be - used as the z direction. + Add text to the plot. + + Keyword arguments will be passed on to `.Axes.text`, except for the + *zdir* keyword, which sets the direction to be used as the z + direction. """ text = super().text(x, y, s, **kwargs) art3d.text_2d_to_3d(text, z, zdir) @@ -1475,7 +1465,7 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): z coordinates of vertices; either one for all points or one for each point. zdir : {'x', 'y', 'z'}, default: 'z' - When plotting 2D data, the direction to use as z ('x', 'y' or 'z'). + When plotting 2D data, the direction to use as z. **kwargs Other arguments are forwarded to `matplotlib.axes.Axes.plot`. """ @@ -1504,13 +1494,12 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): plot3D = plot - @_api.delete_parameter("3.4", "args", alternative="kwargs") - def plot_surface(self, X, Y, Z, *args, norm=None, vmin=None, + def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, vmax=None, lightsource=None, **kwargs): """ Create a surface plot. - By default it will be colored in shades of a solid color, but it also + By default, it will be colored in shades of a solid color, but it also supports colormapping by supplying the *cmap* argument. .. note:: @@ -1572,7 +1561,7 @@ def plot_surface(self, X, Y, Z, *args, norm=None, vmin=None, The lightsource to use when *shade* is True. **kwargs - Other arguments are forwarded to `.Poly3DCollection`. + Other keyword arguments are forwarded to `.Poly3DCollection`. """ had_data = self.has_data() @@ -1595,7 +1584,7 @@ def plot_surface(self, X, Y, Z, *args, norm=None, vmin=None, rcount = kwargs.pop('rcount', 50) ccount = kwargs.pop('ccount', 50) - if rcParams['_internal.classic_mode']: + if mpl.rcParams['_internal.classic_mode']: # Strides have priority over counts in classic mode. # So, only compute strides from counts # if counts were explicitly given @@ -1609,24 +1598,18 @@ def plot_surface(self, X, Y, Z, *args, norm=None, vmin=None, rstride = int(max(np.ceil(rows / rcount), 1)) cstride = int(max(np.ceil(cols / ccount), 1)) - if 'facecolors' in kwargs: - fcolors = kwargs.pop('facecolors') - else: + fcolors = kwargs.pop('facecolors', None) + + if fcolors is None: color = kwargs.pop('color', None) if color is None: color = self._get_lines.get_next_color() color = np.array(mcolors.to_rgba(color)) - fcolors = None cmap = kwargs.get('cmap', None) shade = kwargs.pop('shade', cmap is None) if shade is None: - _api.warn_deprecated( - "3.1", - message="Passing shade=None to Axes3D.plot_surface() is " - "deprecated since matplotlib 3.1 and will change its " - "semantic or raise an error in matplotlib 3.3. " - "Please use shade=False instead.") + raise ValueError("shade cannot be None.") colset = [] # the sampled facecolor if (rows - 1) % rstride == 0 and \ @@ -1679,15 +1662,13 @@ def plot_surface(self, X, Y, Z, *args, norm=None, vmin=None, # note that the striding causes some polygons to have more coordinates # than others - polyc = art3d.Poly3DCollection(polys, *args, **kwargs) if fcolors is not None: - if shade: - colset = self._shade_colors( - colset, self._generate_normals(polys), lightsource) - polyc.set_facecolors(colset) - polyc.set_edgecolors(colset) + polyc = art3d.Poly3DCollection( + polys, edgecolors=colset, facecolors=colset, shade=shade, + lightsource=lightsource, **kwargs) elif cmap: + polyc = art3d.Poly3DCollection(polys, **kwargs) # can't always vectorize, because polys might be jagged if isinstance(polys, np.ndarray): avg_z = polys[..., 2].mean(axis=-1) @@ -1699,99 +1680,16 @@ def plot_surface(self, X, Y, Z, *args, norm=None, vmin=None, if norm is not None: polyc.set_norm(norm) else: - if shade: - colset = self._shade_colors( - color, self._generate_normals(polys), lightsource) - else: - colset = color - polyc.set_facecolors(colset) + polyc = art3d.Poly3DCollection( + polys, facecolors=color, shade=shade, + lightsource=lightsource, **kwargs) self.add_collection(polyc) self.auto_scale_xyz(X, Y, Z, had_data) return polyc - def _generate_normals(self, polygons): - """ - Compute the normals of a list of polygons. - - Normals point towards the viewer for a face with its vertices in - counterclockwise order, following the right hand rule. - - Uses three points equally spaced around the polygon. - This normal of course might not make sense for polygons with more than - three points not lying in a plane, but it's a plausible and fast - approximation. - - Parameters - ---------- - polygons : list of (M_i, 3) array-like, or (..., M, 3) array-like - A sequence of polygons to compute normals for, which can have - varying numbers of vertices. If the polygons all have the same - number of vertices and array is passed, then the operation will - be vectorized. - - Returns - ------- - normals : (..., 3) array - A normal vector estimated for the polygon. - """ - if isinstance(polygons, np.ndarray): - # optimization: polygons all have the same number of points, so can - # vectorize - n = polygons.shape[-2] - i1, i2, i3 = 0, n//3, 2*n//3 - v1 = polygons[..., i1, :] - polygons[..., i2, :] - v2 = polygons[..., i2, :] - polygons[..., i3, :] - else: - # The subtraction doesn't vectorize because polygons is jagged. - v1 = np.empty((len(polygons), 3)) - v2 = np.empty((len(polygons), 3)) - for poly_i, ps in enumerate(polygons): - n = len(ps) - i1, i2, i3 = 0, n//3, 2*n//3 - v1[poly_i, :] = ps[i1, :] - ps[i2, :] - v2[poly_i, :] = ps[i2, :] - ps[i3, :] - return np.cross(v1, v2) - - def _shade_colors(self, color, normals, lightsource=None): - """ - Shade *color* using normal vectors given by *normals*. - *color* can also be an array of the same length as *normals*. - """ - if lightsource is None: - # chosen for backwards-compatibility - lightsource = mcolors.LightSource(azdeg=225, altdeg=19.4712) - - with np.errstate(invalid="ignore"): - shade = ((normals / np.linalg.norm(normals, axis=1, keepdims=True)) - @ lightsource.direction) - mask = ~np.isnan(shade) - - if mask.any(): - # convert dot product to allowed shading fractions - in_norm = mcolors.Normalize(-1, 1) - out_norm = mcolors.Normalize(0.3, 1).inverse - - def norm(x): - return out_norm(in_norm(x)) - - shade[~mask] = 0 - - color = mcolors.to_rgba_array(color) - # shape of color should be (M, 4) (where M is number of faces) - # shape of shade should be (M,) - # colors should have final shape of (M, 4) - alpha = color[:, 3] - colors = norm(shade)[:, np.newaxis] * color - colors[:, 3] = alpha - else: - colors = np.asanyarray(color).copy() - - return colors - - @_api.delete_parameter("3.4", "args", alternative="kwargs") - def plot_wireframe(self, X, Y, Z, *args, **kwargs): + def plot_wireframe(self, X, Y, Z, **kwargs): """ Plot a 3D wireframe. @@ -1826,7 +1724,7 @@ def plot_wireframe(self, X, Y, Z, *args, **kwargs): of the new default of ``rcount = ccount = 50``. **kwargs - Other arguments are forwarded to `.Line3DCollection`. + Other keyword arguments are forwarded to `.Line3DCollection`. """ had_data = self.has_data() @@ -1847,7 +1745,7 @@ def plot_wireframe(self, X, Y, Z, *args, **kwargs): rcount = kwargs.pop('rcount', 50) ccount = kwargs.pop('ccount', 50) - if rcParams['_internal.classic_mode']: + if mpl.rcParams['_internal.classic_mode']: # Strides have priority over counts in classic mode. # So, only compute strides from counts # if counts were explicitly given @@ -1903,7 +1801,7 @@ def plot_wireframe(self, X, Y, Z, *args, **kwargs): + [list(zip(xl, yl, zl)) for xl, yl, zl in zip(txlines, tylines, tzlines)]) - linec = art3d.Line3DCollection(lines, *args, **kwargs) + linec = art3d.Line3DCollection(lines, **kwargs) self.add_collection(linec) self.auto_scale_xyz(X, Y, Z, had_data) @@ -1926,7 +1824,7 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, plot_trisurf(X, Y, triangles=triangles, ...) in which case a Triangulation object will be created. See - `.Triangulation` for a explanation of these possibilities. + `.Triangulation` for an explanation of these possibilities. The remaining arguments are:: @@ -1953,7 +1851,7 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, lightsource : `~matplotlib.colors.LightSource` The lightsource to use when *shade* is True. **kwargs - All other arguments are passed on to + All other keyword arguments are passed on to :class:`~mpl_toolkits.mplot3d.art3d.Poly3DCollection` Examples @@ -1987,9 +1885,8 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, zt = z[triangles] verts = np.stack((xt, yt, zt), axis=-1) - polyc = art3d.Poly3DCollection(verts, *args, **kwargs) - if cmap: + polyc = art3d.Poly3DCollection(verts, *args, **kwargs) # average over the three points of each triangle avg_z = verts[:, :, 2].mean(axis=1) polyc.set_array(avg_z) @@ -1998,12 +1895,9 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, if norm is not None: polyc.set_norm(norm) else: - if shade: - normals = self._generate_normals(verts) - colset = self._shade_colors(color, normals, lightsource) - else: - colset = color - polyc.set_facecolors(colset) + polyc = art3d.Poly3DCollection( + verts, *args, shade=shade, lightsource=lightsource, + facecolors=color, **kwargs) self.add_collection(polyc) self.auto_scale_xyz(tri.x, tri.y, z, had_data) @@ -2028,8 +1922,6 @@ def _3d_extend_contour(self, cset, stride=5): color = linec.get_edgecolor()[0] - polyverts = [] - normals = [] nsteps = round(len(topverts[0]) / stride) if nsteps <= 1: if len(topverts[0]) > 1: @@ -2037,10 +1929,11 @@ def _3d_extend_contour(self, cset, stride=5): else: continue + polyverts = [] stepsize = (len(topverts[0]) - 1) / (nsteps - 1) - for i in range(int(round(nsteps)) - 1): - i1 = int(round(i * stepsize)) - i2 = int(round((i + 1) * stepsize)) + for i in range(round(nsteps) - 1): + i1 = round(i * stepsize) + i2 = round((i + 1) * stepsize) polyverts.append([topverts[0][i1], topverts[0][i2], botverts[0][i2], @@ -2048,13 +1941,10 @@ def _3d_extend_contour(self, cset, stride=5): # all polygons have 4 vertices, so vectorize polyverts = np.array(polyverts) - normals = self._generate_normals(polyverts) - - colors = self._shade_colors(color, normals) - colors2 = self._shade_colors(color, normals) polycol = art3d.Poly3DCollection(polyverts, - facecolors=colors, - edgecolors=colors2) + facecolors=color, + edgecolors=color, + shade=True) polycol.set_sort_zpos(z) self.add_collection3d(polycol) @@ -2079,7 +1969,7 @@ def _add_contourf_set(self, cset, zdir='z', offset=None): """ Returns ------- - levels : numpy.ndarray + levels : `numpy.ndarray` Levels at which the filled contours are added. """ zdir = '-' + zdir @@ -2109,8 +1999,7 @@ def contour(self, X, Y, Z, *args, Parameters ---------- X, Y, Z : array-like, - Input data. See `~matplotlib.axes.Axes.contour` for acceptable - data shapes. + Input data. See `.Axes.contour` for supported data shapes. extend3d : bool, default: False Whether to extend contour in 3D. stride : int @@ -2119,7 +2008,7 @@ def contour(self, X, Y, Z, *args, The direction to use. offset : float, optional If specified, plot a projection of the contour lines at this - position in a plane normal to zdir. + position in a plane normal to *zdir*. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -2154,8 +2043,7 @@ def tricontour(self, *args, Parameters ---------- X, Y, Z : array-like - Input data. See `~matplotlib.axes.Axes.tricontour` for acceptable - data shapes. + Input data. See `.Axes.tricontour` for supported data shapes. extend3d : bool, default: False Whether to extend contour in 3D. stride : int @@ -2164,7 +2052,7 @@ def tricontour(self, *args, The direction to use. offset : float, optional If specified, plot a projection of the contour lines at this - position in a plane normal to zdir. + position in a plane normal to *zdir*. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER *args, **kwargs @@ -2172,7 +2060,7 @@ def tricontour(self, *args, Returns ------- - matplotlib.tri.tricontour.TriContourSet + matplotlib.tri._tricontour.TriContourSet """ had_data = self.has_data() @@ -2213,13 +2101,12 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs): Parameters ---------- X, Y, Z : array-like - Input data. See `~matplotlib.axes.Axes.contourf` for acceptable - data shapes. + Input data. See `.Axes.contourf` for supported data shapes. zdir : {'x', 'y', 'z'}, default: 'z' The direction to use. offset : float, optional If specified, plot a projection of the contour lines at this - position in a plane normal to zdir. + position in a plane normal to *zdir*. data : indexable object, optional DATA_PARAMETER_PLACEHOLDER *args, **kwargs @@ -2252,8 +2139,7 @@ def tricontourf(self, *args, zdir='z', offset=None, **kwargs): Parameters ---------- X, Y, Z : array-like - Input data. See `~matplotlib.axes.Axes.tricontourf` for acceptable - data shapes. + Input data. See `.Axes.tricontourf` for supported data shapes. zdir : {'x', 'y', 'z'}, default: 'z' The direction to use. offset : float, optional @@ -2267,7 +2153,7 @@ def tricontourf(self, *args, zdir='z', offset=None, **kwargs): Returns ------- - matplotlib.tri.tricontour.TriContourSet + matplotlib.tri._tricontour.TriContourSet """ had_data = self.has_data() @@ -2366,7 +2252,7 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs - All other arguments are passed on to `~.axes.Axes.scatter`. + All other keyword arguments are passed on to `~.axes.Axes.scatter`. Returns ------- @@ -2418,7 +2304,8 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs): data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs - Other arguments are forwarded to `matplotlib.axes.Axes.bar`. + Other keyword arguments are forwarded to + `matplotlib.axes.Axes.bar`. Returns ------- @@ -2459,7 +2346,7 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, """ Generate a 3D barplot. - This method creates three dimensional barplot where the width, + This method creates three-dimensional barplot where the width, depth, height, and color of the bars can all be uniquely set. Parameters @@ -2512,8 +2399,7 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, Returns ------- collection : `~.art3d.Poly3DCollection` - A collection of three dimensional polygons representing - the bars. + A collection of three-dimensional polygons representing the bars. """ had_data = self.has_data() @@ -2603,15 +2489,11 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, if len(facecolors) < len(x): facecolors *= (6 * len(x)) - if shade: - normals = self._generate_normals(polys) - sfacecolors = self._shade_colors(facecolors, normals, lightsource) - else: - sfacecolors = facecolors - col = art3d.Poly3DCollection(polys, zsort=zsort, - facecolor=sfacecolors, + facecolors=facecolors, + shade=shade, + lightsource=lightsource, *args, **kwargs) self.add_collection(col) @@ -2627,19 +2509,16 @@ def set_title(self, label, fontdict=None, loc='center', **kwargs): return ret @_preprocess_data() - def quiver(self, *args, + def quiver(self, X, Y, Z, U, V, W, *, length=1, arrow_length_ratio=.3, pivot='tail', normalize=False, **kwargs): """ - ax.quiver(X, Y, Z, U, V, W, /, length=1, arrow_length_ratio=.3, \ -pivot='tail', normalize=False, **kwargs) - Plot a 3D field of arrows. - The arguments could be array-like or scalars, so long as they - they can be broadcast together. The arguments can also be - masked arrays. If an element in any of argument is masked, then - that corresponding quiver element will not be plotted. + The arguments can be array-like or scalars, so long as they can be + broadcast together. The arguments can also be masked arrays. If an + element in any of argument is masked, then that corresponding quiver + element will not be plotted. Parameters ---------- @@ -2669,7 +2548,7 @@ def quiver(self, *args, **kwargs Any additional keyword arguments are delegated to - :class:`~matplotlib.collections.LineCollection` + :class:`.Line3DCollection` """ def calc_arrows(UVW, angle=15): @@ -2700,22 +2579,15 @@ def calc_arrows(UVW, angle=15): had_data = self.has_data() - # handle args - argi = 6 - if len(args) < argi: - raise ValueError('Wrong number of arguments. Expected %d got %d' % - (argi, len(args))) - - # first 6 arguments are X, Y, Z, U, V, W - input_args = args[:argi] + input_args = [X, Y, Z, U, V, W] # extract the masks, if any masks = [k.mask for k in input_args if isinstance(k, np.ma.MaskedArray)] # broadcast to match the shape bcast = np.broadcast_arrays(*input_args, *masks) - input_args = bcast[:argi] - masks = bcast[argi:] + input_args = bcast[:6] + masks = bcast[6:] if masks: # combine the masks into one mask = functools.reduce(np.logical_or, masks) @@ -2727,7 +2599,7 @@ def calc_arrows(UVW, angle=15): if any(len(v) == 0 for v in input_args): # No quivers, so just make an empty collection and return early - linec = art3d.Line3DCollection([], *args[argi:], **kwargs) + linec = art3d.Line3DCollection([], **kwargs) self.add_collection(linec) return linec @@ -2741,7 +2613,7 @@ def calc_arrows(UVW, angle=15): shaft_dt -= length / 2 XYZ = np.column_stack(input_args[:3]) - UVW = np.column_stack(input_args[3:argi]).astype(float) + UVW = np.column_stack(input_args[3:]).astype(float) # Normalize rows of UVW norm = np.linalg.norm(UVW, axis=1) @@ -2770,7 +2642,7 @@ def calc_arrows(UVW, angle=15): else: lines = [] - linec = art3d.Line3DCollection(lines, *args[argi:], **kwargs) + linec = art3d.Line3DCollection(lines, **kwargs) self.add_collection(linec) self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data) @@ -2816,14 +2688,13 @@ def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, can be either a string, or a 1D rgb/rgba array - ``None``, the default, to use a single color for the faces, and the style default for the edges. - - A 3D ndarray of color names, with each item the color for the - corresponding voxel. The size must match the voxels. - - A 4D ndarray of rgb/rgba data, with the components along the - last axis. + - A 3D `~numpy.ndarray` of color names, with each item the color + for the corresponding voxel. The size must match the voxels. + - A 4D `~numpy.ndarray` of rgb/rgba data, with the components + along the last axis. shade : bool, default: True - Whether to shade the facecolors. Shading is always disabled when - *cmap* is specified. + Whether to shade the facecolors. lightsource : `~matplotlib.colors.LightSource` The lightsource to use when *shade* is True. @@ -2980,16 +2851,10 @@ def permutation_matrices(n): # shade the faces facecolor = facecolors[coord] edgecolor = edgecolors[coord] - if shade: - normals = self._generate_normals(faces) - facecolor = self._shade_colors(facecolor, normals, lightsource) - if edgecolor is not None: - edgecolor = self._shade_colors( - edgecolor, normals, lightsource - ) poly = art3d.Poly3DCollection( - faces, facecolors=facecolor, edgecolors=edgecolor, **kwargs) + faces, facecolors=facecolor, edgecolors=edgecolor, + shade=shade, lightsource=lightsource, **kwargs) self.add_collection3d(poly) polygons[coord] = poly @@ -3029,7 +2894,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', The format for the data points / data lines. See `.plot` for details. - Use 'none' (case insensitive) to plot errorbars without any data + Use 'none' (case-insensitive) to plot errorbars without any data markers. ecolor : color, default: None @@ -3061,7 +2926,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', this. *lims*-arguments may be scalars, or array-likes of the same length as the errors. To use limits with inverted axes, `~.Axes.set_xlim` or `~.Axes.set_ylim` must be called before - :meth:`errorbar`. Note the tricky parameter names: setting e.g. + `errorbar`. Note the tricky parameter names: setting e.g. *ylolims* to True means that the y-value is a *lower* limit of the True value, so, only an *upward*-pointing arrow will be drawn! @@ -3121,33 +2986,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', if not len(x) == len(y) == len(z): raise ValueError("'x', 'y', and 'z' must have the same size") - if isinstance(errorevery, Integral): - errorevery = (0, errorevery) - if isinstance(errorevery, tuple): - if (len(errorevery) == 2 and - isinstance(errorevery[0], Integral) and - isinstance(errorevery[1], Integral)): - errorevery = slice(errorevery[0], None, errorevery[1]) - else: - raise ValueError( - f'errorevery={errorevery!r} is a not a tuple of two ' - f'integers') - - elif isinstance(errorevery, slice): - pass - - elif not isinstance(errorevery, str) and np.iterable(errorevery): - # fancy indexing - try: - x[errorevery] - except (ValueError, IndexError) as err: - raise ValueError( - f"errorevery={errorevery!r} is iterable but not a valid " - f"NumPy fancy index to match " - f"'xerr'/'yerr'/'zerr'") from err - else: - raise ValueError( - f"errorevery={errorevery!r} is not a recognized value") + everymask = self._errorevery_to_mask(x, errorevery) label = kwargs.pop("label", None) kwargs['label'] = '_nolegend_' @@ -3202,16 +3041,13 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', # Make the style dict for caps (the "hats"). eb_cap_style = {**base_style, 'linestyle': 'None'} if capsize is None: - capsize = rcParams["errorbar.capsize"] + capsize = mpl.rcParams["errorbar.capsize"] if capsize > 0: eb_cap_style['markersize'] = 2. * capsize if capthick is not None: eb_cap_style['markeredgewidth'] = capthick eb_cap_style['color'] = ecolor - everymask = np.zeros(len(x), bool) - everymask[errorevery] = True - def _apply_mask(arrays, mask): # Return, for each array in *arrays*, the elements for which *mask* # is True, without using fancy indexing. @@ -3246,19 +3082,19 @@ def _extract_errs(err, data, lomask, himask): # scene is rotated, they are given a standard size based on viewing # them directly in planar form. quiversize = eb_cap_style.get('markersize', - rcParams['lines.markersize']) ** 2 + mpl.rcParams['lines.markersize']) ** 2 quiversize *= self.figure.dpi / 72 quiversize = self.transAxes.inverted().transform([ (0, 0), (quiversize, quiversize)]) quiversize = np.mean(np.diff(quiversize, axis=0)) # quiversize is now in Axes coordinates, and to convert back to data # coordinates, we need to run it through the inverse 3D transform. For - # consistency, this uses a fixed azimuth and elevation. - with cbook._setattr_cm(self, azim=0, elev=0): + # consistency, this uses a fixed elevation, azimuth, and roll. + with cbook._setattr_cm(self, elev=0, azim=0, roll=0): invM = np.linalg.inv(self.get_proj()) - # azim=elev=0 produces the Y-Z plane, so quiversize in 2D 'x' is 'y' in - # 3D, hence the 1 index. - quiversize = np.dot(invM, np.array([quiversize, 0, 0, 0]))[1] + # elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is + # 'y' in 3D, hence the 1 index. + quiversize = np.dot(invM, [quiversize, 0, 0, 0])[1] # Quivers use a fixed 15-degree arrow head, so scale up the length so # that the size corresponds to the base. In other words, this constant # corresponds to the equation tan(15) = (base / 2) / (arrow length). @@ -3348,7 +3184,7 @@ def _digout_minmax(err_arr, coord_label): return errlines, caplines, limmarks - def get_tightbbox(self, renderer, call_axes_locator=True, + def get_tightbbox(self, renderer=None, call_axes_locator=True, bbox_extra_artists=None, *, for_layout_only=False): ret = super().get_tightbbox(renderer, call_axes_locator=call_axes_locator, @@ -3356,18 +3192,12 @@ def get_tightbbox(self, renderer, call_axes_locator=True, for_layout_only=for_layout_only) batch = [ret] if self._axis3don: - for axis in self._get_axis_list(): + for axis in self._axis_map.values(): if axis.get_visible(): - try: - axis_bb = axis.get_tightbbox( - renderer, - for_layout_only=for_layout_only - ) - except TypeError: - # in case downstream library has redefined axis: - axis_bb = axis.get_tightbbox(renderer) - if axis_bb: - batch.append(axis_bb) + axis_bb = martist._get_tightbbox_for_layout_only( + axis, renderer) + if axis_bb: + batch.append(axis_bb) return mtransforms.Bbox.union(batch) @_preprocess_data() @@ -3467,7 +3297,7 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', # Determine style for stem lines. linestyle, linemarker, linecolor = _process_plot_format(linefmt) if linestyle is None: - linestyle = rcParams['lines.linestyle'] + linestyle = mpl.rcParams['lines.linestyle'] # Plot everything in required order. baseline, = self.plot(basex, basey, basefmt, zs=bottom, diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 7d6110c25a47..3d75aabb65eb 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -2,15 +2,27 @@ # Created: 23 Sep 2005 # Parts rewritten by Reinier Heeres +import inspect + import numpy as np -import matplotlib.transforms as mtransforms +import matplotlib as mpl from matplotlib import ( - artist, lines as mlines, axis as maxis, patches as mpatches, rcParams) + _api, artist, lines as mlines, axis as maxis, patches as mpatches, + transforms as mtransforms, colors as mcolors) from . import art3d, proj3d +@_api.deprecated("3.6", alternative="a vendored copy of _move_from_center") def move_from_center(coord, centers, deltas, axmask=(True, True, True)): + """ + For each coordinate where *axmask* is True, move *coord* away from + *centers* by *deltas*. + """ + return _move_from_center(coord, centers, deltas, axmask=axmask) + + +def _move_from_center(coord, centers, deltas, axmask=(True, True, True)): """ For each coordinate where *axmask* is True, move *coord* away from *centers* by *deltas*. @@ -19,8 +31,14 @@ def move_from_center(coord, centers, deltas, axmask=(True, True, True)): return coord + axmask * np.copysign(1, coord - centers) * deltas +@_api.deprecated("3.6", alternative="a vendored copy of _tick_update_position") def tick_update_position(tick, tickxs, tickys, labelpos): """Update tick line and label position and style.""" + _tick_update_position(tick, tickxs, tickys, labelpos) + + +def _tick_update_position(tick, tickxs, tickys, labelpos): + """Update tick line and label position and style.""" tick.label1.set_position(labelpos) tick.label2.set_position(labelpos) @@ -29,47 +47,66 @@ def tick_update_position(tick, tickxs, tickys, labelpos): tick.tick1line.set_linestyle('-') tick.tick1line.set_marker('') tick.tick1line.set_data(tickxs, tickys) - tick.gridline.set_data(0, 0) + tick.gridline.set_data([0], [0]) class Axis(maxis.XAxis): """An Axis class for the 3D plots.""" # These points from the unit cube make up the x, y and z-planes _PLANES = ( - (0, 3, 7, 4), (1, 2, 6, 5), # yz planes - (0, 1, 5, 4), (3, 2, 6, 7), # xz planes - (0, 1, 2, 3), (4, 5, 6, 7), # xy planes + (0, 3, 7, 4), (1, 2, 6, 5), # yz planes + (0, 1, 5, 4), (3, 2, 6, 7), # xz planes + (0, 1, 2, 3), (4, 5, 6, 7), # xy planes ) # Some properties for the axes _AXINFO = { - 'x': {'i': 0, 'tickdir': 1, 'juggled': (1, 0, 2), - 'color': (0.95, 0.95, 0.95, 0.5)}, - 'y': {'i': 1, 'tickdir': 0, 'juggled': (0, 1, 2), - 'color': (0.90, 0.90, 0.90, 0.5)}, - 'z': {'i': 2, 'tickdir': 0, 'juggled': (0, 2, 1), - 'color': (0.925, 0.925, 0.925, 0.5)}, + 'x': {'i': 0, 'tickdir': 1, 'juggled': (1, 0, 2)}, + 'y': {'i': 1, 'tickdir': 0, 'juggled': (0, 1, 2)}, + 'z': {'i': 2, 'tickdir': 0, 'juggled': (0, 2, 1)}, } - def __init__(self, adir, v_intervalx, d_intervalx, axes, *args, - rotate_label=None, **kwargs): - # adir identifies which axes this is - self.adir = adir + def _old_init(self, adir, v_intervalx, d_intervalx, axes, *args, + rotate_label=None, **kwargs): + return locals() + + def _new_init(self, axes, *, rotate_label=None, **kwargs): + return locals() + + def __init__(self, *args, **kwargs): + params = _api.select_matching_signature( + [self._old_init, self._new_init], *args, **kwargs) + if "adir" in params: + _api.warn_deprecated( + "3.6", message=f"The signature of 3D Axis constructors has " + f"changed in %(since)s; the new signature is " + f"{inspect.signature(type(self).__init__)}", pending=True) + if params["adir"] != self.axis_name: + raise ValueError(f"Cannot instantiate {type(self).__name__} " + f"with adir={params['adir']!r}") + axes = params["axes"] + rotate_label = params["rotate_label"] + args = params.get("args", ()) + kwargs = params["kwargs"] + + name = self.axis_name # This is a temporary member variable. # Do not depend on this existing in future releases! - self._axinfo = self._AXINFO[adir].copy() - if rcParams['_internal.classic_mode']: + self._axinfo = self._AXINFO[name].copy() + # Common parts + self._axinfo.update({ + 'label': {'va': 'center', 'ha': 'center', + 'rotation_mode': 'anchor'}, + 'color': mpl.rcParams[f'axes3d.{name}axis.panecolor'], + 'tick': { + 'inward_factor': 0.2, + 'outward_factor': 0.1, + }, + }) + + if mpl.rcParams['_internal.classic_mode']: self._axinfo.update({ - 'label': {'va': 'center', 'ha': 'center'}, - 'tick': { - 'inward_factor': 0.2, - 'outward_factor': 0.1, - 'linewidth': { - True: rcParams['lines.linewidth'], # major - False: rcParams['lines.linewidth'], # minor - } - }, 'axisline': {'linewidth': 0.75, 'color': (0, 0, 0, 1)}, 'grid': { 'color': (0.9, 0.9, 0.9, 1), @@ -77,40 +114,50 @@ def __init__(self, adir, v_intervalx, d_intervalx, axes, *args, 'linestyle': '-', }, }) + self._axinfo['tick'].update({ + 'linewidth': { + True: mpl.rcParams['lines.linewidth'], # major + False: mpl.rcParams['lines.linewidth'], # minor + } + }) else: self._axinfo.update({ - 'label': {'va': 'center', 'ha': 'center'}, - 'tick': { - 'inward_factor': 0.2, - 'outward_factor': 0.1, - 'linewidth': { - True: ( # major - rcParams['xtick.major.width'] if adir in 'xz' else - rcParams['ytick.major.width']), - False: ( # minor - rcParams['xtick.minor.width'] if adir in 'xz' else - rcParams['ytick.minor.width']), - } - }, 'axisline': { - 'linewidth': rcParams['axes.linewidth'], - 'color': rcParams['axes.edgecolor'], + 'linewidth': mpl.rcParams['axes.linewidth'], + 'color': mpl.rcParams['axes.edgecolor'], }, 'grid': { - 'color': rcParams['grid.color'], - 'linewidth': rcParams['grid.linewidth'], - 'linestyle': rcParams['grid.linestyle'], + 'color': mpl.rcParams['grid.color'], + 'linewidth': mpl.rcParams['grid.linewidth'], + 'linestyle': mpl.rcParams['grid.linestyle'], }, }) + self._axinfo['tick'].update({ + 'linewidth': { + True: ( # major + mpl.rcParams['xtick.major.width'] if name in 'xz' + else mpl.rcParams['ytick.major.width']), + False: ( # minor + mpl.rcParams['xtick.minor.width'] if name in 'xz' + else mpl.rcParams['ytick.minor.width']), + } + }) super().__init__(axes, *args, **kwargs) # data and viewing intervals for this direction - self.d_interval = d_intervalx - self.v_interval = v_intervalx + if "d_intervalx" in params: + self.set_data_interval(*params["d_intervalx"]) + if "v_intervalx" in params: + self.set_view_interval(*params["v_intervalx"]) self.set_rotate_label(rotate_label) + self._init3d() # Inline after init3d deprecation elapses. - def init3d(self): + __init__.__signature__ = inspect.signature(_new_init) + adir = _api.deprecated("3.6", pending=True)( + property(lambda self: self.axis_name)) + + def _init3d(self): self.line = mlines.Line2D( xdata=(0, 0), ydata=(0, 0), linewidth=self._axinfo['axisline']['linewidth'], @@ -118,9 +165,7 @@ def init3d(self): antialiased=True) # Store dummy data in Polygon object - self.pane = mpatches.Polygon( - np.array([[0, 0], [0, 1], [1, 0], [0, 0]]), - closed=False, alpha=0.8, facecolor='k', edgecolor='k') + self.pane = mpatches.Polygon([[0, 0], [0, 1]], closed=False) self.set_pane_color(self._axinfo['color']) self.axes._set_artist_props(self.line) @@ -133,6 +178,10 @@ def init3d(self): self.label._transform = self.axes.transData self.offsetText._transform = self.axes.transData + @_api.deprecated("3.6", pending=True) + def init3d(self): # After deprecation elapses, inline _init3d to __init__. + self._init3d() + def get_major_ticks(self, numticks=None): ticks = super().get_major_ticks(numticks) for t in ticks: @@ -149,14 +198,29 @@ def get_minor_ticks(self, numticks=None): obj.set_transform(self.axes.transData) return ticks + @_api.deprecated("3.6") def set_pane_pos(self, xys): + """Set pane position.""" + self._set_pane_pos(xys) + + def _set_pane_pos(self, xys): xys = np.asarray(xys) xys = xys[:, :2] self.pane.xy = xys self.stale = True - def set_pane_color(self, color): - """Set pane color to a RGBA tuple.""" + def set_pane_color(self, color, alpha=None): + """ + Set pane color. + + Parameters + ---------- + color : color + Color for axis pane. + alpha : float, optional + Alpha value for axis pane. If None, base it on *color*. + """ + color = mcolors.to_rgba(color, alpha) self._axinfo['color'] = color self.pane.set_edgecolor(color) self.pane.set_facecolor(color) @@ -195,14 +259,26 @@ def _get_coord_info(self, renderer): # Project the bounds along the current position of the cube: bounds = mins[0], maxs[0], mins[1], maxs[1], mins[2], maxs[2] - bounds_proj = self.axes.tunit_cube(bounds, self.axes.M) + bounds_proj = self.axes._tunit_cube(bounds, self.axes.M) # Determine which one of the parallel planes are higher up: - highs = np.zeros(3, dtype=bool) + means_z0 = np.zeros(3) + means_z1 = np.zeros(3) for i in range(3): - mean_z0 = np.mean(bounds_proj[self._PLANES[2 * i], 2]) - mean_z1 = np.mean(bounds_proj[self._PLANES[2 * i + 1], 2]) - highs[i] = mean_z0 < mean_z1 + means_z0[i] = np.mean(bounds_proj[self._PLANES[2 * i], 2]) + means_z1[i] = np.mean(bounds_proj[self._PLANES[2 * i + 1], 2]) + highs = means_z0 < means_z1 + + # Special handling for edge-on views + equals = np.abs(means_z0 - means_z1) <= np.finfo(float).eps + if np.sum(equals) == 2: + vertical = np.where(~equals)[0][0] + if vertical == 2: # looking at XY plane + highs = np.array([True, True, highs[2]]) + elif vertical == 1: # looking at XZ plane + highs = np.array([True, highs[1], False]) + elif vertical == 0: # looking at YZ plane + highs = np.array([highs[0], False, False]) return mins, maxs, centers, deltas, bounds_proj, highs @@ -246,6 +322,13 @@ def _get_tickdir(self): return tickdir def draw_pane(self, renderer): + """ + Draw pane. + + Parameters + ---------- + renderer : `~matplotlib.backend_bases.RendererBase` subclass + """ renderer.open_group('pane3d', gid=self.get_gid()) mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) @@ -257,7 +340,7 @@ def draw_pane(self, renderer): else: plane = self._PLANES[2 * index + 1] xys = [tc[p] for p in plane] - self.set_pane_pos(xys) + self._set_pane_pos(xys) self.pane.draw(renderer) renderer.close_group('pane3d') @@ -289,10 +372,6 @@ def draw(self, renderer): self.line.set_data(pep[0], pep[1]) self.line.draw(renderer) - # Grid points where the planes meet - xyz0 = np.tile(minmax, (len(ticks), 1)) - xyz0[:, index] = [tick.get_loc() for tick in ticks] - # Draw labels # The transAxes transform is used because the Text object # rotates the text relative to the display coordinate system. @@ -316,7 +395,7 @@ def draw(self, renderer): (self.labelpad + default_offset) * deltas_per_point * deltas) axmask = [True, True, True] axmask[index] = False - lxyz = move_from_center(lxyz, centers, labeldeltas, axmask) + lxyz = _move_from_center(lxyz, centers, labeldeltas, axmask) tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M) self.label.set_position((tlx, tly)) if self.get_rotate_label(self.label.get_text()): @@ -324,6 +403,7 @@ def draw(self, renderer): self.label.set_rotation(angle) self.label.set_va(info['label']['va']) self.label.set_ha(info['label']['ha']) + self.label.set_rotation_mode(info['label']['rotation_mode']) self.label.draw(renderer) # Draw Offset text @@ -337,7 +417,7 @@ def draw(self, renderer): outeredgep = edgep2 outerindex = 1 - pos = move_from_center(outeredgep, centers, labeldeltas, axmask) + pos = _move_from_center(outeredgep, centers, labeldeltas, axmask) olx, oly, olz = proj3d.proj_transform(*pos, self.axes.M) self.offsetText.set_text(self.major.formatter.get_offset()) self.offsetText.set_position((olx, oly)) @@ -381,10 +461,7 @@ def draw(self, renderer): if (centpt[index] > pep[index, outerindex] and np.count_nonzero(highs) % 2 == 0): # Usually mean align left, except if it is axis 2 - if index == 2: - align = 'right' - else: - align = 'left' + align = 'right' if index == 2 else 'left' else: # The TT case align = 'right' @@ -394,6 +471,10 @@ def draw(self, renderer): self.offsetText.draw(renderer) if self.axes._draw_grid and len(ticks): + # Grid points where the planes meet + xyz0 = np.tile(minmax, (len(ticks), 1)) + xyz0[:, index] = [tick.get_loc() for tick in ticks] + # Grid lines go from the end of one plane through the plane # intersection (at xyz0) to the end of the other plane. The first # point (0) differs along dimension index-2 and the last (2) along @@ -402,47 +483,45 @@ def draw(self, renderer): lines[:, 0, index - 2] = maxmin[index - 2] lines[:, 2, index - 1] = maxmin[index - 1] self.gridlines.set_segments(lines) - self.gridlines.set_color(info['grid']['color']) - self.gridlines.set_linewidth(info['grid']['linewidth']) - self.gridlines.set_linestyle(info['grid']['linestyle']) + gridinfo = info['grid'] + self.gridlines.set_color(gridinfo['color']) + self.gridlines.set_linewidth(gridinfo['linewidth']) + self.gridlines.set_linestyle(gridinfo['linestyle']) self.gridlines.do_3d_projection() self.gridlines.draw(renderer) # Draw ticks: tickdir = self._get_tickdir() - tickdelta = deltas[tickdir] - if highs[tickdir]: - ticksign = 1 - else: - ticksign = -1 - + tickdelta = deltas[tickdir] if highs[tickdir] else -deltas[tickdir] + + tick_info = info['tick'] + tick_out = tick_info['outward_factor'] * tickdelta + tick_in = tick_info['inward_factor'] * tickdelta + tick_lw = tick_info['linewidth'] + edgep1_tickdir = edgep1[tickdir] + out_tickdir = edgep1_tickdir + tick_out + in_tickdir = edgep1_tickdir - tick_in + + default_label_offset = 8. # A rough estimate + points = deltas_per_point * deltas for tick in ticks: # Get tick line positions pos = edgep1.copy() pos[index] = tick.get_loc() - pos[tickdir] = ( - edgep1[tickdir] - + info['tick']['outward_factor'] * ticksign * tickdelta) + pos[tickdir] = out_tickdir x1, y1, z1 = proj3d.proj_transform(*pos, self.axes.M) - pos[tickdir] = ( - edgep1[tickdir] - - info['tick']['inward_factor'] * ticksign * tickdelta) + pos[tickdir] = in_tickdir x2, y2, z2 = proj3d.proj_transform(*pos, self.axes.M) # Get position of label - default_offset = 8. # A rough estimate - labeldeltas = ( - (tick.get_pad() + default_offset) * deltas_per_point * deltas) - - axmask = [True, True, True] - axmask[index] = False - pos[tickdir] = edgep1[tickdir] - pos = move_from_center(pos, centers, labeldeltas, axmask) + labeldeltas = (tick.get_pad() + default_label_offset) * points + + pos[tickdir] = edgep1_tickdir + pos = _move_from_center(pos, centers, labeldeltas, axmask) lx, ly, lz = proj3d.proj_transform(*pos, self.axes.M) - tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly)) - tick.tick1line.set_linewidth( - info['tick']['linewidth'][tick._major]) + _tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly)) + tick.tick1line.set_linewidth(tick_lw[tick._major]) tick.draw(renderer) renderer.close_group('axis3d') @@ -450,7 +529,7 @@ def draw(self, renderer): # TODO: Get this to work (more) properly when mplot3d supports the # transforms framework. - def get_tightbbox(self, renderer, *, for_layout_only=False): + def get_tightbbox(self, renderer=None, *, for_layout_only=False): # docstring inherited if not self.get_visible(): return @@ -485,7 +564,7 @@ def get_tightbbox(self, renderer, *, for_layout_only=False): ticks = ticks_to_draw - bb_1, bb_2 = self._get_tick_bboxes(ticks, renderer) + bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer) other = [] if self.line.get_visible(): @@ -496,27 +575,18 @@ def get_tightbbox(self, renderer, *, for_layout_only=False): return mtransforms.Bbox.union([*bb_1, *bb_2, *other]) - @property - def d_interval(self): - return self.get_data_interval() - - @d_interval.setter - def d_interval(self, minmax): - self.set_data_interval(*minmax) - - @property - def v_interval(self): - return self.get_view_interval() - - @v_interval.setter - def v_interval(self, minmax): - self.set_view_interval(*minmax) - - -# Use classes to look at different data limits + d_interval = _api.deprecated( + "3.6", alternative="get_data_interval", pending=True)( + property(lambda self: self.get_data_interval(), + lambda self, minmax: self.set_data_interval(*minmax))) + v_interval = _api.deprecated( + "3.6", alternative="get_view_interval", pending=True)( + property(lambda self: self.get_view_interval(), + lambda self, minmax: self.set_view_interval(*minmax))) class XAxis(Axis): + axis_name = "x" get_view_interval, set_view_interval = maxis._make_getset_interval( "view", "xy_viewLim", "intervalx") get_data_interval, set_data_interval = maxis._make_getset_interval( @@ -524,6 +594,7 @@ class XAxis(Axis): class YAxis(Axis): + axis_name = "y" get_view_interval, set_view_interval = maxis._make_getset_interval( "view", "xy_viewLim", "intervaly") get_data_interval, set_data_interval = maxis._make_getset_interval( @@ -531,6 +602,7 @@ class YAxis(Axis): class ZAxis(Axis): + axis_name = "z" get_view_interval, set_view_interval = maxis._make_getset_interval( "view", "zz_viewLim", "intervalx") get_data_interval, set_data_interval = maxis._make_getset_interval( diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index c9d5a7d65038..646a19781e40 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -6,31 +6,27 @@ import numpy.linalg as linalg -def _line2d_seg_dist(p1, p2, p0): +def _line2d_seg_dist(p, s0, s1): """ - Return the distance(s) from line defined by p1 - p2 to point(s) p0. - - p0[0] = x(s) - p0[1] = y(s) - - intersection point p = p1 + u*(p2-p1) - and intersection point lies within segment if u is between 0 and 1. - - If p1 and p2 are identical, the distance between them and p0 is returned. + Return the distance(s) from point(s) *p* to segment(s) (*s0*, *s1*). + + Parameters + ---------- + p : (ndim,) or (N, ndim) array-like + The points from which the distances are computed. + s0, s1 : (ndim,) or (N, ndim) array-like + The xy(z...) coordinates of the segment endpoints. """ - - x01 = np.asarray(p0[0]) - p1[0] - y01 = np.asarray(p0[1]) - p1[1] - if np.all(p1 == p2): - return np.hypot(x01, y01) - - x21 = p2[0] - p1[0] - y21 = p2[1] - p1[1] - u = (x01*x21 + y01*y21) / (x21**2 + y21**2) - u = np.clip(u, 0, 1) - d = np.hypot(x01 - u*x21, y01 - u*y21) - - return d + s0 = np.asarray(s0) + s01 = s1 - s0 # shape (ndim,) or (N, ndim) + s0p = p - s0 # shape (ndim,) or (N, ndim) + l2 = s01 @ s01 # squared segment length + # Avoid div. by zero for degenerate segments (for them, s01 = (0, 0, ...) + # so the value of l2 doesn't matter; this just replaces 0/0 by 0/1). + l2 = np.where(l2, l2, 1) + # Project onto segment, without going past segment ends. + p1 = s0 + np.multiply.outer(np.clip(s0p @ s01 / l2, 0, 1), s01) + return ((p - p1) ** 2).sum(axis=-1) ** (1/2) def world_transformation(xmin, xmax, @@ -55,55 +51,126 @@ def world_transformation(xmin, xmax, [0, 0, 0, 1]]) -def view_transformation(E, R, V): - n = (E - R) - ## new -# n /= np.linalg.norm(n) -# u = np.cross(V, n) -# u /= np.linalg.norm(u) -# v = np.cross(n, u) -# Mr = np.diag([1.] * 4) -# Mt = np.diag([1.] * 4) -# Mr[:3,:3] = u, v, n -# Mt[:3,-1] = -E - ## end new - - ## old - n = n / np.linalg.norm(n) - u = np.cross(V, n) - u = u / np.linalg.norm(u) - v = np.cross(n, u) - Mr = [[u[0], u[1], u[2], 0], - [v[0], v[1], v[2], 0], - [n[0], n[1], n[2], 0], - [0, 0, 0, 1]] - # - Mt = [[1, 0, 0, -E[0]], - [0, 1, 0, -E[1]], - [0, 0, 1, -E[2]], - [0, 0, 0, 1]] - ## end old - - return np.dot(Mr, Mt) - - -def persp_transformation(zfront, zback): - a = (zfront+zback)/(zfront-zback) - b = -2*(zfront*zback)/(zfront-zback) - return np.array([[1, 0, 0, 0], - [0, 1, 0, 0], - [0, 0, a, b], - [0, 0, -1, 0]]) +def rotation_about_vector(v, angle): + """ + Produce a rotation matrix for an angle in radians about a vector. + """ + vx, vy, vz = v / np.linalg.norm(v) + s = np.sin(angle) + c = np.cos(angle) + t = 2*np.sin(angle/2)**2 # more numerically stable than t = 1-c + + R = np.array([ + [t*vx*vx + c, t*vx*vy - vz*s, t*vx*vz + vy*s], + [t*vy*vx + vz*s, t*vy*vy + c, t*vy*vz - vx*s], + [t*vz*vx - vy*s, t*vz*vy + vx*s, t*vz*vz + c]]) + + return R + + +def _view_axes(E, R, V, roll): + """ + Get the unit viewing axes in data coordinates. + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + + Returns + ------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + """ + w = (E - R) + w = w/np.linalg.norm(w) + u = np.cross(V, w) + u = u/np.linalg.norm(u) + v = np.cross(w, u) # Will be a unit vector + + # Save some computation for the default roll=0 + if roll != 0: + # A positive rotation of the camera is a negative rotation of the world + Rroll = rotation_about_vector(w, -roll) + u = np.dot(Rroll, u) + v = np.dot(Rroll, v) + return u, v, w + + +def _view_transformation_uvw(u, v, w, E): + """ + Return the view transformation matrix. + + Parameters + ---------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + E : 3-element numpy array + The coordinates of the eye/camera. + """ + Mr = np.eye(4) + Mt = np.eye(4) + Mr[:3, :3] = [u, v, w] + Mt[:3, -1] = -E + M = np.dot(Mr, Mt) + return M + + +def view_transformation(E, R, V, roll): + """ + Return the view transformation matrix. + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + """ + u, v, w = _view_axes(E, R, V, roll) + M = _view_transformation_uvw(u, v, w, E) + return M + + +def persp_transformation(zfront, zback, focal_length): + e = focal_length + a = 1 # aspect ratio + b = (zfront+zback)/(zfront-zback) + c = -2*(zfront*zback)/(zfront-zback) + proj_matrix = np.array([[e, 0, 0, 0], + [0, e/a, 0, 0], + [0, 0, b, c], + [0, 0, -1, 0]]) + return proj_matrix def ortho_transformation(zfront, zback): # note: w component in the resulting vector will be (zback-zfront), not 1 a = -(zfront + zback) b = -(zfront - zback) - return np.array([[2, 0, 0, 0], - [0, 2, 0, 0], - [0, 0, -2, 0], - [0, 0, a, b]]) + proj_matrix = np.array([[2, 0, 0, 0], + [0, 2, 0, 0], + [0, 0, -2, 0], + [0, 0, a, b]]) + return proj_matrix def _proj_transform_vec(vec, M): @@ -126,6 +193,9 @@ def _proj_transform_vec_clip(vec, M): def inv_transform(xs, ys, zs, M): + """ + Transform the points by the inverse of the projection matrix *M*. + """ iM = linalg.inv(M) vec = _vec_pad_ones(xs, ys, zs) vecr = np.dot(iM, vec) @@ -142,7 +212,7 @@ def _vec_pad_ones(xs, ys, zs): def proj_transform(xs, ys, zs, M): """ - Transform the points by the projection matrix + Transform the points by the projection matrix *M*. """ vec = _vec_pad_ones(xs, ys, zs) return _proj_transform_vec(vec, M) diff --git a/lib/mpl_toolkits/mplot3d/tests/__init__.py b/lib/mpl_toolkits/mplot3d/tests/__init__.py new file mode 100644 index 000000000000..5b6390f4fe26 --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/tests/__init__.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +# Check that the test directories exist +if not (Path(__file__).parent / "baseline_images").exists(): + raise IOError( + 'The baseline image directory does not exist. ' + 'This is most likely because the test data is not installed. ' + 'You may need to install matplotlib from source to get the ' + 'test data.') diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/add_collection3d_zs_array.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/add_collection3d_zs_array.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/add_collection3d_zs_array.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/add_collection3d_zs_array.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/add_collection3d_zs_scalar.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/add_collection3d_zs_scalar.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/add_collection3d_zs_scalar.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/add_collection3d_zs_scalar.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/arc_pathpatch.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/arc_pathpatch.png new file mode 100644 index 000000000000..66e6a9acb986 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/arc_pathpatch.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/aspects.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/aspects.png new file mode 100644 index 000000000000..3bb088e2d131 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/aspects.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/aspects_adjust_box.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/aspects_adjust_box.png new file mode 100644 index 000000000000..7fb448f2c51d Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/aspects_adjust_box.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_cla.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_cla.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_cla.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_cla.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_focal_length.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_focal_length.png new file mode 100644 index 000000000000..1d61e0a0c0f6 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_focal_length.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_isometric.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_isometric.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_isometric.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_isometric.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_labelpad.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_labelpad.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_labelpad.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_labelpad.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_ortho.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_ortho.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_ortho.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_ortho.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png new file mode 100644 index 000000000000..025156f34d39 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_primary_views.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_rotated.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_rotated.png new file mode 100644 index 000000000000..9e7193d6b326 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/axes3d_rotated.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_notshaded.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_notshaded.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_notshaded.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_notshaded.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_shaded.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_shaded.png new file mode 100644 index 000000000000..39dc9997cb1d Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_shaded.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/computed_zorder.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/computed_zorder.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/computed_zorder.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/computed_zorder.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/contour3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contour3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/contour3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contour3d.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contour3d_extend3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contour3d_extend3d.png new file mode 100644 index 000000000000..061d4add9e47 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contour3d_extend3d.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/contourf3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contourf3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/contourf3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contourf3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/contourf3d_fill.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contourf3d_fill.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/contourf3d_fill.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/contourf3d_fill.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/equal_box_aspect.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/equal_box_aspect.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/equal_box_aspect.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/equal_box_aspect.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/errorbar3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/errorbar3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d_errorevery.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/errorbar3d_errorevery.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/errorbar3d_errorevery.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/errorbar3d_errorevery.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/lines3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/lines3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/lines3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/lines3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/minor_ticks.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/minor_ticks.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/minor_ticks.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/minor_ticks.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/mixedsubplot.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/mixedsubplot.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/mixedsubplot.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/mixedsubplot.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/panecolor_rcparams.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/panecolor_rcparams.png new file mode 100644 index 000000000000..e8e2ac6dcd5a Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/panecolor_rcparams.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/plot_3d_from_2d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/plot_3d_from_2d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/plot_3d_from_2d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/plot_3d_from_2d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/poly3dcollection_alpha.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/poly3dcollection_alpha.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/poly3dcollection_alpha.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/poly3dcollection_alpha.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/poly3dcollection_closed.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/poly3dcollection_closed.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/poly3dcollection_closed.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/poly3dcollection_closed.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/proj3d_axes_cube.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_axes_cube.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/proj3d_axes_cube.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_axes_cube.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/proj3d_axes_cube_ortho.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_axes_cube_ortho.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/proj3d_axes_cube_ortho.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_axes_cube_ortho.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/proj3d_lines_dists.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_lines_dists.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/proj3d_lines_dists.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_lines_dists.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d_masked.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d_masked.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d_masked.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d_masked.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d_pivot_middle.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d_pivot_middle.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d_pivot_middle.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d_pivot_middle.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d_pivot_tail.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d_pivot_tail.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/quiver3d_pivot_tail.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d_pivot_tail.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/scatter3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/scatter3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/scatter3d_color.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_color.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/scatter3d_color.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_color.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png new file mode 100644 index 000000000000..c4c07dd9e8d6 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/scatter_spiral.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter_spiral.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/scatter_spiral.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter_spiral.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/stem3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/stem3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/stem3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/stem3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d_masked.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d_masked.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d_masked_strides.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked_strides.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d_masked_strides.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked_strides.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d_shaded.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_shaded.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/surface3d_shaded.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_shaded.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/text3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/text3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/text3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/text3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/tricontour.png similarity index 99% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/tricontour.png index c05dcac7d25e..4387737e8115 100644 Binary files a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/tricontour.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/tricontour.png differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/trisurf3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/trisurf3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/trisurf3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/trisurf3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/trisurf3d_shaded.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/trisurf3d_shaded.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/trisurf3d_shaded.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/trisurf3d_shaded.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-alpha.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-alpha.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-alpha.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-alpha.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-edge-style.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-edge-style.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-edge-style.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-edge-style.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-named-colors.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-named-colors.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-named-colors.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-named-colors.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-rgb-data.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-rgb-data.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-rgb-data.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-rgb-data.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-simple.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-simple.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-simple.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-simple.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-xyz.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-xyz.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/voxels-xyz.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/voxels-xyz.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/wireframe3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3d.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/wireframe3d.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3d.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/wireframe3dzerocstride.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerocstride.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/wireframe3dzerocstride.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerocstride.png diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/wireframe3dzerorstride.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerorstride.png similarity index 100% rename from lib/mpl_toolkits/tests/baseline_images/test_mplot3d/wireframe3dzerorstride.png rename to lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerorstride.png diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png new file mode 100644 index 000000000000..d53e297e9df8 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/legend_bar.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/legend_bar.png new file mode 100644 index 000000000000..3502ddb7653f Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/legend_bar.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/legend_plot.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/legend_plot.png new file mode 100644 index 000000000000..159430af8d20 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/legend_plot.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/conftest.py b/lib/mpl_toolkits/mplot3d/tests/conftest.py new file mode 100644 index 000000000000..61c2de3e07ba --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/tests/conftest.py @@ -0,0 +1,2 @@ +from matplotlib.testing.conftest import (mpl_test_settings, # noqa + pytest_configure, pytest_unconfigure) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py new file mode 100644 index 000000000000..02d35aad0e4b --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py @@ -0,0 +1,38 @@ +import matplotlib.pyplot as plt + +from matplotlib.backend_bases import MouseEvent + + +def test_scatter_3d_projection_conservation(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + # fix axes3d projection + ax.roll = 0 + ax.elev = 0 + ax.azim = -45 + ax.stale = True + + x = [0, 1, 2, 3, 4] + scatter_collection = ax.scatter(x, x, x) + fig.canvas.draw_idle() + + # Get scatter location on canvas and freeze the data + scatter_offset = scatter_collection.get_offsets() + scatter_location = ax.transData.transform(scatter_offset) + + # Yaw -44 and -46 are enough to produce two set of scatter + # with opposite z-order without moving points too far + for azim in (-44, -46): + ax.azim = azim + ax.stale = True + fig.canvas.draw_idle() + + for i in range(5): + # Create a mouse event used to locate and to get index + # from each dots + event = MouseEvent("button_press_event", fig.canvas, + *scatter_location[i, :]) + contains, ind = scatter_collection.contains(event) + assert contains is True + assert len(ind["ind"]) == 1 + assert ind["ind"][0] == i diff --git a/lib/mpl_toolkits/tests/test_mplot3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py similarity index 75% rename from lib/mpl_toolkits/tests/test_mplot3d.py rename to lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index ebf3e15cf124..2ff7d428291c 100644 --- a/lib/mpl_toolkits/tests/test_mplot3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -5,14 +5,16 @@ from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d import matplotlib as mpl -from matplotlib.backend_bases import MouseButton -from matplotlib.cbook import MatplotlibDeprecationWarning +from matplotlib.backend_bases import (MouseButton, MouseEvent, + NavigationToolbar2) from matplotlib import cm -from matplotlib import colors as mcolors +from matplotlib import colors as mcolors, patches as mpatch from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.testing.widgets import mock_event from matplotlib.collections import LineCollection, PolyCollection -from matplotlib.patches import Circle +from matplotlib.patches import Circle, PathPatch +from matplotlib.path import Path +from matplotlib.text import Text import matplotlib.pyplot as plt import numpy as np @@ -22,11 +24,80 @@ image_comparison, remove_text=True, style='default') -def test_aspect_equal_error(): +@check_figures_equal(extensions=["png"]) +def test_invisible_axes(fig_test, fig_ref): + ax = fig_test.subplots(subplot_kw=dict(projection='3d')) + ax.set_visible(False) + + +@mpl3d_image_comparison(['aspects.png'], remove_text=False) +def test_aspects(): + aspects = ('auto', 'equal', 'equalxy', 'equalyz', 'equalxz') + fig, axs = plt.subplots(1, len(aspects), subplot_kw={'projection': '3d'}) + + # Draw rectangular cuboid with side lengths [1, 1, 5] + r = [0, 1] + scale = np.array([1, 1, 5]) + pts = itertools.combinations(np.array(list(itertools.product(r, r, r))), 2) + for start, end in pts: + if np.sum(np.abs(start - end)) == r[1] - r[0]: + for ax in axs: + ax.plot3D(*zip(start*scale, end*scale)) + for i, ax in enumerate(axs): + ax.set_box_aspect((3, 4, 5)) + ax.set_aspect(aspects[i], adjustable='datalim') + + +@mpl3d_image_comparison(['aspects_adjust_box.png'], remove_text=False) +def test_aspects_adjust_box(): + aspects = ('auto', 'equal', 'equalxy', 'equalyz', 'equalxz') + fig, axs = plt.subplots(1, len(aspects), subplot_kw={'projection': '3d'}, + figsize=(11, 3)) + + # Draw rectangular cuboid with side lengths [4, 3, 5] + r = [0, 1] + scale = np.array([4, 3, 5]) + pts = itertools.combinations(np.array(list(itertools.product(r, r, r))), 2) + for start, end in pts: + if np.sum(np.abs(start - end)) == r[1] - r[0]: + for ax in axs: + ax.plot3D(*zip(start*scale, end*scale)) + for i, ax in enumerate(axs): + ax.set_aspect(aspects[i], adjustable='box') + + +def test_axes3d_repr(): fig = plt.figure() ax = fig.add_subplot(projection='3d') - with pytest.raises(NotImplementedError): - ax.set_aspect('equal') + ax.set_label('label') + ax.set_title('title') + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + assert repr(ax) == ( + "") + + +@mpl3d_image_comparison(['axes3d_primary_views.png']) +def test_axes3d_primary_views(): + # (elev, azim, roll) + views = [(90, -90, 0), # XY + (0, -90, 0), # XZ + (0, 0, 0), # YZ + (-90, 90, 0), # -XY + (0, 90, 0), # -XZ + (0, 180, 0)] # -YZ + # When viewing primary planes, draw the two visible axes so they intersect + # at their low values + fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}) + for i, ax in enumerate(axs.flat): + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + ax.set_proj_type('ortho') + ax.view_init(elev=views[i][0], azim=views[i][1], roll=views[i][2]) + plt.tight_layout() @mpl3d_image_comparison(['bar3d.png']) @@ -61,15 +132,15 @@ def test_bar3d_shaded(): x2d, y2d = x2d.ravel(), y2d.ravel() z = x2d + y2d + 1 # Avoid triggering bug with zero-depth boxes. - views = [(-60, 30), (30, 30), (30, -30), (120, -30)] + views = [(30, -60, 0), (30, 30, 30), (-30, 30, -90), (300, -30, 0)] fig = plt.figure(figsize=plt.figaspect(1 / len(views))) axs = fig.subplots( 1, len(views), subplot_kw=dict(projection='3d') ) - for ax, (azim, elev) in zip(axs, views): + for ax, (elev, azim, roll) in zip(axs, views): ax.bar3d(x2d, y2d, x2d * 0, 1, 1, z, shade=True) - ax.view_init(azim=azim, elev=elev) + ax.view_init(elev=elev, azim=azim, roll=roll) fig.canvas.draw() @@ -126,6 +197,17 @@ def test_contour3d(): ax.set_zlim(-100, 100) +@mpl3d_image_comparison(['contour3d_extend3d.png']) +def test_contour3d_extend3d(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.contour(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm, extend3d=True) + ax.set_xlim(-30, 30) + ax.set_ylim(-20, 40) + ax.set_zlim(-80, 80) + + @mpl3d_image_comparison(['contourf3d.png']) def test_contourf3d(): fig = plt.figure() @@ -164,7 +246,7 @@ def test_contourf3d_extend(fig_test, fig_ref, extend, levels): Z = X**2 + Y**2 # Manually set the over/under colors to be the end of the colormap - cmap = plt.get_cmap('viridis').copy() + cmap = mpl.colormaps['viridis'].copy() cmap.set_under(cmap(0)) cmap.set_over(cmap(255)) # Set vmin/max to be the min/max values plotted on the reference image @@ -228,6 +310,23 @@ def test_plot_scalar(fig_test, fig_ref): ax2.plot(1, 1, "o") +def test_invalid_line_data(): + with pytest.raises(RuntimeError, match='x must be'): + art3d.Line3D(0, [], []) + with pytest.raises(RuntimeError, match='y must be'): + art3d.Line3D([], 0, []) + with pytest.raises(RuntimeError, match='z must be'): + art3d.Line3D([], [], 0) + + line = art3d.Line3D([], [], []) + with pytest.raises(RuntimeError, match='x must be'): + line.set_data_3d(0, [], []) + with pytest.raises(RuntimeError, match='y must be'): + line.set_data_3d([], 0, []) + with pytest.raises(RuntimeError, match='z must be'): + line.set_data_3d([], [], 0) + + @mpl3d_image_comparison(['mixedsubplot.png']) def test_mixedsubplots(): def f(t): @@ -294,6 +393,30 @@ def test_scatter3d_color(): color='b', marker='s') +@mpl3d_image_comparison(['scatter3d_linewidth.png']) +def test_scatter3d_linewidth(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Check that array-like linewidth can be set + ax.scatter(np.arange(10), np.arange(10), np.arange(10), + marker='o', linewidth=np.arange(10)) + + +@check_figures_equal(extensions=['png']) +def test_scatter3d_linewidth_modification(fig_ref, fig_test): + # Changing Path3DCollection linewidths with array-like post-creation + # should work correctly. + ax_test = fig_test.add_subplot(projection='3d') + c = ax_test.scatter(np.arange(10), np.arange(10), np.arange(10), + marker='o') + c.set_linewidths(np.arange(10)) + + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.scatter(np.arange(10), np.arange(10), np.arange(10), marker='o', + linewidths=np.arange(10)) + + @check_figures_equal(extensions=['png']) def test_scatter3d_modification(fig_ref, fig_test): # Changing Path3DCollection properties post-creation should work correctly. @@ -386,10 +509,10 @@ def test_marker_draw_order_data_reversed(fig_test, fig_ref, azim): color = ['b', 'y'] ax = fig_test.add_subplot(projection='3d') ax.scatter(x, y, z, s=3500, c=color) - ax.view_init(elev=0, azim=azim) + ax.view_init(elev=0, azim=azim, roll=0) ax = fig_ref.add_subplot(projection='3d') ax.scatter(x[::-1], y[::-1], z[::-1], s=3500, c=color[::-1]) - ax.view_init(elev=0, azim=azim) + ax.view_init(elev=0, azim=azim, roll=0) @check_figures_equal(extensions=['png']) @@ -409,11 +532,11 @@ def test_marker_draw_order_view_rotated(fig_test, fig_ref): # axis are not exactly invariant under 180 degree rotation -> deactivate ax.set_axis_off() ax.scatter(x, y, z, s=3500, c=color) - ax.view_init(elev=0, azim=azim) + ax.view_init(elev=0, azim=azim, roll=0) ax = fig_ref.add_subplot(projection='3d') ax.set_axis_off() ax.scatter(x, y, z, s=3500, c=color[::-1]) # color reversed - ax.view_init(elev=0, azim=azim - 180) # view rotated by 180 degrees + ax.view_init(elev=0, azim=azim - 180, roll=0) # view rotated by 180 deg @mpl3d_image_comparison(['plot_3d_from_2d.png'], tol=0.015) @@ -480,9 +603,19 @@ def test_surface3d_masked(): ) z = np.ma.masked_less(matrix, 0) norm = mcolors.Normalize(vmax=z.max(), vmin=z.min()) - colors = plt.get_cmap("plasma")(norm(z)) + colors = mpl.colormaps["plasma"](norm(z)) ax.plot_surface(x, y, z, facecolors=colors) - ax.view_init(30, -80) + ax.view_init(30, -80, 0) + + +@check_figures_equal(extensions=["png"]) +def test_plot_surface_None_arg(fig_test, fig_ref): + x, y = np.meshgrid(np.arange(5), np.arange(5)) + z = x + y + ax_test = fig_test.add_subplot(projection='3d') + ax_test.plot_surface(x, y, z, facecolors=None) + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.plot_surface(x, y, z) @mpl3d_image_comparison(['surface3d_masked_strides.png']) @@ -494,7 +627,7 @@ def test_surface3d_masked_strides(): z = np.ma.masked_less(x * y, 2) ax.plot_surface(x, y, z, rstride=4, cstride=4) - ax.view_init(60, -45) + ax.view_init(60, -45, 0) @mpl3d_image_comparison(['text3d.png'], remove_text=False) @@ -819,14 +952,16 @@ def test_add_collection3d_zs_scalar(): @mpl3d_image_comparison(['axes3d_labelpad.png'], remove_text=False) def test_axes3d_labelpad(): fig = plt.figure() - ax = fig.add_axes(Axes3D(fig, auto_add_to_figure=False)) + ax = fig.add_axes(Axes3D(fig)) # labelpad respects rcParams assert ax.xaxis.labelpad == mpl.rcParams['axes.labelpad'] # labelpad can be set in set_label ax.set_xlabel('X LABEL', labelpad=10) assert ax.xaxis.labelpad == 10 ax.set_ylabel('Y LABEL') - ax.set_zlabel('Z LABEL') + ax.set_zlabel('Z LABEL', labelpad=20) + assert ax.zaxis.labelpad == 20 + assert ax.get_zlabel() == 'Z LABEL' # or manually ax.yaxis.labelpad = 20 ax.zaxis.labelpad = -40 @@ -849,7 +984,7 @@ def test_axes3d_cla(): def test_axes3d_rotated(): fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection='3d') - ax.view_init(90, 45) # look down, rotated. Should be square + ax.view_init(90, 45, 0) # look down, rotated. Should be square def test_plotsurface_1d_raises(): @@ -869,8 +1004,10 @@ def _test_proj_make_M(): E = np.array([1000, -1000, 2000]) R = np.array([100, 100, 100]) V = np.array([0, 0, 1]) - viewM = proj3d.view_transformation(E, R, V) - perspM = proj3d.persp_transformation(100, -100) + roll = 0 + u, v, w = proj3d._view_axes(E, R, V, roll) + viewM = proj3d._view_transformation_uvw(u, v, w, E) + perspM = proj3d.persp_transformation(100, -100, 1) M = np.dot(perspM, viewM) return M @@ -934,7 +1071,9 @@ def test_proj_axes_cube_ortho(): E = np.array([200, 100, 100]) R = np.array([0, 0, 0]) V = np.array([0, 0, 1]) - viewM = proj3d.view_transformation(E, R, V) + roll = 0 + u, v, w = proj3d._view_axes(E, R, V, roll) + viewM = proj3d._view_transformation_uvw(u, v, w, E) orthoM = proj3d.ortho_transformation(-1, 1) M = np.dot(orthoM, viewM) @@ -991,8 +1130,10 @@ def test_lines_dists(): ys = (100, 150, 30, 200) ax.scatter(xs, ys) - dist = proj3d._line2d_seg_dist(p0, p1, (xs[0], ys[0])) - dist = proj3d._line2d_seg_dist(p0, p1, np.array((xs, ys))) + dist0 = proj3d._line2d_seg_dist((xs[0], ys[0]), p0, p1) + dist = proj3d._line2d_seg_dist(np.array((xs, ys)).T, p0, p1) + assert dist0 == dist[0] + for x, y, d in zip(xs, ys, dist): c = Circle((x, y), d, fill=0) ax.add_patch(c) @@ -1002,17 +1143,16 @@ def test_lines_dists(): def test_lines_dists_nowarning(): - # Smoke test to see that no RuntimeWarning is emitted when two first - # arguments are the same, see GH#22624 - p0 = (10, 30) - p1 = (20, 150) - proj3d._line2d_seg_dist(p0, p0, p1) - p0 = np.array(p0) - proj3d._line2d_seg_dist(p0, p0, p1) + # No RuntimeWarning must be emitted for degenerate segments, see GH#22624. + s0 = (10, 30, 50) + p = (20, 150, 180) + proj3d._line2d_seg_dist(p, s0, s0) + proj3d._line2d_seg_dist(np.array(p), s0, s0) def test_autoscale(): fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + assert ax.get_zscale() == 'linear' ax.margins(x=0, y=.1, z=.2) ax.plot([0, 1], [0, 1], [0, 1]) assert ax.get_w_lims() == (0, 1, -.1, 1.1, -.2, 1.2) @@ -1020,6 +1160,9 @@ def test_autoscale(): ax.set_autoscalez_on(True) ax.plot([0, 2], [0, 2], [0, 2]) assert ax.get_w_lims() == (0, 1, -.1, 1.1, -.4, 2.4) + ax.autoscale(axis='x') + ax.plot([0, 2], [0, 2], [0, 2]) + assert ax.get_w_lims() == (0, 2, -.1, 1.1, -.4, 2.4) @pytest.mark.parametrize('axis', ('x', 'y', 'z')) @@ -1044,6 +1187,22 @@ def test_unautoscale(axis, auto): np.testing.assert_array_equal(get_lim(), (-0.5, 0.5)) +def test_axes3d_focal_length_checks(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + with pytest.raises(ValueError): + ax.set_proj_type('persp', focal_length=0) + with pytest.raises(ValueError): + ax.set_proj_type('ortho', focal_length=1) + + +@mpl3d_image_comparison(['axes3d_focal_length.png'], remove_text=False) +def test_axes3d_focal_length(): + fig, axs = plt.subplots(1, 2, subplot_kw={'projection': '3d'}) + axs[0].set_proj_type('persp', focal_length=np.inf) + axs[1].set_proj_type('persp', focal_length=0.15) + + @mpl3d_image_comparison(['axes3d_ortho.png'], remove_text=False) def test_axes3d_ortho(): fig = plt.figure() @@ -1063,7 +1222,7 @@ def test_axes3d_isometric(): for s, e in combinations(np.array(list(product(r, r, r))), 2): if abs(s - e).sum() == r[1] - r[0]: ax.plot3D(*zip(s, e), c='k') - ax.view_init(elev=np.degrees(np.arctan(1. / np.sqrt(2))), azim=-45) + ax.view_init(elev=np.degrees(np.arctan(1. / np.sqrt(2))), azim=-45, roll=0) ax.grid(True) @@ -1258,8 +1417,7 @@ def test_inverted_cla(): def test_ax3d_tickcolour(): fig = plt.figure() - with pytest.warns(MatplotlibDeprecationWarning): - ax = Axes3D(fig) + ax = Axes3D(fig) ax.tick_params(axis='x', colors='red') ax.tick_params(axis='y', colors='red') @@ -1439,6 +1597,9 @@ def test_equal_box_aspect(): ax.axis('off') ax.set_box_aspect((1, 1, 1)) + with pytest.raises(ValueError, match="Argument zoom ="): + ax.set_box_aspect((1, 1, 1), zoom=-1) + def test_colorbar_pos(): num_plots = 2 @@ -1456,6 +1617,55 @@ def test_colorbar_pos(): assert cbar.ax.get_position().extents[1] < 0.2 +def test_inverted_zaxis(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + assert not ax.zaxis_inverted() + assert ax.get_zlim() == (0, 1) + assert ax.get_zbound() == (0, 1) + + # Change bound + ax.set_zbound((0, 2)) + assert not ax.zaxis_inverted() + assert ax.get_zlim() == (0, 2) + assert ax.get_zbound() == (0, 2) + + # Change invert + ax.invert_zaxis() + assert ax.zaxis_inverted() + assert ax.get_zlim() == (2, 0) + assert ax.get_zbound() == (0, 2) + + # Set upper bound + ax.set_zbound(upper=1) + assert ax.zaxis_inverted() + assert ax.get_zlim() == (1, 0) + assert ax.get_zbound() == (0, 1) + + # Set lower bound + ax.set_zbound(lower=2) + assert ax.zaxis_inverted() + assert ax.get_zlim() == (2, 1) + assert ax.get_zbound() == (1, 2) + + +def test_set_zlim(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + assert ax.get_zlim() == (0, 1) + ax.set_zlim(zmax=2) + assert ax.get_zlim() == (0, 2) + ax.set_zlim(zmin=1) + assert ax.get_zlim() == (1, 2) + + with pytest.raises( + TypeError, match="Cannot pass both 'bottom' and 'zmin'"): + ax.set_zlim(bottom=0, zmin=1) + with pytest.raises( + TypeError, match="Cannot pass both 'top' and 'zmax'"): + ax.set_zlim(top=0, zmax=1) + + def test_shared_axes_retick(): fig = plt.figure() ax1 = fig.add_subplot(211, projection="3d") @@ -1506,6 +1716,82 @@ def convert_lim(dmin, dmax): assert z_center != pytest.approx(z_center0) +@pytest.mark.parametrize("tool,button,key,expected", + [("zoom", MouseButton.LEFT, None, # zoom in + ((0.00, 0.06), (0.01, 0.07), (0.02, 0.08))), + ("zoom", MouseButton.LEFT, 'x', # zoom in + ((-0.01, 0.10), (-0.03, 0.08), (-0.06, 0.06))), + ("zoom", MouseButton.LEFT, 'y', # zoom in + ((-0.07, 0.04), (-0.03, 0.08), (0.00, 0.11))), + ("zoom", MouseButton.RIGHT, None, # zoom out + ((-0.09, 0.15), (-0.07, 0.17), (-0.06, 0.18))), + ("pan", MouseButton.LEFT, None, + ((-0.70, -0.58), (-1.03, -0.91), (-1.27, -1.15))), + ("pan", MouseButton.LEFT, 'x', + ((-0.96, -0.84), (-0.58, -0.46), (-0.06, 0.06))), + ("pan", MouseButton.LEFT, 'y', + ((0.20, 0.32), (-0.51, -0.39), (-1.27, -1.15)))]) +def test_toolbar_zoom_pan(tool, button, key, expected): + # NOTE: The expected zoom values are rough ballparks of moving in the view + # to make sure we are getting the right direction of motion. + # The specific values can and should change if the zoom movement + # scaling factor gets updated. + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(0, 0, 0) + fig.canvas.draw() + xlim0, ylim0, zlim0 = ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d() + + # Mouse from (0, 0) to (1, 1) + d0 = (0, 0) + d1 = (1, 1) + # Convert to screen coordinates ("s"). Events are defined only with pixel + # precision, so round the pixel values, and below, check against the + # corresponding xdata/ydata, which are close but not equal to d0/d1. + s0 = ax.transData.transform(d0).astype(int) + s1 = ax.transData.transform(d1).astype(int) + + # Set up the mouse movements + start_event = MouseEvent( + "button_press_event", fig.canvas, *s0, button, key=key) + stop_event = MouseEvent( + "button_release_event", fig.canvas, *s1, button, key=key) + + tb = NavigationToolbar2(fig.canvas) + if tool == "zoom": + tb.zoom() + tb.press_zoom(start_event) + tb.drag_zoom(stop_event) + tb.release_zoom(stop_event) + else: + tb.pan() + tb.press_pan(start_event) + tb.drag_pan(stop_event) + tb.release_pan(stop_event) + + # Should be close, but won't be exact due to screen integer resolution + xlim, ylim, zlim = expected + assert ax.get_xlim3d() == pytest.approx(xlim, abs=0.01) + assert ax.get_ylim3d() == pytest.approx(ylim, abs=0.01) + assert ax.get_zlim3d() == pytest.approx(zlim, abs=0.01) + + # Ensure that back, forward, and home buttons work + tb.back() + assert ax.get_xlim3d() == pytest.approx(xlim0) + assert ax.get_ylim3d() == pytest.approx(ylim0) + assert ax.get_zlim3d() == pytest.approx(zlim0) + + tb.forward() + assert ax.get_xlim3d() == pytest.approx(xlim, abs=0.01) + assert ax.get_ylim3d() == pytest.approx(ylim, abs=0.01) + assert ax.get_zlim3d() == pytest.approx(zlim, abs=0.01) + + tb.home() + assert ax.get_xlim3d() == pytest.approx(xlim0) + assert ax.get_ylim3d() == pytest.approx(ylim0) + assert ax.get_zlim3d() == pytest.approx(zlim0) + + @mpl.style.context('default') @check_figures_equal(extensions=["png"]) def test_scalarmap_update(fig_test, fig_ref): @@ -1536,8 +1822,9 @@ def test_subfigure_simple(): ax = sf[1].add_subplot(1, 1, 1, projection='3d', label='other') +# Update style when regenerating the test image @image_comparison(baseline_images=['computed_zorder'], remove_text=True, - extensions=['png']) + extensions=['png'], style=('classic', '_classic_test_patch')) def test_computed_zorder(): fig = plt.figure() ax1 = fig.add_subplot(221, projection='3d') @@ -1615,10 +1902,107 @@ def test_computed_zorder(): linestyle='--', color='green', zorder=4) - ax.view_init(azim=-20, elev=20) + ax.view_init(elev=20, azim=-20, roll=0) ax.axis('off') +def test_format_coord(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + x = np.arange(10) + ax.plot(x, np.sin(x)) + fig.canvas.draw() + assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553' + # Modify parameters + ax.view_init(roll=30, vertical_axis="y") + fig.canvas.draw() + assert ax.format_coord(0, 0) == 'x=9.1651, y=−0.9215, z=−0.0359' + # Reset parameters + ax.view_init() + fig.canvas.draw() + assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553' + + +def test_get_axis_position(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + x = np.arange(10) + ax.plot(x, np.sin(x)) + fig.canvas.draw() + assert ax.get_axis_position() == (False, True, False) + + +def test_margins(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.margins(0.2) + assert ax.margins() == (0.2, 0.2, 0.2) + ax.margins(0.1, 0.2, 0.3) + assert ax.margins() == (0.1, 0.2, 0.3) + ax.margins(x=0) + assert ax.margins() == (0, 0.2, 0.3) + ax.margins(y=0.1) + assert ax.margins() == (0, 0.1, 0.3) + ax.margins(z=0) + assert ax.margins() == (0, 0.1, 0) + + +@pytest.mark.parametrize('err, args, kwargs, match', ( + (ValueError, (-1,), {}, r'margin must be greater than -0\.5'), + (ValueError, (1, -1, 1), {}, r'margin must be greater than -0\.5'), + (ValueError, (1, 1, -1), {}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'x': -1}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'y': -1}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'z': -1}, r'margin must be greater than -0\.5'), + (TypeError, (1, ), {'x': 1}, + 'Cannot pass both positional and keyword'), + (TypeError, (1, ), {'x': 1, 'y': 1, 'z': 1}, + 'Cannot pass both positional and keyword'), + (TypeError, (1, ), {'x': 1, 'y': 1}, + 'Cannot pass both positional and keyword'), + (TypeError, (1, 1), {}, 'Must pass a single positional argument for'), +)) +def test_margins_errors(err, args, kwargs, match): + with pytest.raises(err, match=match): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.margins(*args, **kwargs) + + +@check_figures_equal(extensions=["png"]) +def test_text_3d(fig_test, fig_ref): + ax = fig_ref.add_subplot(projection="3d") + txt = Text(0.5, 0.5, r'Foo bar $\int$') + art3d.text_2d_to_3d(txt, z=1) + ax.add_artist(txt) + assert txt.get_position_3d() == (0.5, 0.5, 1) + + ax = fig_test.add_subplot(projection="3d") + t3d = art3d.Text3D(0.5, 0.5, 1, r'Foo bar $\int$') + ax.add_artist(t3d) + assert t3d.get_position_3d() == (0.5, 0.5, 1) + + +def test_draw_single_lines_from_Nx1(): + # Smoke test for GH#23459 + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.plot([[0], [1]], [[0], [1]], [[0], [1]]) + + +@check_figures_equal(extensions=["png"]) +def test_pathpatch_3d(fig_test, fig_ref): + ax = fig_ref.add_subplot(projection="3d") + path = Path.unit_rectangle() + patch = PathPatch(path) + art3d.pathpatch_2d_to_3d(patch, z=(0, 0.5, 0.7, 1, 0), zdir='y') + ax.add_artist(patch) + + ax = fig_test.add_subplot(projection="3d") + pp3d = art3d.PathPatch3D(path, zs=(0, 0.5, 0.7, 1, 0), zdir='y') + ax.add_artist(pp3d) + + @image_comparison(baseline_images=['scatter_spiral.png'], remove_text=True, style='default') @@ -1632,6 +2016,28 @@ def test_scatter_spiral(): fig.canvas.draw() +def test_Poly3DCollection_get_facecolor(): + # Smoke test to see that get_facecolor does not raise + # See GH#4067 + y, x = np.ogrid[1:10:100j, 1:10:100j] + z2 = np.cos(x) ** 3 - np.sin(y) ** 2 + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + r = ax.plot_surface(x, y, z2, cmap='hot') + r.get_facecolor() + + +def test_Poly3DCollection_get_edgecolor(): + # Smoke test to see that get_edgecolor does not raise + # See GH#4067 + y, x = np.ogrid[1:10:100j, 1:10:100j] + z2 = np.cos(x) ** 3 - np.sin(y) ** 2 + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + r = ax.plot_surface(x, y, z2, cmap='hot') + r.get_edgecolor() + + @pytest.mark.parametrize( "vertical_axis, proj_expected, axis_lines_expected, tickdirs_expected", [ @@ -1659,9 +2065,9 @@ def test_scatter_spiral(): [0.0, 0.0, -1.142857, 10.571429], ], [ - ([0.06329114, -0.06329114], [-0.04746835, -0.04746835]), - ([-0.06329114, -0.06329114], [0.04746835, -0.04746835]), - ([0.05617978, 0.06329114], [-0.04213483, -0.04746835]), + ([-0.06329114, 0.06329114], [0.04746835, 0.04746835]), + ([0.06329114, 0.06329114], [-0.04746835, 0.04746835]), + ([-0.05617978, -0.06329114], [0.04213483, 0.04746835]), ], [2, 2, 0], ), @@ -1674,9 +2080,9 @@ def test_scatter_spiral(): [0.0, -1.142857, 0.0, 10.571429], ], [ - ([-0.06329114, -0.06329114], [-0.04746835, 0.04746835]), - ([0.06329114, 0.05617978], [-0.04746835, -0.04213483]), - ([0.06329114, -0.06329114], [-0.04746835, -0.04746835]), + ([-0.06329114, -0.06329114], [0.04746835, -0.04746835]), + ([0.06329114, 0.05617978], [0.04746835, 0.04213483]), + ([0.06329114, -0.06329114], [0.04746835, 0.04746835]), ], [1, 2, 1], ), @@ -1702,7 +2108,7 @@ def test_view_init_vertical_axis( """ rtol = 2e-06 ax = plt.subplot(1, 1, 1, projection="3d") - ax.view_init(azim=0, elev=0, vertical_axis=vertical_axis) + ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis) ax.figure.canvas.draw() # Assert the projection matrix: @@ -1722,61 +2128,49 @@ def test_view_init_vertical_axis( np.testing.assert_array_equal(tickdir_expected, tickdir_actual) -def test_do_3d_projection_renderer_deprecation_warn_on_argument(): - """ - Test that an external artist with an old-style calling convention raises - a suitable deprecation warning. - """ - class DummyPatch(art3d.Patch3D): - def do_3d_projection(self, renderer): - return 0 - - def draw(self, renderer): - pass +@image_comparison(baseline_images=['arc_pathpatch.png'], + remove_text=True, + style='default') +def test_arc_pathpatch(): + ax = plt.subplot(1, 1, 1, projection="3d") + a = mpatch.Arc((0.5, 0.5), width=0.5, height=0.9, + angle=20, theta1=10, theta2=130) + ax.add_patch(a) + art3d.pathpatch_2d_to_3d(a, z=0, zdir='z') - fig = plt.figure() - ax = fig.add_subplot(111, projection='3d') - artist = DummyPatch() - ax.add_artist(artist) - match = r"The 'renderer' parameter of do_3d_projection\(\) was deprecated" - with pytest.warns(MatplotlibDeprecationWarning, match=match): - fig.canvas.draw() +@image_comparison(baseline_images=['panecolor_rcparams.png'], + remove_text=True, + style='mpl20') +def test_panecolor_rcparams(): + with plt.rc_context({'axes3d.xaxis.panecolor': 'r', + 'axes3d.yaxis.panecolor': 'g', + 'axes3d.zaxis.panecolor': 'b'}): + fig = plt.figure(figsize=(1, 1)) + fig.add_subplot(projection='3d') -def test_do_3d_projection_renderer_deprecation_nowarn_on_optional_argument(): - """ - Test that an external artist with a calling convention compatible with - both v3.3 and v3.4 does not raise a deprecation warning. +@check_figures_equal(extensions=["png"]) +def test_mutating_input_arrays_y_and_z(fig_test, fig_ref): """ - class DummyPatch(art3d.Patch3D): - def do_3d_projection(self, renderer=None): - return 0 - - def draw(self, renderer): - pass - - fig = plt.figure() - ax = fig.add_subplot(111, projection='3d') - artist = DummyPatch() - ax.add_artist(artist) - fig.canvas.draw() - + Test to see if the `z` axis does not get mutated + after a call to `Axes3D.plot` -def test_do_3d_projection_renderer_deprecation_nowarn_on_no_argument(): + test cases came from GH#8990 """ - Test that an external artist with a calling convention compatible with - only v3.4 does not raise a deprecation warning. - """ - class DummyPatch(art3d.Patch3D): - def do_3d_projection(self): - return 0 - - def draw(self, renderer): - pass - - fig = plt.figure() - ax = fig.add_subplot(111, projection='3d') - artist = DummyPatch() - ax.add_artist(artist) - fig.canvas.draw() + ax1 = fig_test.add_subplot(111, projection='3d') + x = [1, 2, 3] + y = [0.0, 0.0, 0.0] + z = [0.0, 0.0, 0.0] + ax1.plot(x, y, z, 'o-') + + # mutate y,z to get a nontrivial line + y[:] = [1, 2, 3] + z[:] = [1, 2, 3] + + # draw the same plot without mutating x and y + ax2 = fig_ref.add_subplot(111, projection='3d') + x = [1, 2, 3] + y = [0.0, 0.0, 0.0] + z = [0.0, 0.0, 0.0] + ax2.plot(x, y, z, 'o-') diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py new file mode 100644 index 000000000000..bdd46754fe5d --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -0,0 +1,109 @@ + +import numpy as np + +from matplotlib.colors import same_color +from matplotlib.testing.decorators import image_comparison +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import art3d + + +# Update style when regenerating the test image +@image_comparison(['legend_plot.png'], remove_text=True, + style=('classic', '_classic_test_patch')) +def test_legend_plot(): + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + x = np.arange(10) + ax.plot(x, 5 - x, 'o', zdir='y', label='z=1') + ax.plot(x, x - 5, 'o', zdir='y', label='z=-1') + ax.legend() + + +# Update style when regenerating the test image +@image_comparison(['legend_bar.png'], remove_text=True, + style=('classic', '_classic_test_patch')) +def test_legend_bar(): + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + x = np.arange(10) + b1 = ax.bar(x, x, zdir='y', align='edge', color='m') + b2 = ax.bar(x, x[::-1], zdir='x', align='edge', color='g') + ax.legend([b1[0], b2[0]], ['up', 'down']) + + +# Update style when regenerating the test image +@image_comparison(['fancy.png'], remove_text=True, + style=('classic', '_classic_test_patch')) +def test_fancy(): + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.plot(np.arange(10), np.full(10, 5), np.full(10, 5), 'o--', label='line') + ax.scatter(np.arange(10), np.arange(10, 0, -1), label='scatter') + ax.errorbar(np.full(10, 5), np.arange(10), np.full(10, 10), + xerr=0.5, zerr=0.5, label='errorbar') + ax.legend(loc='lower left', ncols=2, title='My legend', numpoints=1) + + +def test_linecollection_scaled_dashes(): + lines1 = [[(0, .5), (.5, 1)], [(.3, .6), (.2, .2)]] + lines2 = [[[0.7, .2], [.8, .4]], [[.5, .7], [.6, .1]]] + lines3 = [[[0.6, .2], [.8, .4]], [[.5, .7], [.1, .1]]] + lc1 = art3d.Line3DCollection(lines1, linestyles="--", lw=3) + lc2 = art3d.Line3DCollection(lines2, linestyles="-.") + lc3 = art3d.Line3DCollection(lines3, linestyles=":", lw=.5) + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.add_collection(lc1) + ax.add_collection(lc2) + ax.add_collection(lc3) + + leg = ax.legend([lc1, lc2, lc3], ['line1', 'line2', 'line 3']) + h1, h2, h3 = leg.legend_handles + + for oh, lh in zip((lc1, lc2, lc3), (h1, h2, h3)): + assert oh.get_linestyles()[0] == lh._dash_pattern + + +def test_handlerline3d(): + # Test marker consistency for monolithic Line3D legend handler. + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.scatter([0, 1], [0, 1], marker="v") + handles = [art3d.Line3D([0], [0], [0], marker="v")] + leg = ax.legend(handles, ["Aardvark"], numpoints=1) + assert handles[0].get_marker() == leg.legend_handles[0].get_marker() + + +def test_contour_legend_elements(): + from matplotlib.collections import LineCollection + x, y = np.mgrid[1:10, 1:10] + h = x * y + colors = ['blue', '#00FF00', 'red'] + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + cs = ax.contour(x, y, h, levels=[10, 30, 50], colors=colors, extend='both') + + artists, labels = cs.legend_elements() + assert labels == ['$x = 10.0$', '$x = 30.0$', '$x = 50.0$'] + assert all(isinstance(a, LineCollection) for a in artists) + assert all(same_color(a.get_color(), c) + for a, c in zip(artists, colors)) + + +def test_contourf_legend_elements(): + from matplotlib.patches import Rectangle + x, y = np.mgrid[1:10, 1:10] + h = x * y + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + cs = ax.contourf(x, y, h, levels=[10, 30, 50], + colors=['#FFFF00', '#FF00FF', '#00FFFF'], + extend='both') + cs.cmap.set_over('red') + cs.cmap.set_under('blue') + cs.changed() + artists, labels = cs.legend_elements() + assert labels == ['$x \\leq -1e+250s$', + '$10.0 < x \\leq 30.0$', + '$30.0 < x \\leq 50.0$', + '$x > 1e+250s$'] + expected_colors = ('blue', '#FFFF00', '#FF00FF', 'red') + assert all(isinstance(a, Rectangle) for a in artists) + assert all(same_color(a.get_facecolor(), c) + for a, c in zip(artists, expected_colors)) diff --git a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_clip_path/clip_path.png b/lib/mpl_toolkits/tests/baseline_images/test_axisartist_clip_path/clip_path.png deleted file mode 100644 index 1f296b6d06d5..000000000000 Binary files a/lib/mpl_toolkits/tests/baseline_images/test_axisartist_clip_path/clip_path.png and /dev/null differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_rotated.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_rotated.png deleted file mode 100644 index 0c79fd32e42c..000000000000 Binary files a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/axes3d_rotated.png and /dev/null differ diff --git a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png b/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png deleted file mode 100644 index c22f2a5671d3..000000000000 Binary files a/lib/mpl_toolkits/tests/baseline_images/test_mplot3d/bar3d_shaded.png and /dev/null differ diff --git a/lib/mpl_toolkits/tests/test_axes_grid.py b/lib/mpl_toolkits/tests/test_axes_grid.py deleted file mode 100644 index 4d77b90e5e03..000000000000 --- a/lib/mpl_toolkits/tests/test_axes_grid.py +++ /dev/null @@ -1,56 +0,0 @@ -import numpy as np - -import matplotlib as mpl -import matplotlib.ticker as mticker -from matplotlib.testing.decorators import image_comparison -import matplotlib.pyplot as plt -from mpl_toolkits.axes_grid1 import ImageGrid - - -# The original version of this test relied on mpl_toolkits's slightly different -# colorbar implementation; moving to matplotlib's own colorbar implementation -# caused the small image comparison error. -@image_comparison(['imagegrid_cbar_mode.png'], - remove_text=True, style='mpl20', tol=0.3) -def test_imagegrid_cbar_mode_edge(): - # Remove this line when this test image is regenerated. - plt.rcParams['pcolormesh.snap'] = False - - X, Y = np.meshgrid(np.linspace(0, 6, 30), np.linspace(0, 6, 30)) - arr = np.sin(X) * np.cos(Y) + 1j*(np.sin(3*Y) * np.cos(Y/2.)) - - fig = plt.figure(figsize=(18, 9)) - - positions = (241, 242, 243, 244, 245, 246, 247, 248) - directions = ['row']*4 + ['column']*4 - cbar_locations = ['left', 'right', 'top', 'bottom']*2 - - for position, direction, location in zip( - positions, directions, cbar_locations): - grid = ImageGrid(fig, position, - nrows_ncols=(2, 2), - direction=direction, - cbar_location=location, - cbar_size='20%', - cbar_mode='edge') - ax1, ax2, ax3, ax4, = grid - - ax1.imshow(arr.real, cmap='nipy_spectral') - ax2.imshow(arr.imag, cmap='hot') - ax3.imshow(np.abs(arr), cmap='jet') - ax4.imshow(np.arctan2(arr.imag, arr.real), cmap='hsv') - - # In each row/column, the "first" colorbars must be overwritten by the - # "second" ones. To achieve this, clear out the axes first. - for ax in grid: - ax.cax.cla() - cb = ax.cax.colorbar(ax.images[0]) - - -def test_imagegrid(): - fig = plt.figure() - grid = ImageGrid(fig, 111, nrows_ncols=(1, 1)) - ax = grid[0] - im = ax.imshow([[1, 2]], norm=mpl.colors.LogNorm()) - cb = ax.cax.colorbar(im) - assert isinstance(cb.locator, mticker.LogLocator) diff --git a/lib/mpl_toolkits/tests/test_axisartist_clip_path.py b/lib/mpl_toolkits/tests/test_axisartist_clip_path.py deleted file mode 100644 index 1108533353a1..000000000000 --- a/lib/mpl_toolkits/tests/test_axisartist_clip_path.py +++ /dev/null @@ -1,35 +0,0 @@ -import numpy as np - -from matplotlib import _api -import matplotlib.pyplot as plt -from matplotlib.testing.decorators import image_comparison -from matplotlib.transforms import Bbox - -with _api.suppress_matplotlib_deprecation_warning(): - from mpl_toolkits.axisartist.clip_path import clip_line_to_rect - - -@image_comparison(['clip_path.png'], style='default') -def test_clip_path(): - x = np.array([-3, -2, -1, 0., 1, 2, 3, 2, 1, 0, -1, -2, -3, 5]) - y = np.arange(len(x)) - - fig, ax = plt.subplots() - ax.plot(x, y, lw=1) - - bbox = Bbox.from_extents(-2, 3, 2, 12.5) - rect = plt.Rectangle(bbox.p0, bbox.width, bbox.height, - facecolor='none', edgecolor='k', ls='--') - ax.add_patch(rect) - - clipped_lines, ticks = clip_line_to_rect(x, y, bbox) - for lx, ly in clipped_lines: - ax.plot(lx, ly, lw=1, color='C1') - for px, py in zip(lx, ly): - assert bbox.contains(px, py) - - ccc = iter(['C3o', 'C2x', 'C3o', 'C2x']) - for ttt in ticks: - cc = next(ccc) - for (xx, yy), aa in ttt: - ax.plot([xx], [yy], cc) diff --git a/mplsetup.cfg.template b/mplsetup.cfg.template index 6c54a23fdccb..30985b2e313d 100644 --- a/mplsetup.cfg.template +++ b/mplsetup.cfg.template @@ -3,26 +3,26 @@ [libs] # By default, Matplotlib builds with LTO, which may be slow if you re-compile # often, and don't need the space saving/speedup. +# #enable_lto = True +# # By default, Matplotlib downloads and builds its own copies of FreeType and of # Qhull. You may set the following to True to instead link against a system # FreeType/Qhull. As an exception, Matplotlib defaults to the system version # of FreeType on AIX. +# #system_freetype = False #system_qhull = False [packages] -# There are a number of data subpackages from Matplotlib that are -# considered optional. All except 'tests' data (meaning the baseline -# image files) are installed by default, but that can be changed here. -#tests = False - -[gui_support] -# Matplotlib supports multiple GUI toolkits, known as backends. -# The MacOSX backend requires the Cocoa headers included with XCode. -# You can select whether to build it by uncommenting the following line. -# It is never built on Linux or Windows, regardless of the config value. +# Some of Matplotlib's components are optional: the MacOSX backend (installed +# by default on MacOSX; requires the Cocoa headers included with XCode), and +# the test data (i.e., the baseline image files; not installed by default). +# You can control whether they are installed by uncommenting the following +# lines. Note that the MacOSX backend is never built on Linux or Windows, +# regardless of the config value. # +#tests = False #macosx = True [rc_options] diff --git a/plot_types/3D/README.rst b/plot_types/3D/README.rst new file mode 100644 index 000000000000..e7157d4ba628 --- /dev/null +++ b/plot_types/3D/README.rst @@ -0,0 +1,6 @@ +.. _3D_plots: + +3D +-- + +3D plots using the `mpl_toolkits.mplot3d` library. diff --git a/plot_types/3D/scatter3d_simple.py b/plot_types/3D/scatter3d_simple.py new file mode 100644 index 000000000000..023a46448ccf --- /dev/null +++ b/plot_types/3D/scatter3d_simple.py @@ -0,0 +1,29 @@ +""" +============== +3D scatterplot +============== + +See `~mpl_toolkits.mplot3d.axes3d.Axes3D.scatter`. +""" +import matplotlib.pyplot as plt +import numpy as np + +plt.style.use('_mpl-gallery') + +# Make data +np.random.seed(19680801) +n = 100 +rng = np.random.default_rng() +xs = rng.uniform(23, 32, n) +ys = rng.uniform(0, 100, n) +zs = rng.uniform(-50, -25, n) + +# Plot +fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) +ax.scatter(xs, ys, zs) + +ax.set(xticklabels=[], + yticklabels=[], + zticklabels=[]) + +plt.show() diff --git a/plot_types/3D/surface3d_simple.py b/plot_types/3D/surface3d_simple.py new file mode 100644 index 000000000000..b1aff7d23b12 --- /dev/null +++ b/plot_types/3D/surface3d_simple.py @@ -0,0 +1,29 @@ +""" +===================== +3D surface +===================== + +See `~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_surface`. +""" +import matplotlib.pyplot as plt +from matplotlib import cm +import numpy as np + +plt.style.use('_mpl-gallery') + +# Make data +X = np.arange(-5, 5, 0.25) +Y = np.arange(-5, 5, 0.25) +X, Y = np.meshgrid(X, Y) +R = np.sqrt(X**2 + Y**2) +Z = np.sin(R) + +# Plot the surface +fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) +ax.plot_surface(X, Y, Z, vmin=Z.min() * 2, cmap=cm.Blues) + +ax.set(xticklabels=[], + yticklabels=[], + zticklabels=[]) + +plt.show() diff --git a/plot_types/3D/trisurf3d_simple.py b/plot_types/3D/trisurf3d_simple.py new file mode 100644 index 000000000000..92832c1b5b3a --- /dev/null +++ b/plot_types/3D/trisurf3d_simple.py @@ -0,0 +1,34 @@ +""" +====================== +Triangular 3D surfaces +====================== + +See `~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_trisurf`. +""" +import matplotlib.pyplot as plt +from matplotlib import cm +import numpy as np + +plt.style.use('_mpl-gallery') + +n_radii = 8 +n_angles = 36 + +# Make radii and angles spaces +radii = np.linspace(0.125, 1.0, n_radii) +angles = np.linspace(0, 2*np.pi, n_angles, endpoint=False)[..., np.newaxis] + +# Convert polar (radii, angles) coords to cartesian (x, y) coords. +x = np.append(0, (radii*np.cos(angles)).flatten()) +y = np.append(0, (radii*np.sin(angles)).flatten()) +z = np.sin(-x*y) + +# Plot +fig, ax = plt.subplots(subplot_kw={'projection': '3d'}) +ax.plot_trisurf(x, y, z, vmin=z.min() * 2, cmap=cm.Blues) + +ax.set(xticklabels=[], + yticklabels=[], + zticklabels=[]) + +plt.show() diff --git a/plot_types/3D/voxels_simple.py b/plot_types/3D/voxels_simple.py new file mode 100644 index 000000000000..c3473e108969 --- /dev/null +++ b/plot_types/3D/voxels_simple.py @@ -0,0 +1,31 @@ +""" +========================== +3D voxel / volumetric plot +========================== + +See `~mpl_toolkits.mplot3d.axes3d.Axes3D.voxels`. +""" +import matplotlib.pyplot as plt +import numpy as np + +plt.style.use('_mpl-gallery') + +# Prepare some coordinates +x, y, z = np.indices((8, 8, 8)) + +# Draw cuboids in the top left and bottom right corners +cube1 = (x < 3) & (y < 3) & (z < 3) +cube2 = (x >= 5) & (y >= 5) & (z >= 5) + +# Combine the objects into a single boolean array +voxelarray = cube1 | cube2 + +# Plot +fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) +ax.voxels(voxelarray, edgecolor='k') + +ax.set(xticklabels=[], + yticklabels=[], + zticklabels=[]) + +plt.show() diff --git a/plot_types/3D/wire3d_simple.py b/plot_types/3D/wire3d_simple.py new file mode 100644 index 000000000000..c0eaf40210e8 --- /dev/null +++ b/plot_types/3D/wire3d_simple.py @@ -0,0 +1,24 @@ +""" +================= +3D wireframe plot +================= + +See `~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_wireframe`. +""" +from mpl_toolkits.mplot3d import axes3d +import matplotlib.pyplot as plt + +plt.style.use('_mpl-gallery') + +# Make data +X, Y, Z = axes3d.get_test_data(0.05) + +# Plot +fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) +ax.plot_wireframe(X, Y, Z, rstride=10, cstride=10) + +ax.set(xticklabels=[], + yticklabels=[], + zticklabels=[]) + +plt.show() diff --git a/plot_types/README.rst b/plot_types/README.rst index c6a52eb489fa..ef5b08fbedbe 100644 --- a/plot_types/README.rst +++ b/plot_types/README.rst @@ -9,4 +9,4 @@ Overview of many common plotting commands in Matplotlib. Note that we have stripped all labels, but they are present by default. See the `gallery <../gallery/index.html>`_ for many more examples and -the `tutorials page <../tutorials/index.html>`_ for longer examples. \ No newline at end of file +the `tutorials page <../tutorials/index.html>`_ for longer examples. diff --git a/plot_types/arrays/README.rst b/plot_types/arrays/README.rst index 1e9c2590c36e..43844ee9490c 100644 --- a/plot_types/arrays/README.rst +++ b/plot_types/arrays/README.rst @@ -3,4 +3,4 @@ Plots of arrays and fields -------------------------- -Plotting for arrays of data ``Z(x, y)`` and fields ``U(x, y), V(x, y)``. \ No newline at end of file +Plotting for arrays of data ``Z(x, y)`` and fields ``U(x, y), V(x, y)``. diff --git a/plot_types/basic/README.rst b/plot_types/basic/README.rst index 7ad8b4692ec6..9ce35d083177 100644 --- a/plot_types/basic/README.rst +++ b/plot_types/basic/README.rst @@ -3,4 +3,4 @@ Basic ----- -Basic plot types, usually y versus x. \ No newline at end of file +Basic plot types, usually y versus x. diff --git a/plot_types/basic/bar.py b/plot_types/basic/bar.py index a44453c7b134..e23d27baa06c 100644 --- a/plot_types/basic/bar.py +++ b/plot_types/basic/bar.py @@ -1,9 +1,9 @@ """ -=============================== -bar(x, height) / barh(y, width) -=============================== +============== +bar(x, height) +============== -See `~matplotlib.axes.Axes.bar` / `~matplotlib.axes.Axes.barh`. +See `~matplotlib.axes.Axes.bar`. """ import matplotlib.pyplot as plt import numpy as np diff --git a/plot_types/basic/stackplot.py b/plot_types/basic/stackplot.py new file mode 100644 index 000000000000..5b23b52775b3 --- /dev/null +++ b/plot_types/basic/stackplot.py @@ -0,0 +1,27 @@ +""" +=============== +stackplot(x, y) +=============== +See `~matplotlib.axes.Axes.stackplot` +""" +import matplotlib.pyplot as plt +import numpy as np + +plt.style.use('_mpl-gallery') + +# make data +x = np.arange(0, 10, 2) +ay = [1, 1.25, 2, 2.75, 3] +by = [1, 1, 1, 1, 1] +cy = [2, 1, 2, 1, 2] +y = np.vstack([ay, by, cy]) + +# plot +fig, ax = plt.subplots() + +ax.stackplot(x, y) + +ax.set(xlim=(0, 8), xticks=np.arange(1, 8), + ylim=(0, 8), yticks=np.arange(1, 8)) + +plt.show() diff --git a/plot_types/stats/README.rst b/plot_types/stats/README.rst index 9944c77acccc..b2ca855aafbc 100644 --- a/plot_types/stats/README.rst +++ b/plot_types/stats/README.rst @@ -3,4 +3,4 @@ Statistics plots ---------------- -Plots for statistical analysis. \ No newline at end of file +Plots for statistical analysis. diff --git a/plot_types/unstructured/README.rst b/plot_types/unstructured/README.rst index 0f0a04d28913..89b7844775d2 100644 --- a/plot_types/unstructured/README.rst +++ b/plot_types/unstructured/README.rst @@ -4,6 +4,6 @@ Unstructured coordinates ------------------------- Sometimes we collect data ``z`` at coordinates ``(x,y)`` and want to visualize -as a contour. Instead of gridding the data and then using -`~.axes.Axes.contour`, we can use a triangulation algorithm and fill the -triangles. \ No newline at end of file +as a contour. Instead of gridding the data and then using +`~.axes.Axes.contour`, we can use a triangulation algorithm and fill the +triangles. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000000..907b05a39ba4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "certifi>=2020.06.20", + "oldest-supported-numpy", + "pybind11>=2.6", + "setuptools_scm>=7", +] diff --git a/pytest.ini b/pytest.ini index 905d3a5e5a52..f4a8057e0fcc 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,9 @@ -# Additional configuration is in matplotlib/testing/conftest.py. +# Because tests can be run from an installed copy, most of our Pytest +# configuration is in the `pytest_configure` function in +# `lib/matplotlib/testing/conftest.py`. This configuration file exists only to +# set a minimum pytest version and to prevent pytest from wasting time trying +# to check examples and documentation files that are not really tests. + [pytest] minversion = 3.6 diff --git a/requirements/doc/doc-requirements.txt b/requirements/doc/doc-requirements.txt index c77787e4c72b..9622bdab289f 100644 --- a/requirements/doc/doc-requirements.txt +++ b/requirements/doc/doc-requirements.txt @@ -7,15 +7,15 @@ # Install the documentation requirements with: # pip install -r requirements/doc/doc-requirements.txt # -sphinx>=1.8.1,!=2.0.0 +sphinx>=3.0.0,!=6.1.2 colorspacious ipython ipywidgets -numpydoc>=0.8 +numpydoc>=1.0 packaging>=20 -mpl-sphinx-theme +pydata-sphinx-theme>=0.12.0 +mpl-sphinx-theme>=3.7.0 sphinxcontrib-svg2pdfconverter>=1.1.0 sphinx-gallery>=0.10 sphinx-copybutton -sphinx-panels -scipy +sphinx-design diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index 18938c65f0cd..299cb0817dcb 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -1,12 +1,12 @@ # pip requirements for all the CI builds certifi -coverage<6.3 +coverage!=6.3 psutil pytest!=4.6.0,!=5.4.0 pytest-cov pytest-rerunfailures pytest-timeout pytest-xdist -python-dateutil +pytest-xvfb tornado diff --git a/requirements/testing/extra.txt b/requirements/testing/extra.txt index 4582783c743d..8d314a141218 100644 --- a/requirements/testing/extra.txt +++ b/requirements/testing/extra.txt @@ -1,5 +1,6 @@ -# Extra pip requirements for the Python 3.7+ builds +# Extra pip requirements for the Python 3.8+ builds +--prefer-binary ipykernel nbconvert[execute]!=6.0.0,!=6.0.1 nbformat!=5.0.0,!=5.0.1 @@ -7,3 +8,4 @@ pandas!=0.25.0 pikepdf pytz pywin32; sys.platform == 'win32' +xarray diff --git a/requirements/testing/flake8.txt b/requirements/testing/flake8.txt index f98708973072..a4d006b8551e 100644 --- a/requirements/testing/flake8.txt +++ b/requirements/testing/flake8.txt @@ -5,3 +5,5 @@ flake8>=3.8 pydocstyle>=5.1.0 # 1.4.0 adds docstring-convention=all flake8-docstrings>=1.4.0 +# fix bug where flake8 aborts checking on syntax error +flake8-force diff --git a/requirements/testing/minver.txt b/requirements/testing/minver.txt index 578cd205f9b5..2eb2f958af96 100644 --- a/requirements/testing/minver.txt +++ b/requirements/testing/minver.txt @@ -1,10 +1,12 @@ # Extra pip requirements for the minimum-version CI run +contourpy==1.0.1 cycler==0.10 kiwisolver==1.0.1 -numpy==1.17.0 +importlib-resources==3.2.0 +numpy==1.20.0 packaging==20.0 -pillow==6.2.0 -pyparsing==2.2.1 +pillow==6.2.1 +pyparsing==2.3.1 python-dateutil==2.7 fonttools==4.22.0 diff --git a/setup.py b/setup.py index 330d0d430509..490850ad6203 100644 --- a/setup.py +++ b/setup.py @@ -8,8 +8,8 @@ # and/or pip. import sys -py_min_version = (3, 7) # minimal supported python version -since_mpl_version = (3, 4) # py_min_version is required since this mpl version +py_min_version = (3, 8) # minimal supported python version +since_mpl_version = (3, 6) # py_min_version is required since this mpl version if sys.version_info < py_min_version: error = """ @@ -32,9 +32,11 @@ from setuptools import setup, find_packages, Distribution, Extension import setuptools.command.build_ext import setuptools.command.build_py -import setuptools.command.test import setuptools.command.sdist +# sys.path modified to find setupext.py during pyproject.toml builds. +sys.path.append(str(Path(__file__).resolve().parent)) + import setupext from setupext import print_raw, print_status @@ -67,14 +69,14 @@ def has_flag(self, flagname): return True -class NoopTestCommand(setuptools.command.test.test): - def __init__(self, dist): - print("Matplotlib does not support running tests with " - "'python setup.py test'. Please run 'pytest'.") - - class BuildExtraLibraries(setuptools.command.build_ext.build_ext): def finalize_options(self): + # If coverage is enabled then need to keep the .o and .gcno files in a + # non-temporary directory otherwise coverage info not collected. + cppflags = os.getenv('CPPFLAGS') + if cppflags and '--coverage' in cppflags: + self.build_temp = 'build' + self.distribution.ext_modules[:] = [ ext for package in good_packages @@ -140,7 +142,7 @@ def prepare_flags(name, enable_lto): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) - except Exception as e: + except Exception: pass else: version = result.stdout.lower() @@ -167,12 +169,6 @@ def prepare_flags(name, enable_lto): return env def build_extensions(self): - # Remove the -Wstrict-prototypes option, it's not valid for C++. Fixed - # in Py3.7 as bpo-5755. - try: - self.compiler.compiler_so.remove('-Wstrict-prototypes') - except (ValueError, AttributeError): - pass if (self.compiler.compiler_type == 'msvc' and os.environ.get('MPL_DISABLE_FH4')): # Disable FH4 Exception Handling implementation so that we don't @@ -207,7 +203,7 @@ def update_matplotlibrc(path): # line. Otherwise, use the default `##backend: Agg` which has no effect # even after decommenting, which allows _auto_backend_sentinel to be filled # in at import time. - template_lines = path.read_text().splitlines(True) + template_lines = path.read_text(encoding="utf-8").splitlines(True) backend_line_idx, = [ # Also asserts that there is a single such line. idx for idx, line in enumerate(template_lines) if "#backend:" in line] @@ -215,14 +211,15 @@ def update_matplotlibrc(path): "#backend: {}\n".format(setupext.options["backend"]) if setupext.options["backend"] else "##backend: Agg\n") - path.write_text("".join(template_lines)) + path.write_text("".join(template_lines), encoding="utf-8") class BuildPy(setuptools.command.build_py.build_py): def run(self): super().run() - update_matplotlibrc( - Path(self.build_lib, "matplotlib/mpl-data/matplotlibrc")) + if not getattr(self, 'editable_mode', False): + update_matplotlibrc( + Path(self.build_lib, "matplotlib/mpl-data/matplotlibrc")) class Sdist(setuptools.command.sdist.sdist): @@ -276,7 +273,7 @@ def make_release_tree(self, base_dir, files): author="John D. Hunter, Michael Droettboom", author_email="matplotlib-users@python.org", url="https://matplotlib.org", - download_url="https://matplotlib.org/users/installing.html", + download_url="https://matplotlib.org/stable/users/installing/index.html", project_urls={ 'Documentation': 'https://matplotlib.org', 'Source Code': 'https://github.com/matplotlib/matplotlib', @@ -284,8 +281,8 @@ def make_release_tree(self, base_dir, files): 'Forum': 'https://discourse.matplotlib.org/', 'Donate': 'https://numfocus.org/donate-to-matplotlib' }, - long_description=Path("README.rst").read_text(encoding="utf-8"), - long_description_content_type="text/x-rst", + long_description=Path("README.md").read_text(encoding="utf-8"), + long_description_content_type="text/markdown", license="PSF", platforms="any", classifiers=[ @@ -296,10 +293,10 @@ def make_release_tree(self, base_dir, files): 'License :: OSI Approved :: Python Software Foundation License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Scientific/Engineering :: Visualization', ], @@ -313,28 +310,35 @@ def make_release_tree(self, base_dir, files): package_data=package_data, python_requires='>={}'.format('.'.join(str(n) for n in py_min_version)), - setup_requires=[ - "certifi>=2020.06.20", - "numpy>=1.17", - "setuptools_scm>=4", - "setuptools_scm_git_archive", - ], + # When updating the list of dependencies, add an api_changes/development + # entry and also update the following places: + # - lib/matplotlib/__init__.py (matplotlib._check_versions()) + # - requirements/testing/minver.txt + # - doc/devel/dependencies.rst + # - .github/workflows/tests.yml + # - environment.yml install_requires=[ + "contourpy>=1.0.1", "cycler>=0.10", "fonttools>=4.22.0", "kiwisolver>=1.0.1", - "numpy>=1.17", + "numpy>=1.20", "packaging>=20.0", "pillow>=6.2.0", - "pyparsing>=2.2.1", + "pyparsing>=2.3.1", "python-dateutil>=2.7", ] + ( # Installing from a git checkout that is not producing a wheel. - ["setuptools_scm>=4"] if ( + ["setuptools_scm>=7"] if ( Path(__file__).with_name(".git").exists() and os.environ.get("CIBUILDWHEEL", "0") != "1" ) else [] ), + extras_require={ + ':python_version<"3.10"': [ + "importlib-resources>=3.2.0", + ], + }, use_scm_version={ "version_scheme": "release-branch-semver", "local_scheme": "node-and-date", @@ -343,7 +347,6 @@ def make_release_tree(self, base_dir, files): "fallback_version": "0.0+UNKNOWN", }, cmdclass={ - "test": NoopTestCommand, "build_ext": BuildExtraLibraries, "build_py": BuildPy, "sdist": Sdist, diff --git a/setupext.py b/setupext.py index 90bc304531b5..a898d642d631 100644 --- a/setupext.py +++ b/setupext.py @@ -12,9 +12,11 @@ import sys import sysconfig import tarfile +from tempfile import TemporaryDirectory import textwrap import urllib.request +from pybind11.setup_helpers import Pybind11Extension from setuptools import Distribution, Extension _log = logging.getLogger(__name__) @@ -22,9 +24,9 @@ def _get_xdg_cache_dir(): """ - Return the XDG cache directory. + Return the `XDG cache directory`__. - See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + __ https://specifications.freedesktop.org/basedir-spec/latest/ """ cache_dir = os.environ.get('XDG_CACHE_HOME') if not cache_dir: @@ -117,6 +119,13 @@ def get_and_extract_tarball(urls, sha, dirname): """ toplevel = Path("build", dirname) if not toplevel.exists(): # Download it or load it from cache. + try: + import certifi # noqa + except ImportError as e: + raise ImportError( + f"`certifi` is unavailable ({e}) so unable to download any of " + f"the following: {urls}.") from None + Path("build").mkdir(exist_ok=True) for url in urls: try: @@ -171,9 +180,8 @@ def get_and_extract_tarball(urls, sha, dirname): 'f8db94d307e9c54961b39a1cc799a67d46681480696ed72ecf78d4473770f09b' } # This is the version of FreeType to use when building a local version. It -# must match the value in lib/matplotlib.__init__.py and also needs to be -# changed below in the embedded windows build script (grep for "REMINDER" in -# this file). Also update the cache path in `.circleci/config.yml`. +# must match the value in lib/matplotlib.__init__.py, and the cache path in +# `.circleci/config.yml`. TESTING_VERSION_OF_FREETYPE = '2.6.1' if sys.platform.startswith('win') and platform.machine() == 'ARM64': # older versions of freetype are not supported for win/arm64 @@ -186,7 +194,8 @@ def get_and_extract_tarball(urls, sha, dirname): # Also update the cache path in `.circleci/config.yml`. LOCAL_QHULL_VERSION = '2020.2' -LOCAL_QHULL_HASH = 'b5c2d7eb833278881b952c8a52d20179eab87766b00b865000469a45c1838b7e' +LOCAL_QHULL_HASH = ( + 'b5c2d7eb833278881b952c8a52d20179eab87766b00b865000469a45c1838b7e') # Matplotlib build options, which can be altered using mplsetup.cfg @@ -197,9 +206,12 @@ def get_and_extract_tarball(urls, sha, dirname): options = { 'backend': config.get('rc_options', 'backend', fallback=None), 'system_freetype': config.getboolean( - 'libs', 'system_freetype', fallback=sys.platform.startswith('aix')), + 'libs', 'system_freetype', + fallback=sys.platform.startswith(('aix', 'os400')) + ), 'system_qhull': config.getboolean( - 'libs', 'system_qhull', fallback=False), + 'libs', 'system_qhull', fallback=sys.platform.startswith('os400') + ), } @@ -212,7 +224,7 @@ def print_raw(*args, **kwargs): pass # Suppress our own output. def print_status(package, status): initial_indent = "%12s: " % package indent = ' ' * 18 - print_raw(textwrap.fill(str(status), width=80, + print_raw(textwrap.fill(status, width=80, initial_indent=initial_indent, subsequent_indent=indent)) @@ -330,7 +342,6 @@ def do_custom_build(self, env): class OptionalPackage(SetupPackage): - config_category = "packages" default_config = True def check(self): @@ -339,7 +350,7 @@ def check(self): May be overridden by subclasses for additional checks. """ - if config.getboolean(self.config_category, self.name, + if config.getboolean("packages", self.name, fallback=self.default_config): return "installing" else: # Configuration opt-out by user @@ -399,16 +410,6 @@ def get_extensions(self): "win32": ["ole32", "shell32", "user32"], }.get(sys.platform, []))) yield ext - # contour - ext = Extension( - "matplotlib._contour", [ - "src/_contour.cpp", - "src/_contour_wrapper.cpp", - "src/py_converters.cpp", - ]) - add_numpy_flags(ext) - add_libagg_flags(ext) - yield ext # ft2font ext = Extension( "matplotlib.ft2font", [ @@ -440,7 +441,7 @@ def get_extensions(self): yield ext # qhull ext = Extension( - "matplotlib._qhull", ["src/qhull_wrap.cpp"], + "matplotlib._qhull", ["src/_qhull_wrapper.cpp"], define_macros=[("MPL_DEVNULL", os.devnull)]) add_numpy_flags(ext) Qhull.add_flags(ext) @@ -459,12 +460,12 @@ def get_extensions(self): add_libagg_flags(ext) yield ext # tri - ext = Extension( + ext = Pybind11Extension( "matplotlib._tri", [ "src/tri/_tri.cpp", "src/tri/_tri_wrapper.cpp", - ]) - add_numpy_flags(ext) + ], + cxx_std=11) yield ext # ttconv ext = Extension( @@ -489,11 +490,17 @@ def get_package_data(self): *_pkg_data_helper('matplotlib', 'tests/baseline_images'), *_pkg_data_helper('matplotlib', 'tests/tinypages'), 'tests/cmr10.pfb', + 'tests/Courier10PitchBT-Bold.pfb', 'tests/mpltest.ttf', 'tests/test_*.ipynb', ], 'mpl_toolkits': [ - *_pkg_data_helper('mpl_toolkits', 'tests/baseline_images'), + *_pkg_data_helper('mpl_toolkits', + 'axes_grid1/tests/baseline_images'), + *_pkg_data_helper('mpl_toolkits', + 'axisartist/tests/baseline_images'), + *_pkg_data_helper('mpl_toolkits', + 'mplot3d/tests/baseline_images'), ] } @@ -628,13 +635,20 @@ def do_custom_build(self, env): }, **env, } + configure_ac = Path(src_path, "builds/unix/configure.ac") + if ((src_path / "autogen.sh").exists() + and not configure_ac.exists()): + print(f"{configure_ac} does not exist. " + f"Using sh autogen.sh to generate.") + subprocess.check_call( + ["sh", "./autogen.sh"], env=env, cwd=src_path) env["CFLAGS"] = env.get("CFLAGS", "") + " -fPIC" configure = [ "./configure", "--with-zlib=no", "--with-bzip2=no", "--with-png=no", "--with-harfbuzz=no", "--enable-static", "--disable-shared" ] - host = sysconfig.get_config_var('BUILD_GNU_TYPE') + host = sysconfig.get_config_var('HOST_GNU_TYPE') if host is not None: # May be unset on PyPy. configure.append(f"--host={host}") subprocess.check_call(configure, env=env, cwd=src_path) @@ -666,18 +680,20 @@ def do_custom_build(self, env): f"build/freetype-{LOCAL_FREETYPE_VERSION}/builds/windows" ) vc = 'vc2010' - sln_path = ( - base_path / vc / "freetype.sln" - ) + sln_path = base_path / vc / "freetype.sln" # https://developercommunity.visualstudio.com/comments/190992/view.html - (sln_path.parent / "Directory.Build.props").write_text(""" - - - - $([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion('Windows', '10.0')) - - -""") + (sln_path.parent / "Directory.Build.props").write_text( + "" + "" + "" + # WindowsTargetPlatformVersion must be given on a single line. + "$(" + "[Microsoft.Build.Utilities.ToolLocationHelper]" + "::GetLatestSDKTargetPlatformVersion('Windows', '10.0')" + ")" + "" + "", + encoding="utf-8") # It is not a trivial task to determine PlatformToolset to plug it # into msbuild command, and Directory.Build.props will not override # the value in the project file. @@ -693,7 +709,23 @@ def do_custom_build(self, env): f.write(vcxproj) cc = get_ccompiler() - cc.initialize() # Get msbuild in the %PATH% of cc.spawn. + cc.initialize() + # On setuptools versions that use "local" distutils, + # ``cc.spawn(["msbuild", ...])`` no longer manages to locate the + # right executable, even though they are correctly on the PATH, + # because only the env kwarg to Popen() is updated, and not + # os.environ["PATH"]. Instead, use shutil.which to walk the PATH + # and get absolute executable paths. + with TemporaryDirectory() as tmpdir: + dest = Path(tmpdir, "path") + cc.spawn([ + sys.executable, "-c", + "import pathlib, shutil, sys\n" + "dest = pathlib.Path(sys.argv[1])\n" + "dest.write_text(shutil.which('msbuild'))\n", + str(dest), + ]) + msbuild_path = dest.read_text() # Freetype 2.10.0+ support static builds. msbuild_config = ( "Release Static" @@ -701,7 +733,7 @@ def do_custom_build(self, env): else "Release" ) - cc.spawn(["msbuild", str(sln_path), + cc.spawn([msbuild_path, str(sln_path), "/t:Clean;Build", f"/p:Configuration={msbuild_config};" f"Platform={msbuild_platform}"]) @@ -745,13 +777,13 @@ def do_custom_build(self, env): for ext in self._extensions_to_update: qhull_path = Path(f'build/qhull-{LOCAL_QHULL_VERSION}/src') ext.include_dirs.insert(0, str(qhull_path)) - ext.sources.extend(map(str, sorted(qhull_path.glob('libqhull_r/*.c')))) + ext.sources.extend( + map(str, sorted(qhull_path.glob('libqhull_r/*.c')))) if sysconfig.get_config_var("LIBM") == "-lm": ext.libraries.extend("m") class BackendMacOSX(OptionalPackage): - config_category = 'gui_support' name = 'macosx' def check(self): @@ -760,11 +792,11 @@ def check(self): return super().check() def get_extensions(self): - sources = [ - 'src/_macosx.m' - ] - ext = Extension('matplotlib.backends._macosx', sources) - ext.extra_compile_args.extend(['-Werror=unguarded-availability']) + ext = Extension( + 'matplotlib.backends._macosx', [ + 'src/_macosx.m' + ]) + ext.extra_compile_args.extend(['-Werror']) ext.extra_link_args.extend(['-framework', 'Cocoa']) if platform.python_implementation().lower() == 'pypy': ext.extra_compile_args.append('-DPYPY=1') diff --git a/src/_backend_agg.cpp b/src/_backend_agg.cpp index 9c46148ba4af..335e40971948 100644 --- a/src/_backend_agg.cpp +++ b/src/_backend_agg.cpp @@ -11,7 +11,7 @@ void BufferRegion::to_string_argb(uint8_t *buf) unsigned char tmp; size_t i, j; - memcpy(buf, data, height * stride); + memcpy(buf, data, (size_t) height * stride); for (i = 0; i < (size_t)height; ++i) { pix = buf + i * stride; diff --git a/src/_backend_agg.h b/src/_backend_agg.h index 0cd76618e26d..31a58db9f8f1 100644 --- a/src/_backend_agg.h +++ b/src/_backend_agg.h @@ -43,7 +43,7 @@ /**********************************************************************/ -// a helper class to pass agg::buffer objects around. +// a helper class to pass agg::buffer objects around. class BufferRegion { @@ -1124,7 +1124,7 @@ class QuadMeshGenerator inline size_t num_paths() const { - return m_meshWidth * m_meshHeight; + return (size_t) m_meshWidth * m_meshHeight; } inline path_iterator operator()(size_t i) const diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp index 72c9627c317f..15053b08fb70 100644 --- a/src/_backend_agg_wrapper.cpp +++ b/src/_backend_agg_wrapper.cpp @@ -12,6 +12,8 @@ typedef struct Py_ssize_t suboffsets[3]; } PyRendererAgg; +static PyTypeObject PyRendererAggType; + typedef struct { PyObject_HEAD @@ -21,6 +23,8 @@ typedef struct Py_ssize_t suboffsets[3]; } PyBufferRegion; +static PyTypeObject PyBufferRegionType; + /********************************************************************** * BufferRegion @@ -42,8 +46,14 @@ static void PyBufferRegion_dealloc(PyBufferRegion *self) static PyObject *PyBufferRegion_to_string(PyBufferRegion *self, PyObject *args) { + char const* msg = + "BufferRegion.to_string is deprecated since Matplotlib 3.7 and will " + "be removed two minor releases later; use np.asarray(region) instead."; + if (PyErr_WarnEx(PyExc_DeprecationWarning, msg, 1)) { + return NULL; + } return PyBytes_FromStringAndSize((const char *)self->x->get_data(), - self->x->get_height() * self->x->get_stride()); + (Py_ssize_t) self->x->get_height() * self->x->get_stride()); } /* TODO: This doesn't seem to be used internally. Remove? */ @@ -79,10 +89,19 @@ static PyObject *PyBufferRegion_get_extents(PyBufferRegion *self, PyObject *args static PyObject *PyBufferRegion_to_string_argb(PyBufferRegion *self, PyObject *args) { + char const* msg = + "BufferRegion.to_string_argb is deprecated since Matplotlib 3.7 and " + "will be removed two minor releases later; use " + "np.take(region, [2, 1, 0, 3], axis=2) instead."; + if (PyErr_WarnEx(PyExc_DeprecationWarning, msg, 1)) { + return NULL; + } PyObject *bufobj; uint8_t *buf; - - bufobj = PyBytes_FromStringAndSize(NULL, self->x->get_height() * self->x->get_stride()); + Py_ssize_t height, stride; + height = self->x->get_height(); + stride = self->x->get_stride(); + bufobj = PyBytes_FromStringAndSize(NULL, height * stride); buf = (uint8_t *)PyBytes_AS_STRING(bufobj); CALL_CPP_CLEANUP("to_string_argb", (self->x->to_string_argb(buf)), Py_DECREF(bufobj)); @@ -114,9 +133,7 @@ int PyBufferRegion_get_buffer(PyBufferRegion *self, Py_buffer *buf, int flags) return 1; } -static PyTypeObject PyBufferRegionType; - -static PyTypeObject *PyBufferRegion_init_type(PyObject *m, PyTypeObject *type) +static PyTypeObject *PyBufferRegion_init_type() { static PyMethodDef methods[] = { { "to_string", (PyCFunction)PyBufferRegion_to_string, METH_NOARGS, NULL }, @@ -128,26 +145,17 @@ static PyTypeObject *PyBufferRegion_init_type(PyObject *m, PyTypeObject *type) }; static PyBufferProcs buffer_procs; - memset(&buffer_procs, 0, sizeof(PyBufferProcs)); buffer_procs.bf_getbuffer = (getbufferproc)PyBufferRegion_get_buffer; - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib.backends._backend_agg.BufferRegion"; - type->tp_basicsize = sizeof(PyBufferRegion); - type->tp_dealloc = (destructor)PyBufferRegion_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; - type->tp_methods = methods; - type->tp_new = PyBufferRegion_new; - type->tp_as_buffer = &buffer_procs; - - if (PyType_Ready(type) < 0) { - return NULL; - } - - /* Don't need to add to module, since you can't create buffer - regions directly from Python */ + PyBufferRegionType.tp_name = "matplotlib.backends._backend_agg.BufferRegion"; + PyBufferRegionType.tp_basicsize = sizeof(PyBufferRegion); + PyBufferRegionType.tp_dealloc = (destructor)PyBufferRegion_dealloc; + PyBufferRegionType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; + PyBufferRegionType.tp_methods = methods; + PyBufferRegionType.tp_new = PyBufferRegion_new; + PyBufferRegionType.tp_as_buffer = &buffer_procs; - return type; + return &PyBufferRegionType; } /********************************************************************** @@ -592,9 +600,7 @@ static PyObject *PyRendererAgg_restore_region(PyRendererAgg *self, PyObject *arg Py_RETURN_NONE; } -PyTypeObject PyRendererAggType; - -static PyTypeObject *PyRendererAgg_init_type(PyObject *m, PyTypeObject *type) +static PyTypeObject *PyRendererAgg_init_type() { static PyMethodDef methods[] = { {"draw_path", (PyCFunction)PyRendererAgg_draw_path, METH_VARARGS, NULL}, @@ -614,28 +620,18 @@ static PyTypeObject *PyRendererAgg_init_type(PyObject *m, PyTypeObject *type) }; static PyBufferProcs buffer_procs; - memset(&buffer_procs, 0, sizeof(PyBufferProcs)); buffer_procs.bf_getbuffer = (getbufferproc)PyRendererAgg_get_buffer; - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib.backends._backend_agg.RendererAgg"; - type->tp_basicsize = sizeof(PyRendererAgg); - type->tp_dealloc = (destructor)PyRendererAgg_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; - type->tp_methods = methods; - type->tp_init = (initproc)PyRendererAgg_init; - type->tp_new = PyRendererAgg_new; - type->tp_as_buffer = &buffer_procs; - - if (PyType_Ready(type) < 0) { - return NULL; - } - - if (PyModule_AddObject(m, "RendererAgg", (PyObject *)type)) { - return NULL; - } + PyRendererAggType.tp_name = "matplotlib.backends._backend_agg.RendererAgg"; + PyRendererAggType.tp_basicsize = sizeof(PyRendererAgg); + PyRendererAggType.tp_dealloc = (destructor)PyRendererAgg_dealloc; + PyRendererAggType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; + PyRendererAggType.tp_methods = methods; + PyRendererAggType.tp_init = (initproc)PyRendererAgg_init; + PyRendererAggType.tp_new = PyRendererAgg_new; + PyRendererAggType.tp_as_buffer = &buffer_procs; - return type; + return &PyRendererAggType; } static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "_backend_agg" }; @@ -644,26 +640,16 @@ static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "_backend_agg" }; PyMODINIT_FUNC PyInit__backend_agg(void) { - PyObject *m; - import_array(); - - m = PyModule_Create(&moduledef); - - if (m == NULL) { - return NULL; - } - - if (!PyRendererAgg_init_type(m, &PyRendererAggType)) { - Py_DECREF(m); - return NULL; - } - - if (!PyBufferRegion_init_type(m, &PyBufferRegionType)) { - Py_DECREF(m); + PyObject *m; + if (!(m = PyModule_Create(&moduledef)) + || prepare_and_add_type(PyRendererAgg_init_type(), m) + // BufferRegion is not constructible from Python, thus not added to the module. + || PyType_Ready(PyBufferRegion_init_type()) + ) { + Py_XDECREF(m); return NULL; } - return m; } diff --git a/src/_c_internal_utils.c b/src/_c_internal_utils.c index db20d49bc904..f340f0397203 100644 --- a/src/_c_internal_utils.c +++ b/src/_c_internal_utils.c @@ -153,16 +153,17 @@ mpl_SetProcessDpiAwareness_max(PyObject* module) SetProcessDpiAwarenessContext_t SetProcessDpiAwarenessContextPtr = (SetProcessDpiAwarenessContext_t)GetProcAddress( user32, "SetProcessDpiAwarenessContext"); - if (IsValidDpiAwarenessContextPtr != NULL && SetProcessDpiAwarenessContextPtr != NULL) { - if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) { - // Added in Creators Update of Windows 10. - SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); - } else if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE)) { - // Added in Windows 10. - SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE); - } else if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE)) { - // Added in Windows 10. - SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE); + DPI_AWARENESS_CONTEXT ctxs[3] = { + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, // Win10 Creators Update + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE, // Win10 + DPI_AWARENESS_CONTEXT_SYSTEM_AWARE}; // Win10 + if (IsValidDpiAwarenessContextPtr != NULL + && SetProcessDpiAwarenessContextPtr != NULL) { + for (int i = 0; i < sizeof(ctxs) / sizeof(DPI_AWARENESS_CONTEXT); ++i) { + if (IsValidDpiAwarenessContextPtr(ctxs[i])) { + SetProcessDpiAwarenessContextPtr(ctxs[i]); + break; + } } } else { // Added in Windows Vista. @@ -183,31 +184,32 @@ static PyMethodDef functions[] = { "Check whether the current X11 or Wayland display is valid.\n\n" "On Linux, returns True if either $DISPLAY is set and XOpenDisplay(NULL)\n" "succeeds, or $WAYLAND_DISPLAY is set and wl_display_connect(NULL)\n" - "succeeds. On other platforms, always returns True."}, + "succeeds.\n\n" + "On other platforms, always returns True."}, {"Win32_GetCurrentProcessExplicitAppUserModelID", (PyCFunction)mpl_GetCurrentProcessExplicitAppUserModelID, METH_NOARGS, "Win32_GetCurrentProcessExplicitAppUserModelID()\n--\n\n" - "Wrapper for Windows's GetCurrentProcessExplicitAppUserModelID. On \n" - "non-Windows platforms, always returns None."}, + "Wrapper for Windows's GetCurrentProcessExplicitAppUserModelID.\n\n" + "On non-Windows platforms, always returns None."}, {"Win32_SetCurrentProcessExplicitAppUserModelID", (PyCFunction)mpl_SetCurrentProcessExplicitAppUserModelID, METH_O, "Win32_SetCurrentProcessExplicitAppUserModelID(appid, /)\n--\n\n" - "Wrapper for Windows's SetCurrentProcessExplicitAppUserModelID. On \n" - "non-Windows platforms, a no-op."}, + "Wrapper for Windows's SetCurrentProcessExplicitAppUserModelID.\n\n" + "On non-Windows platforms, does nothing."}, {"Win32_GetForegroundWindow", (PyCFunction)mpl_GetForegroundWindow, METH_NOARGS, "Win32_GetForegroundWindow()\n--\n\n" - "Wrapper for Windows' GetForegroundWindow. On non-Windows platforms, \n" - "always returns None."}, + "Wrapper for Windows' GetForegroundWindow.\n\n" + "On non-Windows platforms, always returns None."}, {"Win32_SetForegroundWindow", (PyCFunction)mpl_SetForegroundWindow, METH_O, "Win32_SetForegroundWindow(hwnd, /)\n--\n\n" - "Wrapper for Windows' SetForegroundWindow. On non-Windows platforms, \n" - "a no-op."}, + "Wrapper for Windows' SetForegroundWindow.\n\n" + "On non-Windows platforms, does nothing."}, {"Win32_SetProcessDpiAwareness_max", (PyCFunction)mpl_SetProcessDpiAwareness_max, METH_NOARGS, "Win32_SetProcessDpiAwareness_max()\n--\n\n" - "Set Windows' process DPI awareness to best option available.\n" + "Set Windows' process DPI awareness to best option available.\n\n" "On non-Windows platforms, does nothing."}, {NULL, NULL}}; // sentinel. static PyModuleDef util_module = { diff --git a/src/_contour.cpp b/src/_contour.cpp deleted file mode 100644 index 663d0e93bd1e..000000000000 --- a/src/_contour.cpp +++ /dev/null @@ -1,1842 +0,0 @@ -// This file contains liberal use of asserts to assist code development and -// debugging. Standard matplotlib builds disable asserts so they cause no -// performance reduction. To enable the asserts, you need to undefine the -// NDEBUG macro, which is achieved by adding the following -// undef_macros=['NDEBUG'] -// to the appropriate make_extension call in setupext.py, and then rebuilding. -#define NO_IMPORT_ARRAY - -#include "mplutils.h" -#include "_contour.h" -#include - - -// Point indices from current quad index. -#define POINT_SW (quad) -#define POINT_SE (quad+1) -#define POINT_NW (quad+_nx) -#define POINT_NE (quad+_nx+1) - -// CacheItem masks, only accessed directly to set. To read, use accessors -// detailed below. 1 and 2 refer to level indices (lower and upper). -#define MASK_Z_LEVEL 0x0003 // Combines the following two. -#define MASK_Z_LEVEL_1 0x0001 // z > lower_level. -#define MASK_Z_LEVEL_2 0x0002 // z > upper_level. -#define MASK_VISITED_1 0x0004 // Algorithm has visited this quad. -#define MASK_VISITED_2 0x0008 -#define MASK_SADDLE_1 0x0010 // quad is a saddle quad. -#define MASK_SADDLE_2 0x0020 -#define MASK_SADDLE_LEFT_1 0x0040 // Contours turn left at saddle quad. -#define MASK_SADDLE_LEFT_2 0x0080 -#define MASK_SADDLE_START_SW_1 0x0100 // Next visit starts on S or W edge. -#define MASK_SADDLE_START_SW_2 0x0200 -#define MASK_BOUNDARY_S 0x0400 // S edge of quad is a boundary. -#define MASK_BOUNDARY_W 0x0800 // W edge of quad is a boundary. -// EXISTS_QUAD bit is always used, but the 4 EXISTS_CORNER are only used if -// _corner_mask is true. Only one of EXISTS_QUAD or EXISTS_??_CORNER is ever -// set per quad, hence not using unique bits for each; care is needed when -// testing for these flags as they overlap. -#define MASK_EXISTS_QUAD 0x1000 // All of quad exists (is not masked). -#define MASK_EXISTS_SW_CORNER 0x2000 // SW corner exists, NE corner is masked. -#define MASK_EXISTS_SE_CORNER 0x3000 -#define MASK_EXISTS_NW_CORNER 0x4000 -#define MASK_EXISTS_NE_CORNER 0x5000 -#define MASK_EXISTS 0x7000 // Combines all 5 EXISTS masks. - -// The following are only needed for filled contours. -#define MASK_VISITED_S 0x10000 // Algorithm has visited S boundary. -#define MASK_VISITED_W 0x20000 // Algorithm has visited W boundary. -#define MASK_VISITED_CORNER 0x40000 // Algorithm has visited corner edge. - - -// Accessors for various CacheItem masks. li is shorthand for level_index. -#define Z_LEVEL(quad) (_cache[quad] & MASK_Z_LEVEL) -#define Z_NE Z_LEVEL(POINT_NE) -#define Z_NW Z_LEVEL(POINT_NW) -#define Z_SE Z_LEVEL(POINT_SE) -#define Z_SW Z_LEVEL(POINT_SW) -#define VISITED(quad,li) ((_cache[quad] & (li==1 ? MASK_VISITED_1 : MASK_VISITED_2)) != 0) -#define VISITED_S(quad) ((_cache[quad] & MASK_VISITED_S) != 0) -#define VISITED_W(quad) ((_cache[quad] & MASK_VISITED_W) != 0) -#define VISITED_CORNER(quad) ((_cache[quad] & MASK_VISITED_CORNER) != 0) -#define SADDLE(quad,li) ((_cache[quad] & (li==1 ? MASK_SADDLE_1 : MASK_SADDLE_2)) != 0) -#define SADDLE_LEFT(quad,li) ((_cache[quad] & (li==1 ? MASK_SADDLE_LEFT_1 : MASK_SADDLE_LEFT_2)) != 0) -#define SADDLE_START_SW(quad,li) ((_cache[quad] & (li==1 ? MASK_SADDLE_START_SW_1 : MASK_SADDLE_START_SW_2)) != 0) -#define BOUNDARY_S(quad) ((_cache[quad] & MASK_BOUNDARY_S) != 0) -#define BOUNDARY_W(quad) ((_cache[quad] & MASK_BOUNDARY_W) != 0) -#define BOUNDARY_N(quad) BOUNDARY_S(quad+_nx) -#define BOUNDARY_E(quad) BOUNDARY_W(quad+1) -#define EXISTS_QUAD(quad) ((_cache[quad] & MASK_EXISTS) == MASK_EXISTS_QUAD) -#define EXISTS_NONE(quad) ((_cache[quad] & MASK_EXISTS) == 0) -// The following are only used if _corner_mask is true. -#define EXISTS_SW_CORNER(quad) ((_cache[quad] & MASK_EXISTS) == MASK_EXISTS_SW_CORNER) -#define EXISTS_SE_CORNER(quad) ((_cache[quad] & MASK_EXISTS) == MASK_EXISTS_SE_CORNER) -#define EXISTS_NW_CORNER(quad) ((_cache[quad] & MASK_EXISTS) == MASK_EXISTS_NW_CORNER) -#define EXISTS_NE_CORNER(quad) ((_cache[quad] & MASK_EXISTS) == MASK_EXISTS_NE_CORNER) -#define EXISTS_ANY_CORNER(quad) (!EXISTS_NONE(quad) && !EXISTS_QUAD(quad)) -#define EXISTS_W_EDGE(quad) (EXISTS_QUAD(quad) || EXISTS_SW_CORNER(quad) || EXISTS_NW_CORNER(quad)) -#define EXISTS_E_EDGE(quad) (EXISTS_QUAD(quad) || EXISTS_SE_CORNER(quad) || EXISTS_NE_CORNER(quad)) -#define EXISTS_S_EDGE(quad) (EXISTS_QUAD(quad) || EXISTS_SW_CORNER(quad) || EXISTS_SE_CORNER(quad)) -#define EXISTS_N_EDGE(quad) (EXISTS_QUAD(quad) || EXISTS_NW_CORNER(quad) || EXISTS_NE_CORNER(quad)) -// Note that EXISTS_NE_CORNER(quad) is equivalent to BOUNDARY_SW(quad), etc. - - - -QuadEdge::QuadEdge() - : quad(-1), edge(Edge_None) -{} - -QuadEdge::QuadEdge(long quad_, Edge edge_) - : quad(quad_), edge(edge_) -{} - -bool QuadEdge::operator<(const QuadEdge& other) const -{ - if (quad != other.quad) - return quad < other.quad; - else - return edge < other.edge; -} - -bool QuadEdge::operator==(const QuadEdge& other) const -{ - return quad == other.quad && edge == other.edge; -} - -bool QuadEdge::operator!=(const QuadEdge& other) const -{ - return !operator==(other); -} - -std::ostream& operator<<(std::ostream& os, const QuadEdge& quad_edge) -{ - return os << quad_edge.quad << ' ' << quad_edge.edge; -} - - - -XY::XY() -{} - -XY::XY(const double& x_, const double& y_) - : x(x_), y(y_) -{} - -bool XY::operator==(const XY& other) const -{ - return x == other.x && y == other.y; -} - -bool XY::operator!=(const XY& other) const -{ - return x != other.x || y != other.y; -} - -XY XY::operator*(const double& multiplier) const -{ - return XY(x*multiplier, y*multiplier); -} - -const XY& XY::operator+=(const XY& other) -{ - x += other.x; - y += other.y; - return *this; -} - -const XY& XY::operator-=(const XY& other) -{ - x -= other.x; - y -= other.y; - return *this; -} - -XY XY::operator+(const XY& other) const -{ - return XY(x + other.x, y + other.y); -} - -XY XY::operator-(const XY& other) const -{ - return XY(x - other.x, y - other.y); -} - -std::ostream& operator<<(std::ostream& os, const XY& xy) -{ - return os << '(' << xy.x << ' ' << xy.y << ')'; -} - - - -ContourLine::ContourLine(bool is_hole) - : std::vector(), - _is_hole(is_hole), - _parent(0) -{} - -void ContourLine::add_child(ContourLine* child) -{ - assert(!_is_hole && "Cannot add_child to a hole"); - assert(child != 0 && "Null child ContourLine"); - _children.push_back(child); -} - -void ContourLine::clear_parent() -{ - assert(is_hole() && "Cannot clear parent of non-hole"); - assert(_parent != 0 && "Null parent ContourLine"); - _parent = 0; -} - -const ContourLine::Children& ContourLine::get_children() const -{ - assert(!_is_hole && "Cannot get_children of a hole"); - return _children; -} - -const ContourLine* ContourLine::get_parent() const -{ - assert(_is_hole && "Cannot get_parent of a non-hole"); - return _parent; -} - -ContourLine* ContourLine::get_parent() -{ - assert(_is_hole && "Cannot get_parent of a non-hole"); - return _parent; -} - -bool ContourLine::is_hole() const -{ - return _is_hole; -} - -void ContourLine::push_back(const XY& point) -{ - if (empty() || point != back()) - std::vector::push_back(point); -} - -void ContourLine::set_parent(ContourLine* parent) -{ - assert(_is_hole && "Cannot set parent of a non-hole"); - assert(parent != 0 && "Null parent ContourLine"); - _parent = parent; -} - -void ContourLine::write() const -{ - std::cout << "ContourLine " << this << " of " << size() << " points:"; - for (const_iterator it = begin(); it != end(); ++it) - std::cout << ' ' << *it; - if (is_hole()) - std::cout << " hole, parent=" << get_parent(); - else { - std::cout << " not hole"; - if (!_children.empty()) { - std::cout << ", children="; - for (Children::const_iterator it = _children.begin(); - it != _children.end(); ++it) - std::cout << *it << ' '; - } - } - std::cout << std::endl; -} - - - -Contour::Contour() -{} - -Contour::~Contour() -{ - delete_contour_lines(); -} - -void Contour::delete_contour_lines() -{ - for (iterator line_it = begin(); line_it != end(); ++line_it) { - delete *line_it; - *line_it = 0; - } - std::vector::clear(); -} - -void Contour::write() const -{ - std::cout << "Contour of " << size() << " lines." << std::endl; - for (const_iterator it = begin(); it != end(); ++it) - (*it)->write(); -} - - - -ParentCache::ParentCache(long nx, long x_chunk_points, long y_chunk_points) - : _nx(nx), - _x_chunk_points(x_chunk_points), - _y_chunk_points(y_chunk_points), - _lines(0), // Initialised when first needed. - _istart(0), - _jstart(0) -{ - assert(_x_chunk_points > 0 && _y_chunk_points > 0 && - "Chunk sizes must be positive"); -} - -ContourLine* ParentCache::get_parent(long quad) -{ - long index = quad_to_index(quad); - ContourLine* parent = _lines[index]; - while (parent == 0) { - index -= _x_chunk_points; - assert(index >= 0 && "Failed to find parent in chunk ParentCache"); - parent = _lines[index]; - } - assert(parent != 0 && "Failed to find parent in chunk ParentCache"); - return parent; -} - -long ParentCache::quad_to_index(long quad) const -{ - long i = quad % _nx; - long j = quad / _nx; - long index = (i-_istart) + (j-_jstart)*_x_chunk_points; - - assert(i >= _istart && i < _istart + _x_chunk_points && - "i-index outside chunk"); - assert(j >= _jstart && j < _jstart + _y_chunk_points && - "j-index outside chunk"); - assert(index >= 0 && index < static_cast(_lines.size()) && - "ParentCache index outside chunk"); - - return index; -} - -void ParentCache::set_chunk_starts(long istart, long jstart) -{ - assert(istart >= 0 && jstart >= 0 && - "Chunk start indices cannot be negative"); - _istart = istart; - _jstart = jstart; - if (_lines.empty()) - _lines.resize(_x_chunk_points*_y_chunk_points, 0); - else - std::fill(_lines.begin(), _lines.end(), (ContourLine*)0); -} - -void ParentCache::set_parent(long quad, ContourLine& contour_line) -{ - assert(!_lines.empty() && - "Accessing ParentCache before it has been initialised"); - long index = quad_to_index(quad); - if (_lines[index] == 0) - _lines[index] = (contour_line.is_hole() ? contour_line.get_parent() - : &contour_line); -} - - - -QuadContourGenerator::QuadContourGenerator(const CoordinateArray& x, - const CoordinateArray& y, - const CoordinateArray& z, - const MaskArray& mask, - bool corner_mask, - long chunk_size) - : _x(x), - _y(y), - _z(z), - _nx(static_cast(_x.dim(1))), - _ny(static_cast(_x.dim(0))), - _n(_nx*_ny), - _corner_mask(corner_mask), - _chunk_size(chunk_size > 0 ? std::min(chunk_size, std::max(_nx, _ny)-1) - : std::max(_nx, _ny)-1), - _nxchunk(calc_chunk_count(_nx)), - _nychunk(calc_chunk_count(_ny)), - _chunk_count(_nxchunk*_nychunk), - _cache(new CacheItem[_n]), - _parent_cache(_nx, - chunk_size > 0 ? chunk_size+1 : _nx, - chunk_size > 0 ? chunk_size+1 : _ny) -{ - assert(!_x.empty() && !_y.empty() && !_z.empty() && "Empty array"); - assert(_y.dim(0) == _x.dim(0) && _y.dim(1) == _x.dim(1) && - "Different-sized y and x arrays"); - assert(_z.dim(0) == _x.dim(0) && _z.dim(1) == _x.dim(1) && - "Different-sized z and x arrays"); - assert((mask.empty() || - (mask.dim(0) == _x.dim(0) && mask.dim(1) == _x.dim(1))) && - "Different-sized mask and x arrays"); - - init_cache_grid(mask); -} - -QuadContourGenerator::~QuadContourGenerator() -{ - delete [] _cache; -} - -void QuadContourGenerator::append_contour_line_to_vertices_and_codes( - ContourLine& contour_line, - PyObject* vertices_list, - PyObject* codes_list) const -{ - // Convert ContourLine to Python equivalent, and clear it for reuse. - // This function is called once for each line generated in create_contour(). - // A line is either a closed line loop (in which case the last point is - // identical to the first) or an open line strip. Two NumPy arrays are - // created for each line: - // vertices is a double array of shape (npoints, 2) containing the (x, y) - // coordinates of the points in the line - // codes is a uint8 array of shape (npoints,) containing the 'kind codes' - // which are defined in the Path class - // and they are appended to the Python lists vertices_list and codes_list - // respectively for return to the Python calling function. - - assert(vertices_list != 0 && "Null python vertices_list"); - assert(codes_list != 0 && "Null python codes_list"); - - npy_intp npoints = static_cast(contour_line.size()); - - npy_intp vertices_dims[2] = {npoints, 2}; - numpy::array_view vertices(vertices_dims); - double* vertices_ptr = vertices.data(); - - npy_intp codes_dims[1] = {npoints}; - numpy::array_view codes(codes_dims); - unsigned char* codes_ptr = codes.data(); - - for (ContourLine::const_iterator point = contour_line.begin(); - point != contour_line.end(); ++point) { - *vertices_ptr++ = point->x; - *vertices_ptr++ = point->y; - *codes_ptr++ = (point == contour_line.begin() ? MOVETO : LINETO); - } - - // Closed line loop has identical first and last (x, y) points. - if (contour_line.size() > 1 && contour_line.front() == contour_line.back()) - *(codes_ptr-1) = CLOSEPOLY; - - if (PyList_Append(vertices_list, vertices.pyobj_steal()) || - PyList_Append(codes_list, codes.pyobj_steal())) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - throw std::runtime_error("Unable to add contour line to vertices and codes lists"); - } - - contour_line.clear(); -} - -void QuadContourGenerator::append_contour_to_vertices_and_codes( - Contour& contour, - PyObject* vertices_list, - PyObject* codes_list) const -{ - // Convert Contour to Python equivalent, and clear it for reuse. - // This function is called once for each polygon generated in - // create_filled_contour(). A polygon consists of an outer line loop - // (called the parent) and zero or more inner line loops or holes (called - // the children). Two NumPy arrays are created for each polygon: - // vertices is a double array of shape (npoints, 2) containing the (x, y) - // coordinates of the points in the polygon (parent plus children) - // codes is a uint8 array of shape (npoints,) containing the 'kind codes' - // which are defined in the Path class - // and they are appended to the Python lists vertices_list and codes_list - // respectively for return to the Python calling function. - - assert(vertices_list != 0 && "Null python vertices_list"); - assert(codes_list != 0 && "Null python codes_list"); - - // Convert Contour to python equivalent, and clear it. - for (Contour::iterator line_it = contour.begin(); line_it != contour.end(); - ++line_it) { - ContourLine& line = **line_it; - if (line.is_hole()) { - // If hole has already been converted to python its parent will be - // set to 0 and it can be deleted. - if (line.get_parent() != 0) { - delete *line_it; - *line_it = 0; - } - } - else { - // Non-holes are converted to python together with their child - // holes so that they are rendered correctly. - ContourLine::const_iterator point; - ContourLine::Children::const_iterator children_it; - - const ContourLine::Children& children = line.get_children(); - npy_intp npoints = static_cast(line.size() + 1); - for (children_it = children.begin(); children_it != children.end(); - ++children_it) - npoints += static_cast((*children_it)->size() + 1); - - npy_intp vertices_dims[2] = {npoints, 2}; - numpy::array_view vertices(vertices_dims); - double* vertices_ptr = vertices.data(); - - npy_intp codes_dims[1] = {npoints}; - numpy::array_view codes(codes_dims); - unsigned char* codes_ptr = codes.data(); - - for (point = line.begin(); point != line.end(); ++point) { - *vertices_ptr++ = point->x; - *vertices_ptr++ = point->y; - *codes_ptr++ = (point == line.begin() ? MOVETO : LINETO); - } - point = line.begin(); - *vertices_ptr++ = point->x; - *vertices_ptr++ = point->y; - *codes_ptr++ = CLOSEPOLY; - - for (children_it = children.begin(); children_it != children.end(); - ++children_it) { - ContourLine& child = **children_it; - for (point = child.begin(); point != child.end(); ++point) { - *vertices_ptr++ = point->x; - *vertices_ptr++ = point->y; - *codes_ptr++ = (point == child.begin() ? MOVETO : LINETO); - } - point = child.begin(); - *vertices_ptr++ = point->x; - *vertices_ptr++ = point->y; - *codes_ptr++ = CLOSEPOLY; - - child.clear_parent(); // To indicate it can be deleted. - } - - if (PyList_Append(vertices_list, vertices.pyobj_steal()) || - PyList_Append(codes_list, codes.pyobj_steal())) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - contour.delete_contour_lines(); - throw std::runtime_error("Unable to add contour line to vertices and codes lists"); - } - - delete *line_it; - *line_it = 0; - } - } - - // Delete remaining contour lines. - contour.delete_contour_lines(); -} - -long QuadContourGenerator::calc_chunk_count(long point_count) const -{ - assert(point_count > 0 && "point count must be positive"); - assert(_chunk_size > 0 && "Chunk size must be positive"); - - if (_chunk_size > 0) { - long count = (point_count-1) / _chunk_size; - if (count*_chunk_size < point_count-1) - ++count; - - assert(count >= 1 && "Invalid chunk count"); - return count; - } - else - return 1; -} - -PyObject* QuadContourGenerator::create_contour(const double& level) -{ - init_cache_levels(level, level); - - PyObject* vertices_list = PyList_New(0); - if (vertices_list == 0) - throw std::runtime_error("Failed to create Python list"); - - PyObject* codes_list = PyList_New(0); - if (codes_list == 0) { - Py_XDECREF(vertices_list); - throw std::runtime_error("Failed to create Python list"); - } - - // Lines that start and end on boundaries. - long ichunk, jchunk, istart, iend, jstart, jend; - for (long ijchunk = 0; ijchunk < _chunk_count; ++ijchunk) { - get_chunk_limits(ijchunk, ichunk, jchunk, istart, iend, jstart, jend); - - for (long j = jstart; j < jend; ++j) { - long quad_end = iend + j*_nx; - for (long quad = istart + j*_nx; quad < quad_end; ++quad) { - if (EXISTS_NONE(quad) || VISITED(quad,1)) continue; - - if (BOUNDARY_S(quad) && Z_SW >= 1 && Z_SE < 1 && - start_line(vertices_list, codes_list, quad, Edge_S, level)) continue; - - if (BOUNDARY_W(quad) && Z_NW >= 1 && Z_SW < 1 && - start_line(vertices_list, codes_list, quad, Edge_W, level)) continue; - - if (BOUNDARY_N(quad) && Z_NE >= 1 && Z_NW < 1 && - start_line(vertices_list, codes_list, quad, Edge_N, level)) continue; - - if (BOUNDARY_E(quad) && Z_SE >= 1 && Z_NE < 1 && - start_line(vertices_list, codes_list, quad, Edge_E, level)) continue; - - if (_corner_mask) { - // Equates to NE boundary. - if (EXISTS_SW_CORNER(quad) && Z_SE >= 1 && Z_NW < 1 && - start_line(vertices_list, codes_list, quad, Edge_NE, level)) continue; - - // Equates to NW boundary. - if (EXISTS_SE_CORNER(quad) && Z_NE >= 1 && Z_SW < 1 && - start_line(vertices_list, codes_list, quad, Edge_NW, level)) continue; - - // Equates to SE boundary. - if (EXISTS_NW_CORNER(quad) && Z_SW >= 1 && Z_NE < 1 && - start_line(vertices_list, codes_list, quad, Edge_SE, level)) continue; - - // Equates to SW boundary. - if (EXISTS_NE_CORNER(quad) && Z_NW >= 1 && Z_SE < 1 && - start_line(vertices_list, codes_list, quad, Edge_SW, level)) continue; - } - } - } - } - - // Internal loops. - ContourLine contour_line(false); // Reused for each contour line. - for (long ijchunk = 0; ijchunk < _chunk_count; ++ijchunk) { - get_chunk_limits(ijchunk, ichunk, jchunk, istart, iend, jstart, jend); - - for (long j = jstart; j < jend; ++j) { - long quad_end = iend + j*_nx; - for (long quad = istart + j*_nx; quad < quad_end; ++quad) { - if (EXISTS_NONE(quad) || VISITED(quad,1)) - continue; - - Edge start_edge = get_start_edge(quad, 1); - if (start_edge == Edge_None) - continue; - - QuadEdge quad_edge(quad, start_edge); - QuadEdge start_quad_edge(quad_edge); - - // To obtain output identical to that produced by legacy code, - // sometimes need to ignore the first point and add it on the - // end instead. - bool ignore_first = (start_edge == Edge_N); - follow_interior(contour_line, quad_edge, 1, level, - !ignore_first, &start_quad_edge, 1, false); - if (ignore_first && !contour_line.empty()) - contour_line.push_back(contour_line.front()); - - append_contour_line_to_vertices_and_codes( - contour_line, vertices_list, codes_list); - - // Repeat if saddle point but not visited. - if (SADDLE(quad,1) && !VISITED(quad,1)) - --quad; - } - } - } - - PyObject* tuple = PyTuple_New(2); - if (tuple == 0) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - throw std::runtime_error("Failed to create Python tuple"); - } - - // No error checking here as filling in a brand new pre-allocated tuple. - PyTuple_SET_ITEM(tuple, 0, vertices_list); - PyTuple_SET_ITEM(tuple, 1, codes_list); - - return tuple; -} - -PyObject* QuadContourGenerator::create_filled_contour(const double& lower_level, - const double& upper_level) -{ - init_cache_levels(lower_level, upper_level); - - Contour contour; - - PyObject* vertices_list = PyList_New(0); - if (vertices_list == 0) - throw std::runtime_error("Failed to create Python list"); - - PyObject* codes_list = PyList_New(0); - if (codes_list == 0) { - Py_XDECREF(vertices_list); - throw std::runtime_error("Failed to create Python list"); - } - - long ichunk, jchunk, istart, iend, jstart, jend; - for (long ijchunk = 0; ijchunk < _chunk_count; ++ijchunk) { - get_chunk_limits(ijchunk, ichunk, jchunk, istart, iend, jstart, jend); - _parent_cache.set_chunk_starts(istart, jstart); - - for (long j = jstart; j < jend; ++j) { - long quad_end = iend + j*_nx; - for (long quad = istart + j*_nx; quad < quad_end; ++quad) { - if (!EXISTS_NONE(quad)) - single_quad_filled(contour, quad, lower_level, upper_level); - } - } - - // Clear VISITED_W and VISITED_S flags that are reused by later chunks. - if (jchunk < _nychunk-1) { - long quad_end = iend + jend*_nx; - for (long quad = istart + jend*_nx; quad < quad_end; ++quad) - _cache[quad] &= ~MASK_VISITED_S; - } - - if (ichunk < _nxchunk-1) { - long quad_end = iend + jend*_nx; - for (long quad = iend + jstart*_nx; quad < quad_end; quad += _nx) - _cache[quad] &= ~MASK_VISITED_W; - } - - // Create python objects to return for this chunk. - append_contour_to_vertices_and_codes(contour, vertices_list, codes_list); - } - - PyObject* tuple = PyTuple_New(2); - if (tuple == 0) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - throw std::runtime_error("Failed to create Python tuple"); - } - - // No error checking here as filling in a brand new pre-allocated tuple. - PyTuple_SET_ITEM(tuple, 0, vertices_list); - PyTuple_SET_ITEM(tuple, 1, codes_list); - - return tuple; -} - -XY QuadContourGenerator::edge_interp(const QuadEdge& quad_edge, - const double& level) -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - return interp(get_edge_point_index(quad_edge, true), - get_edge_point_index(quad_edge, false), - level); -} - -unsigned int QuadContourGenerator::follow_boundary( - ContourLine& contour_line, - QuadEdge& quad_edge, - const double& lower_level, - const double& upper_level, - unsigned int level_index, - const QuadEdge& start_quad_edge) -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - assert(is_edge_a_boundary(quad_edge) && "Not a boundary edge"); - assert((level_index == 1 || level_index == 2) && - "level index must be 1 or 2"); - assert(start_quad_edge.quad >= 0 && start_quad_edge.quad < _n && - "Start quad index out of bounds"); - assert(start_quad_edge.edge != Edge_None && "Invalid start edge"); - - // Only called for filled contours, so always updates _parent_cache. - unsigned int end_level = 0; - bool first_edge = true; - bool stop = false; - long& quad = quad_edge.quad; - - while (true) { - // Levels of start and end points of quad_edge. - unsigned int start_level = - (first_edge ? Z_LEVEL(get_edge_point_index(quad_edge, true)) - : end_level); - long end_point = get_edge_point_index(quad_edge, false); - end_level = Z_LEVEL(end_point); - - if (level_index == 1) { - if (start_level <= level_index && end_level == 2) { - // Increasing z, switching levels from 1 to 2. - level_index = 2; - stop = true; - } - else if (start_level >= 1 && end_level == 0) { - // Decreasing z, keeping same level. - stop = true; - } - } - else { // level_index == 2 - if (start_level <= level_index && end_level == 2) { - // Increasing z, keeping same level. - stop = true; - } - else if (start_level >= 1 && end_level == 0) { - // Decreasing z, switching levels from 2 to 1. - level_index = 1; - stop = true; - } - } - - if (!first_edge && !stop && quad_edge == start_quad_edge) - // Return if reached start point of contour line. Do this before - // checking/setting VISITED flags as will already have been - // visited. - break; - - switch (quad_edge.edge) { - case Edge_E: - assert(!VISITED_W(quad+1) && "Already visited"); - _cache[quad+1] |= MASK_VISITED_W; - break; - case Edge_N: - assert(!VISITED_S(quad+_nx) && "Already visited"); - _cache[quad+_nx] |= MASK_VISITED_S; - break; - case Edge_W: - assert(!VISITED_W(quad) && "Already visited"); - _cache[quad] |= MASK_VISITED_W; - break; - case Edge_S: - assert(!VISITED_S(quad) && "Already visited"); - _cache[quad] |= MASK_VISITED_S; - break; - case Edge_NE: - case Edge_NW: - case Edge_SW: - case Edge_SE: - assert(!VISITED_CORNER(quad) && "Already visited"); - _cache[quad] |= MASK_VISITED_CORNER; - break; - default: - assert(0 && "Invalid Edge"); - break; - } - - if (stop) { - // Exiting boundary to enter interior. - contour_line.push_back(edge_interp(quad_edge, - level_index == 1 ? lower_level - : upper_level)); - break; - } - - move_to_next_boundary_edge(quad_edge); - - // Just moved to new quad edge, so label parent of start of quad edge. - switch (quad_edge.edge) { - case Edge_W: - case Edge_SW: - case Edge_S: - case Edge_SE: - if (!EXISTS_SE_CORNER(quad)) - _parent_cache.set_parent(quad, contour_line); - break; - case Edge_E: - case Edge_NE: - case Edge_N: - case Edge_NW: - if (!EXISTS_SW_CORNER(quad)) - _parent_cache.set_parent(quad + 1, contour_line); - break; - default: - assert(0 && "Invalid edge"); - break; - } - - // Add point to contour. - contour_line.push_back(get_point_xy(end_point)); - - if (first_edge) - first_edge = false; - } - - return level_index; -} - -void QuadContourGenerator::follow_interior(ContourLine& contour_line, - QuadEdge& quad_edge, - unsigned int level_index, - const double& level, - bool want_initial_point, - const QuadEdge* start_quad_edge, - unsigned int start_level_index, - bool set_parents) -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds."); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - assert((level_index == 1 || level_index == 2) && - "level index must be 1 or 2"); - assert((start_quad_edge == 0 || - (start_quad_edge->quad >= 0 && start_quad_edge->quad < _n)) && - "Start quad index out of bounds."); - assert((start_quad_edge == 0 || start_quad_edge->edge != Edge_None) && - "Invalid start edge"); - assert((start_level_index == 1 || start_level_index == 2) && - "start level index must be 1 or 2"); - - long& quad = quad_edge.quad; - Edge& edge = quad_edge.edge; - - if (want_initial_point) - contour_line.push_back(edge_interp(quad_edge, level)); - - CacheItem visited_mask = (level_index == 1 ? MASK_VISITED_1 : MASK_VISITED_2); - CacheItem saddle_mask = (level_index == 1 ? MASK_SADDLE_1 : MASK_SADDLE_2); - Dir dir = Dir_Straight; - - while (true) { - assert(!EXISTS_NONE(quad) && "Quad does not exist"); - assert(!(_cache[quad] & visited_mask) && "Quad already visited"); - - // Determine direction to move to next quad. If the quad is already - // labelled as a saddle quad then the direction is easily read from - // the cache. Otherwise the direction is determined differently - // depending on whether the quad is a corner quad or not. - - if (_cache[quad] & saddle_mask) { - // Already identified as a saddle quad, so direction is easy. - dir = (SADDLE_LEFT(quad,level_index) ? Dir_Left : Dir_Right); - _cache[quad] |= visited_mask; - } - else if (EXISTS_ANY_CORNER(quad)) { - // Need z-level of point opposite the entry edge, as that - // determines whether contour turns left or right. - long point_opposite = -1; - switch (edge) { - case Edge_E: - point_opposite = (EXISTS_SE_CORNER(quad) ? POINT_SW - : POINT_NW); - break; - case Edge_N: - point_opposite = (EXISTS_NW_CORNER(quad) ? POINT_SW - : POINT_SE); - break; - case Edge_W: - point_opposite = (EXISTS_SW_CORNER(quad) ? POINT_SE - : POINT_NE); - break; - case Edge_S: - point_opposite = (EXISTS_SW_CORNER(quad) ? POINT_NW - : POINT_NE); - break; - case Edge_NE: point_opposite = POINT_SW; break; - case Edge_NW: point_opposite = POINT_SE; break; - case Edge_SW: point_opposite = POINT_NE; break; - case Edge_SE: point_opposite = POINT_NW; break; - default: assert(0 && "Invalid edge"); break; - } - assert(point_opposite != -1 && "Failed to find opposite point"); - - // Lower-level polygons (level_index == 1) always have higher - // values to the left of the contour. Upper-level contours - // (level_index == 2) are reversed, which is what the fancy XOR - // does below. - if ((Z_LEVEL(point_opposite) >= level_index) ^ (level_index == 2)) - dir = Dir_Right; - else - dir = Dir_Left; - _cache[quad] |= visited_mask; - } - else { - // Calculate configuration of this quad. - long point_left = -1, point_right = -1; - switch (edge) { - case Edge_E: point_left = POINT_SW; point_right = POINT_NW; break; - case Edge_N: point_left = POINT_SE; point_right = POINT_SW; break; - case Edge_W: point_left = POINT_NE; point_right = POINT_SE; break; - case Edge_S: point_left = POINT_NW; point_right = POINT_NE; break; - default: assert(0 && "Invalid edge"); break; - } - - unsigned int config = (Z_LEVEL(point_left) >= level_index) << 1 | - (Z_LEVEL(point_right) >= level_index); - - // Upper level (level_index == 2) polygons are reversed compared to - // lower level ones, i.e. higher values on the right rather than - // the left. - if (level_index == 2) - config = 3 - config; - - // Calculate turn direction to move to next quad along contour line. - if (config == 1) { - // New saddle quad, set up cache bits for it. - double zmid = 0.25*(get_point_z(POINT_SW) + - get_point_z(POINT_SE) + - get_point_z(POINT_NW) + - get_point_z(POINT_NE)); - _cache[quad] |= (level_index == 1 ? MASK_SADDLE_1 : MASK_SADDLE_2); - if ((zmid > level) ^ (level_index == 2)) { - dir = Dir_Right; - } - else { - dir = Dir_Left; - _cache[quad] |= (level_index == 1 ? MASK_SADDLE_LEFT_1 - : MASK_SADDLE_LEFT_2); - } - if (edge == Edge_N || edge == Edge_E) { - // Next visit to this quad must start on S or W. - _cache[quad] |= (level_index == 1 ? MASK_SADDLE_START_SW_1 - : MASK_SADDLE_START_SW_2); - } - } - else { - // Normal (non-saddle) quad. - dir = (config == 0 ? Dir_Left - : (config == 3 ? Dir_Right : Dir_Straight)); - _cache[quad] |= visited_mask; - } - } - - // Use dir to determine exit edge. - edge = get_exit_edge(quad_edge, dir); - - if (set_parents) { - if (edge == Edge_E) - _parent_cache.set_parent(quad+1, contour_line); - else if (edge == Edge_W) - _parent_cache.set_parent(quad, contour_line); - } - - // Add new point to contour line. - contour_line.push_back(edge_interp(quad_edge, level)); - - // Stop if reached boundary. - if (is_edge_a_boundary(quad_edge)) - break; - - move_to_next_quad(quad_edge); - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - - // Return if reached start point of contour line. - if (start_quad_edge != 0 && - quad_edge == *start_quad_edge && - level_index == start_level_index) - break; - } -} - -void QuadContourGenerator::get_chunk_limits(long ijchunk, - long& ichunk, - long& jchunk, - long& istart, - long& iend, - long& jstart, - long& jend) -{ - assert(ijchunk >= 0 && ijchunk < _chunk_count && "ijchunk out of bounds"); - ichunk = ijchunk % _nxchunk; - jchunk = ijchunk / _nxchunk; - istart = ichunk*_chunk_size; - iend = (ichunk == _nxchunk-1 ? _nx : (ichunk+1)*_chunk_size); - jstart = jchunk*_chunk_size; - jend = (jchunk == _nychunk-1 ? _ny : (jchunk+1)*_chunk_size); -} - -Edge QuadContourGenerator::get_corner_start_edge(long quad, - unsigned int level_index) const -{ - assert(quad >= 0 && quad < _n && "Quad index out of bounds"); - assert((level_index == 1 || level_index == 2) && - "level index must be 1 or 2"); - assert(EXISTS_ANY_CORNER(quad) && "Quad is not a corner"); - - // Diagram for NE corner. Rotate for other corners. - // - // edge12 - // point1 +---------+ point2 - // \ | - // \ | edge23 - // edge31 \ | - // \ | - // + point3 - // - long point1, point2, point3; - Edge edge12, edge23, edge31; - switch (_cache[quad] & MASK_EXISTS) { - case MASK_EXISTS_SW_CORNER: - point1 = POINT_SE; point2 = POINT_SW; point3 = POINT_NW; - edge12 = Edge_S; edge23 = Edge_W; edge31 = Edge_NE; - break; - case MASK_EXISTS_SE_CORNER: - point1 = POINT_NE; point2 = POINT_SE; point3 = POINT_SW; - edge12 = Edge_E; edge23 = Edge_S; edge31 = Edge_NW; - break; - case MASK_EXISTS_NW_CORNER: - point1 = POINT_SW; point2 = POINT_NW; point3 = POINT_NE; - edge12 = Edge_W; edge23 = Edge_N; edge31 = Edge_SE; - break; - case MASK_EXISTS_NE_CORNER: - point1 = POINT_NW; point2 = POINT_NE; point3 = POINT_SE; - edge12 = Edge_N; edge23 = Edge_E; edge31 = Edge_SW; - break; - default: - assert(0 && "Invalid EXISTS for quad"); - return Edge_None; - } - - unsigned int config = (Z_LEVEL(point1) >= level_index) << 2 | - (Z_LEVEL(point2) >= level_index) << 1 | - (Z_LEVEL(point3) >= level_index); - - // Upper level (level_index == 2) polygons are reversed compared to lower - // level ones, i.e. higher values on the right rather than the left. - if (level_index == 2) - config = 7 - config; - - switch (config) { - case 0: return Edge_None; - case 1: return edge23; - case 2: return edge12; - case 3: return edge12; - case 4: return edge31; - case 5: return edge23; - case 6: return edge31; - case 7: return Edge_None; - default: assert(0 && "Invalid config"); return Edge_None; - } -} - -long QuadContourGenerator::get_edge_point_index(const QuadEdge& quad_edge, - bool start) const -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - - // Edges are ordered anticlockwise around their quad, as indicated by - // directions of arrows in diagrams below. - // Full quad NW corner (others similar) - // - // POINT_NW Edge_N POINT_NE POINT_NW Edge_N POINT_NE - // +----<-----+ +----<-----+ - // | | | / - // | | | quad / - // Edge_W V quad ^ Edge_E Edge_W V ^ - // | | | / Edge_SE - // | | | / - // +---->-----+ + - // POINT_SW Edge_S POINT_SE POINT_SW - // - const long& quad = quad_edge.quad; - switch (quad_edge.edge) { - case Edge_E: return (start ? POINT_SE : POINT_NE); - case Edge_N: return (start ? POINT_NE : POINT_NW); - case Edge_W: return (start ? POINT_NW : POINT_SW); - case Edge_S: return (start ? POINT_SW : POINT_SE); - case Edge_NE: return (start ? POINT_SE : POINT_NW); - case Edge_NW: return (start ? POINT_NE : POINT_SW); - case Edge_SW: return (start ? POINT_NW : POINT_SE); - case Edge_SE: return (start ? POINT_SW : POINT_NE); - default: assert(0 && "Invalid edge"); return 0; - } -} - -Edge QuadContourGenerator::get_exit_edge(const QuadEdge& quad_edge, - Dir dir) const -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - - const long& quad = quad_edge.quad; - const Edge& edge = quad_edge.edge; - if (EXISTS_ANY_CORNER(quad)) { - // Corner directions are always left or right. A corner is a triangle, - // entered via one edge so the other two edges are the left and right - // ones. - switch (edge) { - case Edge_E: - return (EXISTS_SE_CORNER(quad) - ? (dir == Dir_Left ? Edge_S : Edge_NW) - : (dir == Dir_Right ? Edge_N : Edge_SW)); - case Edge_N: - return (EXISTS_NW_CORNER(quad) - ? (dir == Dir_Right ? Edge_W : Edge_SE) - : (dir == Dir_Left ? Edge_E : Edge_SW)); - case Edge_W: - return (EXISTS_SW_CORNER(quad) - ? (dir == Dir_Right ? Edge_S : Edge_NE) - : (dir == Dir_Left ? Edge_N : Edge_SE)); - case Edge_S: - return (EXISTS_SW_CORNER(quad) - ? (dir == Dir_Left ? Edge_W : Edge_NE) - : (dir == Dir_Right ? Edge_E : Edge_NW)); - case Edge_NE: return (dir == Dir_Left ? Edge_S : Edge_W); - case Edge_NW: return (dir == Dir_Left ? Edge_E : Edge_S); - case Edge_SW: return (dir == Dir_Left ? Edge_N : Edge_E); - case Edge_SE: return (dir == Dir_Left ? Edge_W : Edge_N); - default: assert(0 && "Invalid edge"); return Edge_None; - } - } - else { - // A full quad has four edges, entered via one edge so that other three - // edges correspond to left, straight and right directions. - switch (edge) { - case Edge_E: - return (dir == Dir_Left ? Edge_S : - (dir == Dir_Right ? Edge_N : Edge_W)); - case Edge_N: - return (dir == Dir_Left ? Edge_E : - (dir == Dir_Right ? Edge_W : Edge_S)); - case Edge_W: - return (dir == Dir_Left ? Edge_N : - (dir == Dir_Right ? Edge_S : Edge_E)); - case Edge_S: - return (dir == Dir_Left ? Edge_W : - (dir == Dir_Right ? Edge_E : Edge_N)); - default: assert(0 && "Invalid edge"); return Edge_None; - } - } -} - -XY QuadContourGenerator::get_point_xy(long point) const -{ - assert(point >= 0 && point < _n && "Point index out of bounds."); - return XY(_x.data()[static_cast(point)], - _y.data()[static_cast(point)]); -} - -const double& QuadContourGenerator::get_point_z(long point) const -{ - assert(point >= 0 && point < _n && "Point index out of bounds."); - return _z.data()[static_cast(point)]; -} - -Edge QuadContourGenerator::get_quad_start_edge(long quad, - unsigned int level_index) const -{ - assert(quad >= 0 && quad < _n && "Quad index out of bounds"); - assert((level_index == 1 || level_index == 2) && - "level index must be 1 or 2"); - assert(EXISTS_QUAD(quad) && "Quad does not exist"); - - unsigned int config = (Z_NW >= level_index) << 3 | - (Z_NE >= level_index) << 2 | - (Z_SW >= level_index) << 1 | - (Z_SE >= level_index); - - // Upper level (level_index == 2) polygons are reversed compared to lower - // level ones, i.e. higher values on the right rather than the left. - if (level_index == 2) - config = 15 - config; - - switch (config) { - case 0: return Edge_None; - case 1: return Edge_E; - case 2: return Edge_S; - case 3: return Edge_E; - case 4: return Edge_N; - case 5: return Edge_N; - case 6: - // If already identified as a saddle quad then the start edge is - // read from the cache. Otherwise return either valid start edge - // and the subsequent call to follow_interior() will correctly set - // up saddle bits in cache. - if (!SADDLE(quad,level_index) || SADDLE_START_SW(quad,level_index)) - return Edge_S; - else - return Edge_N; - case 7: return Edge_N; - case 8: return Edge_W; - case 9: - // See comment for 6 above. - if (!SADDLE(quad,level_index) || SADDLE_START_SW(quad,level_index)) - return Edge_W; - else - return Edge_E; - case 10: return Edge_S; - case 11: return Edge_E; - case 12: return Edge_W; - case 13: return Edge_W; - case 14: return Edge_S; - case 15: return Edge_None; - default: assert(0 && "Invalid config"); return Edge_None; - } -} - -Edge QuadContourGenerator::get_start_edge(long quad, - unsigned int level_index) const -{ - if (EXISTS_ANY_CORNER(quad)) - return get_corner_start_edge(quad, level_index); - else - return get_quad_start_edge(quad, level_index); -} - -void QuadContourGenerator::init_cache_grid(const MaskArray& mask) -{ - long i, j, quad; - - if (mask.empty()) { - // No mask, easy to calculate quad existence and boundaries together. - quad = 0; - for (j = 0; j < _ny; ++j) { - for (i = 0; i < _nx; ++i, ++quad) { - _cache[quad] = 0; - - if (i < _nx-1 && j < _ny-1) - _cache[quad] |= MASK_EXISTS_QUAD; - - if ((i % _chunk_size == 0 || i == _nx-1) && j < _ny-1) - _cache[quad] |= MASK_BOUNDARY_W; - - if ((j % _chunk_size == 0 || j == _ny-1) && i < _nx-1) - _cache[quad] |= MASK_BOUNDARY_S; - } - } - } - else { - // Casting avoids problem when sizeof(bool) != sizeof(npy_bool). - const npy_bool* mask_ptr = - reinterpret_cast(mask.data()); - - // Have mask so use two stages. - // Stage 1, determine if quads/corners exist. - quad = 0; - for (j = 0; j < _ny; ++j) { - for (i = 0; i < _nx; ++i, ++quad) { - _cache[quad] = 0; - - if (i < _nx-1 && j < _ny-1) { - unsigned int config = mask_ptr[POINT_NW] << 3 | - mask_ptr[POINT_NE] << 2 | - mask_ptr[POINT_SW] << 1 | - mask_ptr[POINT_SE]; - - if (_corner_mask) { - switch (config) { - case 0: _cache[quad] = MASK_EXISTS_QUAD; break; - case 1: _cache[quad] = MASK_EXISTS_NW_CORNER; break; - case 2: _cache[quad] = MASK_EXISTS_NE_CORNER; break; - case 4: _cache[quad] = MASK_EXISTS_SW_CORNER; break; - case 8: _cache[quad] = MASK_EXISTS_SE_CORNER; break; - default: - // Do nothing, quad is masked out. - break; - } - } - else if (config == 0) - _cache[quad] = MASK_EXISTS_QUAD; - } - } - } - - // Stage 2, calculate W and S boundaries. For each quad use boundary - // data already calculated for quads to W and S, so must iterate - // through quads in correct order (increasing i and j indices). - // Cannot use boundary data for quads to E and N as have not yet - // calculated it. - quad = 0; - for (j = 0; j < _ny; ++j) { - for (i = 0; i < _nx; ++i, ++quad) { - if (_corner_mask) { - bool W_exists_none = (i == 0 || EXISTS_NONE(quad-1)); - bool S_exists_none = (j == 0 || EXISTS_NONE(quad-_nx)); - bool W_exists_E_edge = (i > 0 && EXISTS_E_EDGE(quad-1)); - bool S_exists_N_edge = (j > 0 && EXISTS_N_EDGE(quad-_nx)); - - if ((EXISTS_W_EDGE(quad) && W_exists_none) || - (EXISTS_NONE(quad) && W_exists_E_edge) || - (i % _chunk_size == 0 && EXISTS_W_EDGE(quad) && - W_exists_E_edge)) - _cache[quad] |= MASK_BOUNDARY_W; - - if ((EXISTS_S_EDGE(quad) && S_exists_none) || - (EXISTS_NONE(quad) && S_exists_N_edge) || - (j % _chunk_size == 0 && EXISTS_S_EDGE(quad) && - S_exists_N_edge)) - _cache[quad] |= MASK_BOUNDARY_S; - } - else { - bool W_exists_quad = (i > 0 && EXISTS_QUAD(quad-1)); - bool S_exists_quad = (j > 0 && EXISTS_QUAD(quad-_nx)); - - if ((EXISTS_QUAD(quad) != W_exists_quad) || - (i % _chunk_size == 0 && EXISTS_QUAD(quad) && - W_exists_quad)) - _cache[quad] |= MASK_BOUNDARY_W; - - if ((EXISTS_QUAD(quad) != S_exists_quad) || - (j % _chunk_size == 0 && EXISTS_QUAD(quad) && - S_exists_quad)) - _cache[quad] |= MASK_BOUNDARY_S; - } - } - } - } -} - -void QuadContourGenerator::init_cache_levels(const double& lower_level, - const double& upper_level) -{ - assert(upper_level >= lower_level && - "upper and lower levels are wrong way round"); - - bool two_levels = (lower_level != upper_level); - CacheItem keep_mask = - (_corner_mask ? MASK_EXISTS | MASK_BOUNDARY_S | MASK_BOUNDARY_W - : MASK_EXISTS_QUAD | MASK_BOUNDARY_S | MASK_BOUNDARY_W); - - if (two_levels) { - const double* z_ptr = _z.data(); - for (long quad = 0; quad < _n; ++quad, ++z_ptr) { - _cache[quad] &= keep_mask; - if (*z_ptr > upper_level) - _cache[quad] |= MASK_Z_LEVEL_2; - else if (*z_ptr > lower_level) - _cache[quad] |= MASK_Z_LEVEL_1; - } - } - else { - const double* z_ptr = _z.data(); - for (long quad = 0; quad < _n; ++quad, ++z_ptr) { - _cache[quad] &= keep_mask; - if (*z_ptr > lower_level) - _cache[quad] |= MASK_Z_LEVEL_1; - } - } -} - -XY QuadContourGenerator::interp( - long point1, long point2, const double& level) const -{ - assert(point1 >= 0 && point1 < _n && "Point index 1 out of bounds."); - assert(point2 >= 0 && point2 < _n && "Point index 2 out of bounds."); - assert(point1 != point2 && "Identical points"); - double fraction = (get_point_z(point2) - level) / - (get_point_z(point2) - get_point_z(point1)); - return get_point_xy(point1)*fraction + get_point_xy(point2)*(1.0 - fraction); -} - -bool QuadContourGenerator::is_edge_a_boundary(const QuadEdge& quad_edge) const -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - - switch (quad_edge.edge) { - case Edge_E: return BOUNDARY_E(quad_edge.quad); - case Edge_N: return BOUNDARY_N(quad_edge.quad); - case Edge_W: return BOUNDARY_W(quad_edge.quad); - case Edge_S: return BOUNDARY_S(quad_edge.quad); - case Edge_NE: return EXISTS_SW_CORNER(quad_edge.quad); - case Edge_NW: return EXISTS_SE_CORNER(quad_edge.quad); - case Edge_SW: return EXISTS_NE_CORNER(quad_edge.quad); - case Edge_SE: return EXISTS_NW_CORNER(quad_edge.quad); - default: assert(0 && "Invalid edge"); return true; - } -} - -void QuadContourGenerator::move_to_next_boundary_edge(QuadEdge& quad_edge) const -{ - assert(is_edge_a_boundary(quad_edge) && "QuadEdge is not a boundary"); - - long& quad = quad_edge.quad; - Edge& edge = quad_edge.edge; - - quad = get_edge_point_index(quad_edge, false); - - // quad is now such that POINT_SW is the end point of the quad_edge passed - // to this function. - - // To find the next boundary edge, first attempt to turn left 135 degrees - // and if that edge is a boundary then move to it. If not, attempt to turn - // left 90 degrees, then left 45 degrees, then straight on, etc, until can - // move. - // First determine which edge to attempt first. - int index = 0; - switch (edge) { - case Edge_E: index = 0; break; - case Edge_SE: index = 1; break; - case Edge_S: index = 2; break; - case Edge_SW: index = 3; break; - case Edge_W: index = 4; break; - case Edge_NW: index = 5; break; - case Edge_N: index = 6; break; - case Edge_NE: index = 7; break; - default: assert(0 && "Invalid edge"); break; - } - - // If _corner_mask not set, only need to consider odd index in loop below. - if (!_corner_mask) - ++index; - - // Try each edge in turn until a boundary is found. - int start_index = index; - do - { - switch (index) { - case 0: - if (EXISTS_SE_CORNER(quad-_nx-1)) { // Equivalent to BOUNDARY_NW - quad -= _nx+1; - edge = Edge_NW; - return; - } - break; - case 1: - if (BOUNDARY_N(quad-_nx-1)) { - quad -= _nx+1; - edge = Edge_N; - return; - } - break; - case 2: - if (EXISTS_SW_CORNER(quad-1)) { // Equivalent to BOUNDARY_NE - quad -= 1; - edge = Edge_NE; - return; - } - break; - case 3: - if (BOUNDARY_E(quad-1)) { - quad -= 1; - edge = Edge_E; - return; - } - break; - case 4: - if (EXISTS_NW_CORNER(quad)) { // Equivalent to BOUNDARY_SE - edge = Edge_SE; - return; - } - break; - case 5: - if (BOUNDARY_S(quad)) { - edge = Edge_S; - return; - } - break; - case 6: - if (EXISTS_NE_CORNER(quad-_nx)) { // Equivalent to BOUNDARY_SW - quad -= _nx; - edge = Edge_SW; - return; - } - break; - case 7: - if (BOUNDARY_W(quad-_nx)) { - quad -= _nx; - edge = Edge_W; - return; - } - break; - default: assert(0 && "Invalid index"); break; - } - - if (_corner_mask) - index = (index + 1) % 8; - else - index = (index + 2) % 8; - } while (index != start_index); - - assert(0 && "Failed to find next boundary edge"); -} - -void QuadContourGenerator::move_to_next_quad(QuadEdge& quad_edge) const -{ - assert(quad_edge.quad >= 0 && quad_edge.quad < _n && - "Quad index out of bounds"); - assert(quad_edge.edge != Edge_None && "Invalid edge"); - - // Move from quad_edge.quad to the neighbouring quad in the direction - // specified by quad_edge.edge. - switch (quad_edge.edge) { - case Edge_E: quad_edge.quad += 1; quad_edge.edge = Edge_W; break; - case Edge_N: quad_edge.quad += _nx; quad_edge.edge = Edge_S; break; - case Edge_W: quad_edge.quad -= 1; quad_edge.edge = Edge_E; break; - case Edge_S: quad_edge.quad -= _nx; quad_edge.edge = Edge_N; break; - default: assert(0 && "Invalid edge"); break; - } -} - -void QuadContourGenerator::single_quad_filled(Contour& contour, - long quad, - const double& lower_level, - const double& upper_level) -{ - assert(quad >= 0 && quad < _n && "Quad index out of bounds"); - - // Order of checking is important here as can have different ContourLines - // from both lower and upper levels in the same quad. First check the S - // edge, then move up the quad to the N edge checking as required. - - // Possible starts from S boundary. - if (BOUNDARY_S(quad) && EXISTS_S_EDGE(quad)) { - - // Lower-level start from S boundary into interior. - if (!VISITED_S(quad) && Z_SW >= 1 && Z_SE == 0) - contour.push_back(start_filled(quad, Edge_S, 1, NotHole, Interior, - lower_level, upper_level)); - - // Upper-level start from S boundary into interior. - if (!VISITED_S(quad) && Z_SW < 2 && Z_SE == 2) - contour.push_back(start_filled(quad, Edge_S, 2, NotHole, Interior, - lower_level, upper_level)); - - // Lower-level start following S boundary from W to E. - if (!VISITED_S(quad) && Z_SW <= 1 && Z_SE == 1) - contour.push_back(start_filled(quad, Edge_S, 1, NotHole, Boundary, - lower_level, upper_level)); - - // Upper-level start following S boundary from W to E. - if (!VISITED_S(quad) && Z_SW == 2 && Z_SE == 1) - contour.push_back(start_filled(quad, Edge_S, 2, NotHole, Boundary, - lower_level, upper_level)); - } - - // Possible starts from W boundary. - if (BOUNDARY_W(quad) && EXISTS_W_EDGE(quad)) { - - // Lower-level start from W boundary into interior. - if (!VISITED_W(quad) && Z_NW >= 1 && Z_SW == 0) - contour.push_back(start_filled(quad, Edge_W, 1, NotHole, Interior, - lower_level, upper_level)); - - // Upper-level start from W boundary into interior. - if (!VISITED_W(quad) && Z_NW < 2 && Z_SW == 2) - contour.push_back(start_filled(quad, Edge_W, 2, NotHole, Interior, - lower_level, upper_level)); - - // Lower-level start following W boundary from N to S. - if (!VISITED_W(quad) && Z_NW <= 1 && Z_SW == 1) - contour.push_back(start_filled(quad, Edge_W, 1, NotHole, Boundary, - lower_level, upper_level)); - - // Upper-level start following W boundary from N to S. - if (!VISITED_W(quad) && Z_NW == 2 && Z_SW == 1) - contour.push_back(start_filled(quad, Edge_W, 2, NotHole, Boundary, - lower_level, upper_level)); - } - - // Possible starts from NE boundary. - if (EXISTS_SW_CORNER(quad)) { // i.e. BOUNDARY_NE - - // Lower-level start following NE boundary from SE to NW, hole. - if (!VISITED_CORNER(quad) && Z_NW == 1 && Z_SE == 1) - contour.push_back(start_filled(quad, Edge_NE, 1, Hole, Boundary, - lower_level, upper_level)); - } - // Possible starts from SE boundary. - else if (EXISTS_NW_CORNER(quad)) { // i.e. BOUNDARY_SE - - // Lower-level start from N to SE. - if (!VISITED(quad,1) && Z_NW == 0 && Z_SW == 0 && Z_NE >= 1) - contour.push_back(start_filled(quad, Edge_N, 1, NotHole, Interior, - lower_level, upper_level)); - - // Upper-level start from SE to N, hole. - if (!VISITED(quad,2) && Z_NW < 2 && Z_SW < 2 && Z_NE == 2) - contour.push_back(start_filled(quad, Edge_SE, 2, Hole, Interior, - lower_level, upper_level)); - - // Upper-level start from N to SE. - if (!VISITED(quad,2) && Z_NW == 2 && Z_SW == 2 && Z_NE < 2) - contour.push_back(start_filled(quad, Edge_N, 2, NotHole, Interior, - lower_level, upper_level)); - - // Lower-level start from SE to N, hole. - if (!VISITED(quad,1) && Z_NW >= 1 && Z_SW >= 1 && Z_NE == 0) - contour.push_back(start_filled(quad, Edge_SE, 1, Hole, Interior, - lower_level, upper_level)); - } - // Possible starts from NW boundary. - else if (EXISTS_SE_CORNER(quad)) { // i.e. BOUNDARY_NW - - // Lower-level start from NW to E. - if (!VISITED(quad,1) && Z_SW == 0 && Z_SE == 0 && Z_NE >= 1) - contour.push_back(start_filled(quad, Edge_NW, 1, NotHole, Interior, - lower_level, upper_level)); - - // Upper-level start from E to NW, hole. - if (!VISITED(quad,2) && Z_SW < 2 && Z_SE < 2 && Z_NE == 2) - contour.push_back(start_filled(quad, Edge_E, 2, Hole, Interior, - lower_level, upper_level)); - - // Upper-level start from NW to E. - if (!VISITED(quad,2) && Z_SW == 2 && Z_SE == 2 && Z_NE < 2) - contour.push_back(start_filled(quad, Edge_NW, 2, NotHole, Interior, - lower_level, upper_level)); - - // Lower-level start from E to NW, hole. - if (!VISITED(quad,1) && Z_SW >= 1 && Z_SE >= 1 && Z_NE == 0) - contour.push_back(start_filled(quad, Edge_E, 1, Hole, Interior, - lower_level, upper_level)); - } - // Possible starts from SW boundary. - else if (EXISTS_NE_CORNER(quad)) { // i.e. BOUNDARY_SW - - // Lower-level start from SW boundary into interior. - if (!VISITED_CORNER(quad) && Z_NW >= 1 && Z_SE == 0) - contour.push_back(start_filled(quad, Edge_SW, 1, NotHole, Interior, - lower_level, upper_level)); - - // Upper-level start from SW boundary into interior. - if (!VISITED_CORNER(quad) && Z_NW < 2 && Z_SE == 2) - contour.push_back(start_filled(quad, Edge_SW, 2, NotHole, Interior, - lower_level, upper_level)); - - // Lower-level start following SW boundary from NW to SE. - if (!VISITED_CORNER(quad) && Z_NW <= 1 && Z_SE == 1) - contour.push_back(start_filled(quad, Edge_SW, 1, NotHole, Boundary, - lower_level, upper_level)); - - // Upper-level start following SW boundary from NW to SE. - if (!VISITED_CORNER(quad) && Z_NW == 2 && Z_SE == 1) - contour.push_back(start_filled(quad, Edge_SW, 2, NotHole, Boundary, - lower_level, upper_level)); - } - - // A full (unmasked) quad can only have a start on the NE corner, i.e. from - // N to E (lower level) or E to N (upper level). Any other start will have - // already been created in a call to this function for a prior quad so we - // don't need to test for it again here. - // - // The situation is complicated by the possibility that the quad is a - // saddle quad, in which case a contour line starting on the N could leave - // by either the W or the E. We only need to consider those leaving E. - // - // A NE corner can also have a N to E or E to N start. - if (EXISTS_QUAD(quad) || EXISTS_NE_CORNER(quad)) { - - // Lower-level start from N to E. - if (!VISITED(quad,1) && Z_NW == 0 && Z_SE == 0 && Z_NE >= 1 && - (!SADDLE(quad,1) || SADDLE_LEFT(quad,1))) - contour.push_back(start_filled(quad, Edge_N, 1, NotHole, Interior, - lower_level, upper_level)); - - // Upper-level start from E to N, hole. - if (!VISITED(quad,2) && Z_NW < 2 && Z_SE < 2 && Z_NE == 2 && - (!SADDLE(quad,2) || !SADDLE_LEFT(quad,2))) - contour.push_back(start_filled(quad, Edge_E, 2, Hole, Interior, - lower_level, upper_level)); - - // Upper-level start from N to E. - if (!VISITED(quad,2) && Z_NW == 2 && Z_SE == 2 && Z_NE < 2 && - (!SADDLE(quad,2) || SADDLE_LEFT(quad,2))) - contour.push_back(start_filled(quad, Edge_N, 2, NotHole, Interior, - lower_level, upper_level)); - - // Lower-level start from E to N, hole. - if (!VISITED(quad,1) && Z_NW >= 1 && Z_SE >= 1 && Z_NE == 0 && - (!SADDLE(quad,1) || !SADDLE_LEFT(quad,1))) - contour.push_back(start_filled(quad, Edge_E, 1, Hole, Interior, - lower_level, upper_level)); - - // All possible contours passing through the interior of this quad - // should have already been created, so assert this. - assert((VISITED(quad,1) || get_start_edge(quad, 1) == Edge_None) && - "Found start of contour that should have already been created"); - assert((VISITED(quad,2) || get_start_edge(quad, 2) == Edge_None) && - "Found start of contour that should have already been created"); - } - - // Lower-level start following N boundary from E to W, hole. - // This is required for an internal masked region which is a hole in a - // surrounding contour line. - if (BOUNDARY_N(quad) && EXISTS_N_EDGE(quad) && - !VISITED_S(quad+_nx) && Z_NW == 1 && Z_NE == 1) - contour.push_back(start_filled(quad, Edge_N, 1, Hole, Boundary, - lower_level, upper_level)); -} - -ContourLine* QuadContourGenerator::start_filled( - long quad, - Edge edge, - unsigned int start_level_index, - HoleOrNot hole_or_not, - BoundaryOrInterior boundary_or_interior, - const double& lower_level, - const double& upper_level) -{ - assert(quad >= 0 && quad < _n && "Quad index out of bounds"); - assert(edge != Edge_None && "Invalid edge"); - assert((start_level_index == 1 || start_level_index == 2) && - "start level index must be 1 or 2"); - - ContourLine* contour_line = new ContourLine(hole_or_not == Hole); - if (hole_or_not == Hole) { - // Find and set parent ContourLine. - ContourLine* parent = _parent_cache.get_parent(quad + 1); - assert(parent != 0 && "Failed to find parent ContourLine"); - contour_line->set_parent(parent); - parent->add_child(contour_line); - } - - QuadEdge quad_edge(quad, edge); - const QuadEdge start_quad_edge(quad_edge); - unsigned int level_index = start_level_index; - - // If starts on interior, can only finish on interior. - // If starts on boundary, can only finish on boundary. - - while (true) { - if (boundary_or_interior == Interior) { - double level = (level_index == 1 ? lower_level : upper_level); - follow_interior(*contour_line, quad_edge, level_index, level, - false, &start_quad_edge, start_level_index, true); - } - else { - level_index = follow_boundary( - *contour_line, quad_edge, lower_level, - upper_level, level_index, start_quad_edge); - } - - if (quad_edge == start_quad_edge && (boundary_or_interior == Boundary || - level_index == start_level_index)) - break; - - if (boundary_or_interior == Boundary) - boundary_or_interior = Interior; - else - boundary_or_interior = Boundary; - } - - return contour_line; -} - -bool QuadContourGenerator::start_line( - PyObject* vertices_list, PyObject* codes_list, long quad, Edge edge, - const double& level) -{ - assert(vertices_list != 0 && "Null python vertices list"); - assert(is_edge_a_boundary(QuadEdge(quad, edge)) && - "QuadEdge is not a boundary"); - - QuadEdge quad_edge(quad, edge); - ContourLine contour_line(false); - follow_interior(contour_line, quad_edge, 1, level, true, 0, 1, false); - - append_contour_line_to_vertices_and_codes( - contour_line, vertices_list, codes_list); - - return VISITED(quad,1); -} - -void QuadContourGenerator::write_cache(bool grid_only) const -{ - std::cout << "-----------------------------------------------" << std::endl; - for (long quad = 0; quad < _n; ++quad) - write_cache_quad(quad, grid_only); - std::cout << "-----------------------------------------------" << std::endl; -} - -void QuadContourGenerator::write_cache_quad(long quad, bool grid_only) const -{ - long j = quad / _nx; - long i = quad - j*_nx; - std::cout << quad << ": i=" << i << " j=" << j - << " EXISTS=" << EXISTS_QUAD(quad); - if (_corner_mask) - std::cout << " CORNER=" << EXISTS_SW_CORNER(quad) << EXISTS_SE_CORNER(quad) - << EXISTS_NW_CORNER(quad) << EXISTS_NE_CORNER(quad); - std::cout << " BNDY=" << (BOUNDARY_S(quad)>0) << (BOUNDARY_W(quad)>0); - if (!grid_only) { - std::cout << " Z=" << Z_LEVEL(quad) - << " SAD=" << SADDLE(quad,1) << SADDLE(quad,2) - << " LEFT=" << SADDLE_LEFT(quad,1) << SADDLE_LEFT(quad,2) - << " NW=" << SADDLE_START_SW(quad,1) << SADDLE_START_SW(quad,2) - << " VIS=" << VISITED(quad,1) << VISITED(quad,2) - << VISITED_S(quad) << VISITED_W(quad) - << VISITED_CORNER(quad); - } - std::cout << std::endl; -} diff --git a/src/_contour.h b/src/_contour.h deleted file mode 100644 index 6f1931db0e79..000000000000 --- a/src/_contour.h +++ /dev/null @@ -1,533 +0,0 @@ -/* - * QuadContourGenerator - * -------------------- - * A QuadContourGenerator generates contours for scalar fields defined on - * quadrilateral grids. A single QuadContourGenerator object can create both - * line contours (at single levels) and filled contours (between pairs of - * levels) for the same field. - * - * A field to be contoured has nx, ny points in the x- and y-directions - * respectively. The quad grid is defined by x and y arrays of shape(ny, nx), - * and the field itself is the z array also of shape(ny, nx). There is an - * optional boolean mask; if it exists then it also has shape(ny, nx). The - * mask applies to grid points rather than quads. - * - * How quads are masked based on the point mask is determined by the boolean - * 'corner_mask' flag. If false then any quad that has one or more of its four - * corner points masked is itself masked. If true the behaviour is the same - * except that any quad which has exactly one of its four corner points masked - * has only the triangular corner (half of the quad) adjacent to that point - * masked; the opposite triangular corner has three unmasked points and is not - * masked. - * - * By default the entire domain of nx*ny points is contoured together which can - * result in some very long polygons. The alternative is to break up the - * domain into subdomains or 'chunks' of smaller size, each of which is - * independently contoured. The size of these chunks is controlled by the - * 'nchunk' (or 'chunk_size') parameter. Chunking not only results in shorter - * polygons but also requires slightly less RAM. It can result in rendering - * artifacts though, depending on backend, antialiased flag and alpha value. - * - * Notation - * -------- - * i and j are array indices in the x- and y-directions respectively. Although - * a single element of an array z can be accessed using z[j][i] or z(j,i), it - * is often convenient to use the single quad index z[quad], where - * quad = i + j*nx - * and hence - * i = quad % nx - * j = quad / nx - * - * Rather than referring to x- and y-directions, compass directions are used - * instead such that W, E, S, N refer to the -x, +x, -y, +y directions - * respectively. To move one quad to the E you would therefore add 1 to the - * quad index, to move one quad to the N you would add nx to the quad index. - * - * Cache - * ----- - * Lots of information that is reused during contouring is stored as single - * bits in a mesh-sized cache, indexed by quad. Each quad's cache entry stores - * information about the quad itself such as if it is masked, and about the - * point at the SW corner of the quad, and about the W and S edges. Hence - * information about each point and each edge is only stored once in the cache. - * - * Cache information is divided into two types: that which is constant over the - * lifetime of the QuadContourGenerator, and that which changes for each - * contouring operation. The former is all grid-specific information such - * as quad and corner masks, and which edges are boundaries, either between - * masked and non-masked regions or between adjacent chunks. The latter - * includes whether points lie above or below the current contour levels, plus - * some flags to indicate how the contouring is progressing. - * - * Line Contours - * ------------- - * A line contour connects points with the same z-value. Each point of such a - * contour occurs on an edge of the grid, at a point linearly interpolated to - * the contour z-level from the z-values at the end points of the edge. The - * direction of a line contour is such that higher values are to the left of - * the contour, so any edge that the contour passes through will have a left- - * hand end point with z > contour level and a right-hand end point with - * z <= contour level. - * - * Line contours are of two types. Firstly there are open line strips that - * start on a boundary, traverse the interior of the domain and end on a - * boundary. Secondly there are closed line loops that occur completely within - * the interior of the domain and do not touch a boundary. - * - * The QuadContourGenerator makes two sweeps through the grid to generate line - * contours for a particular level. In the first sweep it looks only for start - * points that occur on boundaries, and when it finds one it follows the - * contour through the interior until it finishes on another boundary edge. - * Each quad that is visited by the algorithm has a 'visited' flag set in the - * cache to indicate that the quad does not need to be visited again. In the - * second sweep all non-visited quads are checked to see if they contain part - * of an interior closed loop, and again each time one is found it is followed - * through the domain interior until it returns back to its start quad and is - * therefore completed. - * - * The situation is complicated by saddle quads that have two opposite corners - * with z >= contour level and the other two corners with z < contour level. - * These therefore contain two segments of a line contour, and the visited - * flags take account of this by only being set on the second visit. On the - * first visit a number of saddle flags are set in the cache to indicate which - * one of the two segments has been completed so far. - * - * Filled Contours - * --------------- - * Filled contours are produced between two contour levels and are always - * closed polygons. They can occur completely within the interior of the - * domain without touching a boundary, following either the lower or upper - * contour levels. Those on the lower level are exactly like interior line - * contours with higher values on the left. Those on the upper level are - * reversed such that higher values are on the right. - * - * Filled contours can also involve a boundary in which case they consist of - * one or more sections along a boundary and one or more sections through the - * interior. Interior sections can be on either level, and again those on the - * upper level have higher values on the right. Boundary sections can remain - * on either contour level or switch between the two. - * - * Once the start of a filled contour is found, the algorithm is similar to - * that for line contours in that it follows the contour to its end, which - * because filled contours are always closed polygons will be by returning - * back to the start. However, because two levels must be considered, each - * level has its own set of saddle and visited flags and indeed some extra - * visited flags for boundary edges. - * - * The major complication for filled contours is that some polygons can be - * holes (with points ordered clockwise) within other polygons (with points - * ordered anticlockwise). When it comes to rendering filled contours each - * non-hole polygon must be rendered along with its zero or more contained - * holes or the rendering will not be correct. The filled contour finding - * algorithm could progress pretty much as the line contour algorithm does, - * taking each polygon as it is found, but then at the end there would have to - * be an extra step to identify the parent non-hole polygon for each hole. - * This is not a particularly onerous task but it does not scale well and can - * easily dominate the execution time of the contour finding for even modest - * problems. It is much better to identity each hole's parent non-hole during - * the sweep algorithm. - * - * This requirement dictates the order that filled contours are identified. As - * the algorithm sweeps up through the grid, every time a polygon passes - * through a quad a ParentCache object is updated with the new possible parent. - * When a new hole polygon is started, the ParentCache is used to find the - * first possible parent in the same quad or to the S of it. Great care is - * needed each time a new quad is checked to see if a new polygon should be - * started, as a single quad can have multiple polygon starts, e.g. a quad - * could be a saddle quad for both lower and upper contour levels, meaning it - * has four contour line segments passing through it which could all be from - * different polygons. The S-most polygon must be started first, then the next - * S-most and so on until the N-most polygon is started in that quad. - */ -#ifndef MPL_CONTOUR_H -#define MPL_CONTOUR_H - -#include "numpy_cpp.h" -#include -#include -#include -#include - - -// Edge of a quad including diagonal edges of masked quads if _corner_mask true. -typedef enum -{ - // Listing values here so easier to check for debug purposes. - Edge_None = -1, - Edge_E = 0, - Edge_N = 1, - Edge_W = 2, - Edge_S = 3, - // The following are only used if _corner_mask is true. - Edge_NE = 4, - Edge_NW = 5, - Edge_SW = 6, - Edge_SE = 7 -} Edge; - -// Combination of a quad and an edge of that quad. -// An invalid quad edge has quad of -1. -struct QuadEdge -{ - QuadEdge(); - QuadEdge(long quad_, Edge edge_); - bool operator<(const QuadEdge& other) const; - bool operator==(const QuadEdge& other) const; - bool operator!=(const QuadEdge& other) const; - friend std::ostream& operator<<(std::ostream& os, - const QuadEdge& quad_edge); - - long quad; - Edge edge; -}; - -// 2D point with x,y coordinates. -struct XY -{ - XY(); - XY(const double& x_, const double& y_); - bool operator==(const XY& other) const; - bool operator!=(const XY& other) const; - XY operator*(const double& multiplier) const; - const XY& operator+=(const XY& other); - const XY& operator-=(const XY& other); - XY operator+(const XY& other) const; - XY operator-(const XY& other) const; - friend std::ostream& operator<<(std::ostream& os, const XY& xy); - - double x, y; -}; - -// A single line of a contour, which may be a closed line loop or an open line -// strip. Identical adjacent points are avoided using push_back(). -// A ContourLine is either a hole (points ordered clockwise) or it is not -// (points ordered anticlockwise). Each hole has a parent ContourLine that is -// not a hole; each non-hole contains zero or more child holes. A non-hole and -// its child holes must be rendered together to obtain the correct results. -class ContourLine : public std::vector -{ -public: - typedef std::list Children; - - ContourLine(bool is_hole); - void add_child(ContourLine* child); - void clear_parent(); - const Children& get_children() const; - const ContourLine* get_parent() const; - ContourLine* get_parent(); - bool is_hole() const; - void push_back(const XY& point); - void set_parent(ContourLine* parent); - void write() const; - -private: - bool _is_hole; - ContourLine* _parent; // Only set if is_hole, not owned. - Children _children; // Only set if !is_hole, not owned. -}; - - -// A Contour is a collection of zero or more ContourLines. -class Contour : public std::vector -{ -public: - Contour(); - virtual ~Contour(); - void delete_contour_lines(); - void write() const; -}; - - -// Single chunk of ContourLine parents, indexed by quad. As a chunk's filled -// contours are created, the ParentCache is updated each time a ContourLine -// passes through each quad. When a new ContourLine is created, if it is a -// hole its parent ContourLine is read from the ParentCache by looking at the -// start quad, then each quad to the S in turn until a non-zero ContourLine is -// found. -class ParentCache -{ -public: - ParentCache(long nx, long x_chunk_points, long y_chunk_points); - ContourLine* get_parent(long quad); - void set_chunk_starts(long istart, long jstart); - void set_parent(long quad, ContourLine& contour_line); - -private: - long quad_to_index(long quad) const; - - long _nx; - long _x_chunk_points, _y_chunk_points; // Number of points not quads. - std::vector _lines; // Not owned. - long _istart, _jstart; -}; - - -// See overview of algorithm at top of file. -class QuadContourGenerator -{ -public: - typedef numpy::array_view CoordinateArray; - typedef numpy::array_view MaskArray; - - // Constructor with optional mask. - // x, y, z: double arrays of shape (ny,nx). - // mask: boolean array, ether empty (if no mask), or of shape (ny,nx). - // corner_mask: flag for different masking behaviour. - // chunk_size: 0 for no chunking, or +ve integer for size of chunks that - // the domain is subdivided into. - QuadContourGenerator(const CoordinateArray& x, - const CoordinateArray& y, - const CoordinateArray& z, - const MaskArray& mask, - bool corner_mask, - long chunk_size); - - // Destructor. - ~QuadContourGenerator(); - - // Create and return polygons for a line (i.e. non-filled) contour at the - // specified level. - PyObject* create_contour(const double& level); - - // Create and return polygons for a filled contour between the two - // specified levels. - PyObject* create_filled_contour(const double& lower_level, - const double& upper_level); - -private: - // Typedef for following either a boundary of the domain or the interior; - // clearer than using a boolean. - typedef enum - { - Boundary, - Interior - } BoundaryOrInterior; - - // Typedef for direction of movement from one quad to the next. - typedef enum - { - Dir_Right = -1, - Dir_Straight = 0, - Dir_Left = +1 - } Dir; - - // Typedef for a polygon being a hole or not; clearer than using a boolean. - typedef enum - { - NotHole, - Hole - } HoleOrNot; - - // Append a C++ ContourLine to the end of two python lists. Used for line - // contours where each ContourLine is converted to a separate numpy array - // of (x,y) points. - // Clears the ContourLine too. - void append_contour_line_to_vertices_and_codes(ContourLine& contour_line, - PyObject* vertices_list, - PyObject* codes_list) const; - - // Append a C++ Contour to the end of two python lists. Used for filled - // contours where each non-hole ContourLine and its child holes are - // represented by a numpy array of (x,y) points and a second numpy array of - // 'kinds' or 'codes' that indicates where the points array is split into - // individual polygons. - // Clears the Contour too, freeing each ContourLine as soon as possible - // for minimum RAM usage. - void append_contour_to_vertices_and_codes(Contour& contour, - PyObject* vertices_list, - PyObject* codes_list) const; - - // Return number of chunks that fit in the specified point_count. - long calc_chunk_count(long point_count) const; - - // Return the point on the specified QuadEdge that intersects the specified - // level. - XY edge_interp(const QuadEdge& quad_edge, const double& level); - - // Follow a contour along a boundary, appending points to the ContourLine - // as it progresses. Only called for filled contours. Stops when the - // contour leaves the boundary to move into the interior of the domain, or - // when the start_quad_edge is reached in which case the ContourLine is a - // completed closed loop. Always adds the end point of each boundary edge - // to the ContourLine, regardless of whether moving to another boundary - // edge or leaving the boundary into the interior. Never adds the start - // point of the first boundary edge to the ContourLine. - // contour_line: ContourLine to append points to. - // quad_edge: on entry the QuadEdge to start from, on exit the QuadEdge - // that is stopped on. - // lower_level: lower contour z-value. - // upper_level: upper contour z-value. - // level_index: level index started on (1 = lower, 2 = upper level). - // start_quad_edge: QuadEdge that the ContourLine started from, which is - // used to check if the ContourLine is finished. - // Returns the end level_index. - unsigned int follow_boundary(ContourLine& contour_line, - QuadEdge& quad_edge, - const double& lower_level, - const double& upper_level, - unsigned int level_index, - const QuadEdge& start_quad_edge); - - // Follow a contour across the interior of the domain, appending points to - // the ContourLine as it progresses. Called for both line and filled - // contours. Stops when the contour reaches a boundary or, if the - // start_quad_edge is specified, when quad_edge == start_quad_edge and - // level_index == start_level_index. Always adds the end point of each - // quad traversed to the ContourLine; only adds the start point of the - // first quad if want_initial_point flag is true. - // contour_line: ContourLine to append points to. - // quad_edge: on entry the QuadEdge to start from, on exit the QuadEdge - // that is stopped on. - // level_index: level index started on (1 = lower, 2 = upper level). - // level: contour z-value. - // want_initial_point: whether want to append the initial point to the - // ContourLine or not. - // start_quad_edge: the QuadEdge that the ContourLine started from to - // check if the ContourLine is finished, or 0 if no check should occur. - // start_level_index: the level_index that the ContourLine started from. - // set_parents: whether should set ParentCache as it progresses or not. - // This is true for filled contours, false for line contours. - void follow_interior(ContourLine& contour_line, - QuadEdge& quad_edge, - unsigned int level_index, - const double& level, - bool want_initial_point, - const QuadEdge* start_quad_edge, - unsigned int start_level_index, - bool set_parents); - - // Return the index limits of a particular chunk. - void get_chunk_limits(long ijchunk, - long& ichunk, - long& jchunk, - long& istart, - long& iend, - long& jstart, - long& jend); - - // Check if a contour starts within the specified corner quad on the - // specified level_index, and if so return the start edge. Otherwise - // return Edge_None. - Edge get_corner_start_edge(long quad, unsigned int level_index) const; - - // Return index of point at start or end of specified QuadEdge, assuming - // anticlockwise ordering around non-masked quads. - long get_edge_point_index(const QuadEdge& quad_edge, bool start) const; - - // Return the edge to exit a quad from, given the specified entry quad_edge - // and direction to move in. - Edge get_exit_edge(const QuadEdge& quad_edge, Dir dir) const; - - // Return the (x,y) coordinates of the specified point index. - XY get_point_xy(long point) const; - - // Return the z-value of the specified point index. - const double& get_point_z(long point) const; - - // Check if a contour starts within the specified non-corner quad on the - // specified level_index, and if so return the start edge. Otherwise - // return Edge_None. - Edge get_quad_start_edge(long quad, unsigned int level_index) const; - - // Check if a contour starts within the specified quad, whether it is a - // corner or a full quad, and if so return the start edge. Otherwise - // return Edge_None. - Edge get_start_edge(long quad, unsigned int level_index) const; - - // Initialise the cache to contain grid information that is constant - // across the lifetime of this object, i.e. does not vary between calls to - // create_contour() and create_filled_contour(). - void init_cache_grid(const MaskArray& mask); - - // Initialise the cache with information that is specific to contouring the - // specified two levels. The levels are the same for contour lines, - // different for filled contours. - void init_cache_levels(const double& lower_level, - const double& upper_level); - - // Return the (x,y) point at which the level intersects the line connecting - // the two specified point indices. - XY interp(long point1, long point2, const double& level) const; - - // Return true if the specified QuadEdge is a boundary, i.e. is either an - // edge between a masked and non-masked quad/corner or is a chunk boundary. - bool is_edge_a_boundary(const QuadEdge& quad_edge) const; - - // Follow a boundary from one QuadEdge to the next in an anticlockwise - // manner around the non-masked region. - void move_to_next_boundary_edge(QuadEdge& quad_edge) const; - - // Move from the quad specified by quad_edge.quad to the neighbouring quad - // by crossing the edge specified by quad_edge.edge. - void move_to_next_quad(QuadEdge& quad_edge) const; - - // Check for filled contours starting within the specified quad and - // complete any that are found, appending them to the specified Contour. - void single_quad_filled(Contour& contour, - long quad, - const double& lower_level, - const double& upper_level); - - // Start and complete a filled contour line. - // quad: index of quad to start ContourLine in. - // edge: edge of quad to start ContourLine from. - // start_level_index: the level_index that the ContourLine starts from. - // hole_or_not: whether the ContourLine is a hole or not. - // boundary_or_interior: whether the ContourLine starts on a boundary or - // the interior. - // lower_level: lower contour z-value. - // upper_level: upper contour z-value. - // Returns newly created ContourLine. - ContourLine* start_filled(long quad, - Edge edge, - unsigned int start_level_index, - HoleOrNot hole_or_not, - BoundaryOrInterior boundary_or_interior, - const double& lower_level, - const double& upper_level); - - // Start and complete a line contour that both starts and end on a - // boundary, traversing the interior of the domain. - // vertices_list: Python list that the ContourLine should be appended to. - // codes_list: Python list that the kind codes should be appended to. - // quad: index of quad to start ContourLine in. - // edge: boundary edge to start ContourLine from. - // level: contour z-value. - // Returns true if the start quad does not need to be visited again, i.e. - // VISITED(quad,1). - bool start_line(PyObject* vertices_list, - PyObject* codes_list, - long quad, - Edge edge, - const double& level); - - // Debug function that writes the cache status to stdout. - void write_cache(bool grid_only = false) const; - - // Debug function that writes that cache status for a single quad to - // stdout. - void write_cache_quad(long quad, bool grid_only) const; - - - - // Note that mask is not stored as once it has been used to initialise the - // cache it is no longer needed. - CoordinateArray _x, _y, _z; - long _nx, _ny; // Number of points in each direction. - long _n; // Total number of points (and hence quads). - - bool _corner_mask; - long _chunk_size; // Number of quads per chunk (not points). - // Always > 0, unlike python nchunk which is 0 - // for no chunking. - - long _nxchunk, _nychunk; // Number of chunks in each direction. - long _chunk_count; // Total number of chunks. - - typedef uint32_t CacheItem; - CacheItem* _cache; - - ParentCache _parent_cache; // On W quad sides. -}; - -#endif // _CONTOUR_H diff --git a/src/_contour_wrapper.cpp b/src/_contour_wrapper.cpp deleted file mode 100644 index 262e56975ca0..000000000000 --- a/src/_contour_wrapper.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "_contour.h" -#include "mplutils.h" -#include "py_converters.h" -#include "py_exceptions.h" - -/* QuadContourGenerator */ - -typedef struct -{ - PyObject_HEAD - QuadContourGenerator* ptr; -} PyQuadContourGenerator; - -static PyTypeObject PyQuadContourGeneratorType; - -static PyObject* PyQuadContourGenerator_new(PyTypeObject* type, PyObject* args, PyObject* kwds) -{ - PyQuadContourGenerator* self; - self = (PyQuadContourGenerator*)type->tp_alloc(type, 0); - self->ptr = NULL; - return (PyObject*)self; -} - -const char* PyQuadContourGenerator_init__doc__ = - "QuadContourGenerator(x, y, z, mask, corner_mask, chunk_size)\n" - "--\n\n" - "Create a new C++ QuadContourGenerator object\n"; - -static int PyQuadContourGenerator_init(PyQuadContourGenerator* self, PyObject* args, PyObject* kwds) -{ - QuadContourGenerator::CoordinateArray x, y, z; - QuadContourGenerator::MaskArray mask; - bool corner_mask; - long chunk_size; - - if (!PyArg_ParseTuple(args, "O&O&O&O&O&l", - &x.converter_contiguous, &x, - &y.converter_contiguous, &y, - &z.converter_contiguous, &z, - &mask.converter_contiguous, &mask, - &convert_bool, &corner_mask, - &chunk_size)) { - return -1; - } - - if (x.empty() || y.empty() || z.empty() || - y.dim(0) != x.dim(0) || z.dim(0) != x.dim(0) || - y.dim(1) != x.dim(1) || z.dim(1) != x.dim(1)) { - PyErr_SetString(PyExc_ValueError, - "x, y and z must all be 2D arrays with the same dimensions"); - return -1; - } - - if (z.dim(0) < 2 || z.dim(1) < 2) { - PyErr_SetString(PyExc_ValueError, - "x, y and z must all be at least 2x2 arrays"); - return -1; - } - - // Mask array is optional, if set must be same size as other arrays. - if (!mask.empty() && (mask.dim(0) != x.dim(0) || mask.dim(1) != x.dim(1))) { - PyErr_SetString(PyExc_ValueError, - "If mask is set it must be a 2D array with the same dimensions as x."); - return -1; - } - - CALL_CPP_INIT("QuadContourGenerator", - (self->ptr = new QuadContourGenerator( - x, y, z, mask, corner_mask, chunk_size))); - return 0; -} - -static void PyQuadContourGenerator_dealloc(PyQuadContourGenerator* self) -{ - delete self->ptr; - Py_TYPE(self)->tp_free((PyObject *)self); -} - -const char* PyQuadContourGenerator_create_contour__doc__ = - "create_contour(level)\n" - "--\n\n" - "Create and return a non-filled contour."; - -static PyObject* PyQuadContourGenerator_create_contour(PyQuadContourGenerator* self, PyObject* args) -{ - double level; - if (!PyArg_ParseTuple(args, "d:create_contour", &level)) { - return NULL; - } - - PyObject* result; - CALL_CPP("create_contour", (result = self->ptr->create_contour(level))); - return result; -} - -const char* PyQuadContourGenerator_create_filled_contour__doc__ = - "create_filled_contour(lower_level, upper_level)\n" - "--\n\n" - "Create and return a filled contour"; - -static PyObject* PyQuadContourGenerator_create_filled_contour(PyQuadContourGenerator* self, PyObject* args) -{ - double lower_level, upper_level; - if (!PyArg_ParseTuple(args, "dd:create_filled_contour", - &lower_level, &upper_level)) { - return NULL; - } - - if (lower_level >= upper_level) - { - PyErr_SetString(PyExc_ValueError, - "filled contour levels must be increasing"); - return NULL; - } - - PyObject* result; - CALL_CPP("create_filled_contour", - (result = self->ptr->create_filled_contour(lower_level, - upper_level))); - return result; -} - -static PyTypeObject* PyQuadContourGenerator_init_type(PyObject* m, PyTypeObject* type) -{ - static PyMethodDef methods[] = { - {"create_contour", (PyCFunction)PyQuadContourGenerator_create_contour, METH_VARARGS, PyQuadContourGenerator_create_contour__doc__}, - {"create_filled_contour", (PyCFunction)PyQuadContourGenerator_create_filled_contour, METH_VARARGS, PyQuadContourGenerator_create_filled_contour__doc__}, - {NULL} - }; - - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib.QuadContourGenerator"; - type->tp_doc = PyQuadContourGenerator_init__doc__; - type->tp_basicsize = sizeof(PyQuadContourGenerator); - type->tp_dealloc = (destructor)PyQuadContourGenerator_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT; - type->tp_methods = methods; - type->tp_new = PyQuadContourGenerator_new; - type->tp_init = (initproc)PyQuadContourGenerator_init; - - if (PyType_Ready(type) < 0) { - return NULL; - } - - if (PyModule_AddObject(m, "QuadContourGenerator", (PyObject*)type)) { - return NULL; - } - - return type; -} - - -/* Module */ - -static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "_contour" }; - -#pragma GCC visibility push(default) - -PyMODINIT_FUNC PyInit__contour(void) -{ - PyObject *m; - - import_array(); - - m = PyModule_Create(&moduledef); - - if (m == NULL) { - return NULL; - } - - if (!PyQuadContourGenerator_init_type(m, &PyQuadContourGeneratorType)) { - Py_DECREF(m); - return NULL; - } - - return m; -} - -#pragma GCC visibility pop diff --git a/src/_image_wrapper.cpp b/src/_image_wrapper.cpp index f53c88acf2a5..9eba0249d3e9 100644 --- a/src/_image_wrapper.cpp +++ b/src/_image_wrapper.cpp @@ -9,7 +9,8 @@ * */ const char* image_resample__doc__ = -"resample(input_array, output_array, matrix, interpolation=NEAREST, alpha=1.0, norm=False, radius=1)\n\n" +"resample(input_array, output_array, matrix, interpolation=NEAREST, alpha=1.0, norm=False, radius=1)\n" +"--\n\n" "Resample input_array, blending it in-place into output_array, using an\n" "affine transformation.\n\n" diff --git a/src/_macosx.m b/src/_macosx.m index 8c7ac2366293..dcb236a861f3 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -3,6 +3,7 @@ #include #include #include +#include "mplutils.h" #ifndef PYPY /* Remove this once Python is fixed: https://bugs.python.org/issue23237 */ @@ -11,43 +12,10 @@ /* Proper way to check for the OS X version we are compiling for, from * https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/cross_development/Using/using.html - */ -#if __MAC_OS_X_VERSION_MIN_REQUIRED >= 1070 -#define COMPILING_FOR_10_7 -#endif -#if __MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 -#define COMPILING_FOR_10_10 -#endif - -#if __MAC_OS_X_VERSION_MIN_REQUIRED < 101200 -/* A lot of symbols were renamed in Sierra and cause deprecation warnings - so define macros for the new names if we are compiling on an older SDK */ -#define NSEventMaskAny NSAnyEventMask -#define NSEventTypeApplicationDefined NSApplicationDefined -#define NSEventModifierFlagCommand NSCommandKeyMask -#define NSEventModifierFlagControl NSControlKeyMask -#define NSEventModifierFlagOption NSAlternateKeyMask -#define NSEventModifierFlagShift NSShiftKeyMask -#define NSEventTypeKeyUp NSKeyUp -#define NSEventTypeKeyDown NSKeyDown -#define NSEventTypeMouseMoved NSMouseMoved -#define NSEventTypeLeftMouseDown NSLeftMouseDown -#define NSEventTypeRightMouseDown NSRightMouseDown -#define NSEventTypeOtherMouseDown NSOtherMouseDown -#define NSEventTypeLeftMouseDragged NSLeftMouseDragged -#define NSEventTypeRightMouseDragged NSRightMouseDragged -#define NSEventTypeOtherMouseDragged NSOtherMouseDragged -#define NSEventTypeLeftMouseUp NSLeftMouseUp -#define NSEventTypeRightMouseUp NSRightMouseUp -#define NSEventTypeOtherMouseUp NSOtherMouseUp -#define NSWindowStyleMaskClosable NSClosableWindowMask -#define NSWindowStyleMaskMiniaturizable NSMiniaturizableWindowMask -#define NSWindowStyleMaskResizable NSResizableWindowMask -#define NSWindowStyleMaskTitled NSTitledWindowMask -#endif + * Renamed symbols cause deprecation warnings, so define macros for the new + * names if we are compiling on an older SDK */ #if __MAC_OS_X_VERSION_MIN_REQUIRED < 101400 -/* A few more deprecations in Mojave */ #define NSButtonTypeMomentaryLight NSMomentaryLightButton #define NSButtonTypePushOnPushOff NSPushOnPushOffButton #define NSBezelStyleShadowlessSquare NSShadowlessSquareBezelStyle @@ -64,6 +32,20 @@ Needed to know when to stop the NSApp */ static long FigureWindowCount = 0; +/* Keep track of modifier key states for flagsChanged + to keep track of press vs release */ +static bool lastCommand = false; +static bool lastControl = false; +static bool lastShift = false; +static bool lastOption = false; +static bool lastCapsLock = false; +/* Keep track of whether this specific key modifier was pressed or not */ +static bool keyChangeCommand = false; +static bool keyChangeControl = false; +static bool keyChangeShift = false; +static bool keyChangeOption = false; +static bool keyChangeCapsLock = false; + /* -------------------------- Helper function ---------------------------- */ static void @@ -96,7 +78,8 @@ static void _sigint_callback(CFSocketRef s, CFRunLoopStop(runloop); } -static CGEventRef _eventtap_callback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) +static CGEventRef _eventtap_callback( + CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) { CFRunLoopRef runloop = refcon; CFRunLoopStop(runloop); @@ -121,8 +104,8 @@ static int wait_for_stdin(void) #ifdef PYOSINPUTHOOK_REPETITIVE if (!CFReadStreamHasBytesAvailable(stream)) /* This is possible because of how PyOS_InputHook is called from Python */ - { #endif + { int error; int channel[2]; CFSocketRef sigint_socket = NULL; @@ -135,8 +118,7 @@ static int wait_for_stdin(void) &clientContext); CFReadStreamScheduleWithRunLoop(stream, runloop, kCFRunLoopDefaultMode); error = socketpair(AF_UNIX, SOCK_STREAM, 0, channel); - if (error==0) - { + if (!error) { CFSocketContext context; context.version = 0; context.info = &interrupted; @@ -150,15 +132,11 @@ static int wait_for_stdin(void) kCFSocketReadCallBack, _sigint_callback, &context); - if (sigint_socket) - { - CFRunLoopSourceRef source; - source = CFSocketCreateRunLoopSource(kCFAllocatorDefault, - sigint_socket, - 0); + if (sigint_socket) { + CFRunLoopSourceRef source = CFSocketCreateRunLoopSource( + kCFAllocatorDefault, sigint_socket, 0); CFRelease(sigint_socket); - if (source) - { + if (source) { CFRunLoopAddSource(runloop, source, kCFRunLoopDefaultMode); CFRelease(source); sigint_fd = channel[0]; @@ -174,25 +152,22 @@ static int wait_for_stdin(void) untilDate: [NSDate distantPast] inMode: NSDefaultRunLoopMode dequeue: YES]; - if (!event) break; + if (!event) { break; } [NSApp sendEvent: event]; } CFRunLoopRun(); - if (interrupted || CFReadStreamHasBytesAvailable(stream)) break; + if (interrupted || CFReadStreamHasBytesAvailable(stream)) { break; } } - if (py_sigint_handler) PyOS_setsig(SIGINT, py_sigint_handler); - CFReadStreamUnscheduleFromRunLoop(stream, - runloop, - kCFRunLoopCommonModes); - if (sigint_socket) CFSocketInvalidate(sigint_socket); - if (error==0) { + if (py_sigint_handler) { PyOS_setsig(SIGINT, py_sigint_handler); } + CFReadStreamUnscheduleFromRunLoop( + stream, runloop, kCFRunLoopCommonModes); + if (sigint_socket) { CFSocketInvalidate(sigint_socket); } + if (!error) { close(channel[0]); close(channel[1]); } -#ifdef PYOSINPUTHOOK_REPETITIVE } -#endif CFReadStreamClose(stream); CFRelease(stream); if (interrupted) { @@ -256,17 +231,55 @@ - (void)keyDown:(NSEvent*)event; - (void)keyUp:(NSEvent*)event; - (void)scrollWheel:(NSEvent *)event; - (BOOL)acceptsFirstResponder; -//- (void)flagsChanged:(NSEvent*)event; +- (void)flagsChanged:(NSEvent*)event; @end /* ---------------------------- Python classes ---------------------------- */ +// Acquire the GIL, call a method with no args, discarding the result and +// printing any exception. +static void gil_call_method(PyObject* obj, const char* name) +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* result = PyObject_CallMethod(obj, name, NULL); + if (result) { + Py_DECREF(result); + } else { + PyErr_Print(); + } + PyGILState_Release(gstate); +} + +void process_event(char const* cls_name, char const* fmt, ...) +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* module = NULL, * cls = NULL, + * args = NULL, * kwargs = NULL, + * event = NULL, * result = NULL; + va_list argp; + va_start(argp, fmt); + if (!(module = PyImport_ImportModule("matplotlib.backend_bases")) + || !(cls = PyObject_GetAttrString(module, cls_name)) + || !(args = PyTuple_New(0)) + || !(kwargs = Py_VaBuildValue(fmt, argp)) + || !(event = PyObject_Call(cls, args, kwargs)) + || !(result = PyObject_CallMethod(event, "_process", ""))) { + PyErr_Print(); + } + va_end(argp); + Py_XDECREF(module); + Py_XDECREF(cls); + Py_XDECREF(args); + Py_XDECREF(kwargs); + Py_XDECREF(event); + Py_XDECREF(result); + PyGILState_Release(gstate); +} + static bool backend_inited = false; static void lazy_init(void) { - if (backend_inited) { - return; - } + if (backend_inited) { return; } backend_inited = true; NSApp = [NSApplication sharedApplication]; @@ -303,17 +316,54 @@ static CGFloat _get_device_scale(CGContextRef cr) return pixelSize.width; } +int mpl_check_modifier( + NSUInteger modifiers, NSEventModifierFlags flag, + PyObject* list, char const* name) +{ + int status = 0; + if (modifiers & flag) { + PyObject* py_name = NULL; + if (!(py_name = PyUnicode_FromString(name)) + || PyList_Append(list, py_name)) { + status = -1; // failure + } + Py_XDECREF(py_name); + } + return status; +} + +PyObject* mpl_modifiers(NSEvent* event) +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* list = NULL; + if (!(list = PyList_New(0))) { + goto exit; + } + NSUInteger modifiers = [event modifierFlags]; + if (mpl_check_modifier(modifiers, NSEventModifierFlagControl, list, "ctrl") + || mpl_check_modifier(modifiers, NSEventModifierFlagOption, list, "alt") + || mpl_check_modifier(modifiers, NSEventModifierFlagShift, list, "shift") + || mpl_check_modifier(modifiers, NSEventModifierFlagCommand, list, "cmd")) { + Py_CLEAR(list); // On failure, return NULL with an exception set. + } +exit: + PyGILState_Release(gstate); + return list; +} + typedef struct { PyObject_HEAD View* view; } FigureCanvas; +static PyTypeObject FigureCanvasType; + static PyObject* FigureCanvas_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { lazy_init(); FigureCanvas *self = (FigureCanvas*)type->tp_alloc(type, 0); - if (!self) return NULL; + if (!self) { return NULL; } self->view = [View alloc]; return (PyObject*)self; } @@ -321,16 +371,27 @@ static CGFloat _get_device_scale(CGContextRef cr) static int FigureCanvas_init(FigureCanvas *self, PyObject *args, PyObject *kwds) { - int width; - int height; - if(!self->view) - { + if (!self->view) { PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return -1; } - - if(!PyArg_ParseTuple(args, "ii", &width, &height)) return -1; - + PyObject *builtins = NULL, + *super_obj = NULL, + *super_init = NULL, + *init_res = NULL, + *wh = NULL; + // super(FigureCanvasMac, self).__init__(*args, **kwargs) + if (!(builtins = PyImport_AddModule("builtins")) // borrowed. + || !(super_obj = PyObject_CallMethod(builtins, "super", "OO", &FigureCanvasType, self)) + || !(super_init = PyObject_GetAttrString(super_obj, "__init__")) + || !(init_res = PyObject_Call(super_init, args, kwds))) { + goto exit; + } + int width, height; + if (!(wh = PyObject_CallMethod((PyObject*)self, "get_width_height", "")) + || !PyArg_ParseTuple(wh, "ii", &width, &height)) { + goto exit; + } NSRect rect = NSMakeRect(0.0, 0.0, width, height); self->view = [self->view initWithFrame: rect]; self->view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; @@ -342,17 +403,20 @@ static CGFloat _get_device_scale(CGContextRef cr) owner: self->view userInfo: nil]]; [self->view setCanvas: (PyObject*)self]; - return 0; + +exit: + Py_XDECREF(super_obj); + Py_XDECREF(super_init); + Py_XDECREF(init_res); + Py_XDECREF(wh); + return PyErr_Occurred() ? -1 : 0; } static void FigureCanvas_dealloc(FigureCanvas* self) { - if (self->view) - { - [self->view setCanvas: NULL]; - [self->view release]; - } + [self->view setCanvas: NULL]; + [self->view release]; Py_TYPE(self)->tp_free((PyObject*)self); } @@ -364,41 +428,49 @@ static CGFloat _get_device_scale(CGContextRef cr) } static PyObject* -FigureCanvas_draw(FigureCanvas* self) +FigureCanvas_update(FigureCanvas* self) { - View* view = self->view; - - if(view) /* The figure may have been closed already */ - { - [view display]; - } - + [self->view setNeedsDisplay: YES]; Py_RETURN_NONE; } static PyObject* -FigureCanvas_draw_idle(FigureCanvas* self) +FigureCanvas_flush_events(FigureCanvas* self) { - View* view = self->view; - if(!view) - { - PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); - return NULL; + // We run the app, matching any events that are waiting in the queue + // to process, breaking out of the loop when no events remain and + // displaying the canvas if needed. + NSEvent *event; + while (true) { + event = [NSApp nextEventMatchingMask: NSEventMaskAny + untilDate: [NSDate distantPast] + inMode: NSDefaultRunLoopMode + dequeue: YES]; + if (!event) { + break; + } + [NSApp sendEvent:event]; } - [view setNeedsDisplay: YES]; + [self->view displayIfNeeded]; Py_RETURN_NONE; } static PyObject* -FigureCanvas_flush_events(FigureCanvas* self) +FigureCanvas_set_cursor(PyObject* unused, PyObject* args) { - View* view = self->view; - if(!view) - { - PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); - return NULL; + int i; + if (!PyArg_ParseTuple(args, "i", &i)) { return NULL; } + switch (i) { + case 1: [[NSCursor arrowCursor] set]; break; + case 2: [[NSCursor pointingHandCursor] set]; break; + case 3: [[NSCursor crosshairCursor] set]; break; + case 4: [[NSCursor openHandCursor] set]; break; + /* OSX handles busy state itself so no need to set a cursor here */ + case 5: break; + case 6: [[NSCursor resizeLeftRightCursor] set]; break; + case 7: [[NSCursor resizeUpDownCursor] set]; break; + default: return NULL; } - [view displayIfNeeded]; Py_RETURN_NONE; } @@ -406,41 +478,20 @@ static CGFloat _get_device_scale(CGContextRef cr) FigureCanvas_set_rubberband(FigureCanvas* self, PyObject *args) { View* view = self->view; - int x0, y0, x1, y1; - NSRect rubberband; - if(!view) - { + if (!view) { PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return NULL; } - if(!PyArg_ParseTuple(args, "iiii", &x0, &y0, &x1, &y1)) return NULL; - + int x0, y0, x1, y1; + if (!PyArg_ParseTuple(args, "iiii", &x0, &y0, &x1, &y1)) { + return NULL; + } x0 /= view->device_scale; x1 /= view->device_scale; y0 /= view->device_scale; y1 /= view->device_scale; - - if (x1 > x0) - { - rubberband.origin.x = x0; - rubberband.size.width = x1 - x0; - } - else - { - rubberband.origin.x = x1; - rubberband.size.width = x0 - x1; - } - if (y1 > y0) - { - rubberband.origin.y = y0; - rubberband.size.height = y1 - y0; - } - else - { - rubberband.origin.y = y1; - rubberband.size.height = y0 - y1; - } - + NSRect rubberband = NSMakeRect(x0 < x1 ? x0 : x1, y0 < y1 ? y0 : y1, + abs(x1 - x0), abs(y1 - y0)); [view setRubberband: rubberband]; Py_RETURN_NONE; } @@ -448,13 +499,7 @@ static CGFloat _get_device_scale(CGContextRef cr) static PyObject* FigureCanvas_remove_rubberband(FigureCanvas* self) { - View* view = self->view; - if(!view) - { - PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); - return NULL; - } - [view removeRubberband]; + [self->view removeRubberband]; Py_RETURN_NONE; } @@ -464,8 +509,9 @@ static CGFloat _get_device_scale(CGContextRef cr) float timeout = 0.0; static char* kwlist[] = {"timeout", NULL}; - if(!PyArg_ParseTupleAndKeywords(args, keywords, "f", kwlist, &timeout)) + if (!PyArg_ParseTupleAndKeywords(args, keywords, "f", kwlist, &timeout)) { return NULL; + } int error; int interrupted = 0; @@ -476,8 +522,7 @@ static CGFloat _get_device_scale(CGContextRef cr) CFRunLoopRef runloop = CFRunLoopGetCurrent(); error = pipe(channel); - if (error==0) - { + if (!error) { CFSocketContext context = {0, NULL, NULL, NULL, NULL}; fcntl(channel[1], F_SETFL, O_WRONLY | O_NONBLOCK); @@ -487,15 +532,11 @@ static CGFloat _get_device_scale(CGContextRef cr) kCFSocketReadCallBack, _sigint_callback, &context); - if (sigint_socket) - { - CFRunLoopSourceRef source; - source = CFSocketCreateRunLoopSource(kCFAllocatorDefault, - sigint_socket, - 0); + if (sigint_socket) { + CFRunLoopSourceRef source = CFSocketCreateRunLoopSource( + kCFAllocatorDefault, sigint_socket, 0); CFRelease(sigint_socket); - if (source) - { + if (source) { CFRunLoopAddSource(runloop, source, kCFRunLoopDefaultMode); CFRelease(source); sigint_fd = channel[1]; @@ -514,15 +555,14 @@ static CGFloat _get_device_scale(CGContextRef cr) untilDate: date inMode: NSDefaultRunLoopMode dequeue: YES]; - if (!event || [event type]==NSEventTypeApplicationDefined) break; + if (!event || [event type]==NSEventTypeApplicationDefined) { break; } [NSApp sendEvent: event]; } - if (py_sigint_handler) PyOS_setsig(SIGINT, py_sigint_handler); - - if (sigint_socket) CFSocketInvalidate(sigint_socket); - if (error==0) close(channel[1]); - if (interrupted) raise(SIGINT); + if (py_sigint_handler) { PyOS_setsig(SIGINT, py_sigint_handler); } + if (sigint_socket) { CFSocketInvalidate(sigint_socket); } + if (!error) { close(channel[1]); } + if (interrupted) { raise(SIGINT); } Py_RETURN_NONE; } @@ -543,87 +583,47 @@ static CGFloat _get_device_scale(CGContextRef cr) Py_RETURN_NONE; } -static PyMethodDef FigureCanvas_methods[] = { - {"draw", - (PyCFunction)FigureCanvas_draw, - METH_NOARGS, - NULL, // docstring inherited. - }, - {"draw_idle", - (PyCFunction)FigureCanvas_draw_idle, - METH_NOARGS, - NULL, // docstring inherited. - }, - {"flush_events", - (PyCFunction)FigureCanvas_flush_events, - METH_NOARGS, - "Flush the GUI events for the figure." - }, - {"set_rubberband", - (PyCFunction)FigureCanvas_set_rubberband, - METH_VARARGS, - "Specifies a new rubberband rectangle and invalidates it." - }, - {"remove_rubberband", - (PyCFunction)FigureCanvas_remove_rubberband, - METH_NOARGS, - "Removes the current rubberband rectangle." - }, - {"start_event_loop", - (PyCFunction)FigureCanvas_start_event_loop, - METH_KEYWORDS | METH_VARARGS, - "Runs the event loop until the timeout or until stop_event_loop is called.\n", - }, - {"stop_event_loop", - (PyCFunction)FigureCanvas_stop_event_loop, - METH_NOARGS, - "Stops the event loop that was started by start_event_loop.\n", - }, - {NULL} /* Sentinel */ -}; - -static char FigureCanvas_doc[] = -"A FigureCanvas object wraps a Cocoa NSView object.\n"; - static PyTypeObject FigureCanvasType = { PyVarObject_HEAD_INIT(NULL, 0) - "_macosx.FigureCanvas", /*tp_name*/ - sizeof(FigureCanvas), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - (destructor)FigureCanvas_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_compare*/ - (reprfunc)FigureCanvas_repr, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash */ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ - FigureCanvas_doc, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - FigureCanvas_methods, /* tp_methods */ - 0, /* tp_members */ - 0, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - (initproc)FigureCanvas_init, /* tp_init */ - 0, /* tp_alloc */ - FigureCanvas_new, /* tp_new */ + .tp_name = "_macosx.FigureCanvas", + .tp_basicsize = sizeof(FigureCanvas), + .tp_dealloc = (destructor)FigureCanvas_dealloc, + .tp_repr = (reprfunc)FigureCanvas_repr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_init = (initproc)FigureCanvas_init, + .tp_new = (newfunc)FigureCanvas_new, + .tp_doc = "A FigureCanvas object wraps a Cocoa NSView object.", + .tp_methods = (PyMethodDef[]){ + {"update", + (PyCFunction)FigureCanvas_update, + METH_NOARGS, + NULL}, // docstring inherited + {"flush_events", + (PyCFunction)FigureCanvas_flush_events, + METH_NOARGS, + NULL}, // docstring inherited + {"set_cursor", + (PyCFunction)FigureCanvas_set_cursor, + METH_VARARGS, + "Set the active cursor."}, + {"set_rubberband", + (PyCFunction)FigureCanvas_set_rubberband, + METH_VARARGS, + "Specify a new rubberband rectangle and invalidate it."}, + {"remove_rubberband", + (PyCFunction)FigureCanvas_remove_rubberband, + METH_NOARGS, + "Remove the current rubberband rectangle."}, + {"start_event_loop", + (PyCFunction)FigureCanvas_start_event_loop, + METH_KEYWORDS | METH_VARARGS, + NULL}, // docstring inherited + {"stop_event_loop", + (PyCFunction)FigureCanvas_stop_event_loop, + METH_NOARGS, + NULL}, // docstring inherited + {} // sentinel + }, }; typedef struct { @@ -636,10 +636,9 @@ static CGFloat _get_device_scale(CGContextRef cr) { lazy_init(); Window* window = [Window alloc]; - if (!window) return NULL; + if (!window) { return NULL; } FigureManager *self = (FigureManager*)type->tp_alloc(type, 0); - if (!self) - { + if (!self) { [window release]; return NULL; } @@ -651,42 +650,26 @@ static CGFloat _get_device_scale(CGContextRef cr) static int FigureManager_init(FigureManager *self, PyObject *args, PyObject *kwds) { - NSRect rect; - Window* window; - View* view; - PyObject* size; - int width, height; - PyObject* obj; - FigureCanvas* canvas; - - if(!self->window) - { - PyErr_SetString(PyExc_RuntimeError, "NSWindow* is NULL"); + PyObject* canvas; + if (!PyArg_ParseTuple(args, "O", &canvas)) { return -1; } - if(!PyArg_ParseTuple(args, "O", &obj)) return -1; - - canvas = (FigureCanvas*)obj; - view = canvas->view; - if (!view) /* Something really weird going on */ - { + View* view = ((FigureCanvas*)canvas)->view; + if (!view) { /* Something really weird going on */ PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return -1; } - size = PyObject_CallMethod(obj, "get_width_height", ""); - if(!size) return -1; - if(!PyArg_ParseTuple(size, "ii", &width, &height)) - { Py_DECREF(size); - return -1; + PyObject* size = PyObject_CallMethod(canvas, "get_width_height", ""); + int width, height; + if (!size || !PyArg_ParseTuple(size, "ii", &width, &height)) { + Py_XDECREF(size); + return -1; } Py_DECREF(size); - rect.origin.x = 100; - rect.origin.y = 350; - rect.size.height = height; - rect.size.width = width; + NSRect rect = NSMakeRect( /* x */ 100, /* y */ 350, width, height); self->window = [self->window initWithContentRect: rect styleMask: NSWindowStyleMaskTitled @@ -696,7 +679,7 @@ static CGFloat _get_device_scale(CGContextRef cr) backing: NSBackingStoreBuffered defer: YES withManager: (PyObject*)self]; - window = self->window; + Window* window = self->window; [window setDelegate: view]; [window makeFirstResponder: view]; [[window contentView] addSubview: view]; @@ -715,34 +698,67 @@ static CGFloat _get_device_scale(CGContextRef cr) static void FigureManager_dealloc(FigureManager* self) { - Window* window = self->window; - if(window) - { - [window close]; - } + [self->window close]; Py_TYPE(self)->tp_free((PyObject*)self); } static PyObject* -FigureManager_show(FigureManager* self) +FigureManager__show(FigureManager* self) { - Window* window = self->window; - if(window) - { - [window makeKeyAndOrderFront: nil]; - [window orderFrontRegardless]; - } + [self->window makeKeyAndOrderFront: nil]; + Py_RETURN_NONE; +} + +static PyObject* +FigureManager__raise(FigureManager* self) +{ + [self->window orderFrontRegardless]; Py_RETURN_NONE; } static PyObject* FigureManager_destroy(FigureManager* self) { - Window* window = self->window; - if(window) - { - [window close]; - self->window = NULL; + [self->window close]; + self->window = NULL; + Py_RETURN_NONE; +} + +static PyObject* +FigureManager_set_icon(PyObject* null, PyObject* args) { + PyObject* icon_path; + if (!PyArg_ParseTuple(args, "O&", &PyUnicode_FSDecoder, &icon_path)) { + return NULL; + } + const char* icon_path_ptr = PyUnicode_AsUTF8(icon_path); + if (!icon_path_ptr) { + Py_DECREF(icon_path); + return NULL; + } + @autoreleasepool { + NSString* ns_icon_path = [NSString stringWithUTF8String: icon_path_ptr]; + Py_DECREF(icon_path); + if (!ns_icon_path) { + PyErr_SetString(PyExc_RuntimeError, "Could not convert to NSString*"); + return NULL; + } + NSImage* image = [[[NSImage alloc] initByReferencingFile: ns_icon_path] autorelease]; + if (!image) { + PyErr_SetString(PyExc_RuntimeError, "Could not create NSImage*"); + return NULL; + } + if (!image.valid) { + PyErr_SetString(PyExc_RuntimeError, "Image is not valid"); + return NULL; + } + @try { + NSApplication* app = [NSApplication sharedApplication]; + app.applicationIconImage = image; + } + @catch (NSException* exception) { + PyErr_SetString(PyExc_RuntimeError, exception.reason.UTF8String); + return NULL; + } } Py_RETURN_NONE; } @@ -755,32 +771,16 @@ static CGFloat _get_device_scale(CGContextRef cr) if (!PyArg_ParseTuple(args, "s", &title)) { return NULL; } - Window* window = self->window; - if(window) - { - NSString* ns_title = [[[NSString alloc] - initWithCString: title - encoding: NSUTF8StringEncoding] autorelease]; - [window setTitle: ns_title]; - } + [self->window setTitle: [NSString stringWithUTF8String: title]]; Py_RETURN_NONE; } static PyObject* FigureManager_get_window_title(FigureManager* self) { - Window* window = self->window; - PyObject* result = NULL; - if(window) - { - NSString* title = [window title]; - if (title) { - const char* cTitle = [title UTF8String]; - result = PyUnicode_FromString(cTitle); - } - } - if (result) { - return result; + NSString* title = [self->window title]; + if (title) { + return PyUnicode_FromString([title UTF8String]); } else { Py_RETURN_NONE; } @@ -794,8 +794,7 @@ static CGFloat _get_device_scale(CGContextRef cr) return NULL; } Window* window = self->window; - if(window) - { + if (window) { CGFloat device_pixel_ratio = [window backingScaleFactor]; width /= device_pixel_ratio; height /= device_pixel_ratio; @@ -805,77 +804,51 @@ static CGFloat _get_device_scale(CGContextRef cr) Py_RETURN_NONE; } -static PyMethodDef FigureManager_methods[] = { - {"show", - (PyCFunction)FigureManager_show, - METH_NOARGS, - "Shows the window associated with the figure manager." - }, - {"destroy", - (PyCFunction)FigureManager_destroy, - METH_NOARGS, - "Closes the window associated with the figure manager." - }, - {"set_window_title", - (PyCFunction)FigureManager_set_window_title, - METH_VARARGS, - "Sets the title of the window associated with the figure manager." - }, - {"get_window_title", - (PyCFunction)FigureManager_get_window_title, - METH_NOARGS, - "Returns the title of the window associated with the figure manager." - }, - {"resize", - (PyCFunction)FigureManager_resize, - METH_VARARGS, - "Resize the window (in pixels)." - }, - {NULL} /* Sentinel */ -}; - -static char FigureManager_doc[] = -"A FigureManager object wraps a Cocoa NSWindow object.\n"; +static PyObject* +FigureManager_full_screen_toggle(FigureManager* self) +{ + [self->window toggleFullScreen: nil]; + Py_RETURN_NONE; +} static PyTypeObject FigureManagerType = { PyVarObject_HEAD_INIT(NULL, 0) - "_macosx.FigureManager", /*tp_name*/ - sizeof(FigureManager), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - (destructor)FigureManager_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_compare*/ - (reprfunc)FigureManager_repr, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash */ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ - FigureManager_doc, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - FigureManager_methods, /* tp_methods */ - 0, /* tp_members */ - 0, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - (initproc)FigureManager_init, /* tp_init */ - 0, /* tp_alloc */ - FigureManager_new, /* tp_new */ + .tp_name = "_macosx.FigureManager", + .tp_basicsize = sizeof(FigureManager), + .tp_dealloc = (destructor)FigureManager_dealloc, + .tp_repr = (reprfunc)FigureManager_repr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_init = (initproc)FigureManager_init, + .tp_new = (newfunc)FigureManager_new, + .tp_doc = "A FigureManager object wraps a Cocoa NSWindow object.", + .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + {"_show", + (PyCFunction)FigureManager__show, + METH_NOARGS}, + {"_raise", + (PyCFunction)FigureManager__raise, + METH_NOARGS}, + {"destroy", + (PyCFunction)FigureManager_destroy, + METH_NOARGS}, + {"set_icon", + (PyCFunction)FigureManager_set_icon, + METH_STATIC | METH_VARARGS, + "Set application icon"}, + {"set_window_title", + (PyCFunction)FigureManager_set_window_title, + METH_VARARGS}, + {"get_window_title", + (PyCFunction)FigureManager_get_window_title, + METH_NOARGS}, + {"resize", + (PyCFunction)FigureManager_resize, + METH_VARARGS}, + {"full_screen_toggle", + (PyCFunction)FigureManager_full_screen_toggle, + METH_NOARGS}, + {} // sentinel + }, }; @interface NavigationToolbar2Handler : NSObject @@ -884,7 +857,7 @@ @interface NavigationToolbar2Handler : NSObject NSButton* zoombutton; } - (NavigationToolbar2Handler*)initWithToolbar:(PyObject*)toolbar; -- (void)installCallbacks:(SEL[7])actions forButtons: (NSButton*[7])buttons; +- (void)installCallbacks:(SEL[7])actions forButtons:(NSButton*[7])buttons; - (void)home:(id)sender; - (void)back:(id)sender; - (void)forward:(id)sender; @@ -904,118 +877,43 @@ - (void)save_figure:(id)sender; @implementation NavigationToolbar2Handler - (NavigationToolbar2Handler*)initWithToolbar:(PyObject*)theToolbar -{ [self init]; +{ + [self init]; toolbar = theToolbar; return self; } -- (void)installCallbacks:(SEL[7])actions forButtons: (NSButton*[7])buttons +- (void)installCallbacks:(SEL[7])actions forButtons:(NSButton*[7])buttons { int i; - for (i = 0; i < 7; i++) - { + for (i = 0; i < 7; i++) { SEL action = actions[i]; NSButton* button = buttons[i]; [button setTarget: self]; [button setAction: action]; - if (action==@selector(pan:)) panbutton = button; - if (action==@selector(zoom:)) zoombutton = button; + if (action == @selector(pan:)) { panbutton = button; } + if (action == @selector(zoom:)) { zoombutton = button; } } } --(void)home:(id)sender -{ PyObject* result; - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "home", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); -} - --(void)back:(id)sender -{ PyObject* result; - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "back", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); -} - --(void)forward:(id)sender -{ PyObject* result; - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "forward", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); -} +-(void)home:(id)sender { gil_call_method(toolbar, "home"); } +-(void)back:(id)sender { gil_call_method(toolbar, "back"); } +-(void)forward:(id)sender { gil_call_method(toolbar, "forward"); } -(void)pan:(id)sender -{ PyObject* result; - PyGILState_STATE gstate; - if ([sender state]) - { - if (zoombutton) [zoombutton setState: NO]; - } - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "pan", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); +{ + if ([sender state]) { [zoombutton setState:NO]; } + gil_call_method(toolbar, "pan"); } -(void)zoom:(id)sender -{ PyObject* result; - PyGILState_STATE gstate; - if ([sender state]) - { - if (panbutton) [panbutton setState: NO]; - } - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "zoom", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); -} - --(void)configure_subplots:(id)sender { - PyObject* result; - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "configure_subplots", NULL); - if (result) { - Py_DECREF(result); - } else { - PyErr_Print(); - } - PyGILState_Release(gstate); + if ([sender state]) { [panbutton setState:NO]; } + gil_call_method(toolbar, "zoom"); } --(void)save_figure:(id)sender -{ PyObject* result; - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(toolbar, "save_figure", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); -} +-(void)configure_subplots:(id)sender { gil_call_method(toolbar, "configure_subplots"); } +-(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); } @end static PyObject* @@ -1023,10 +921,9 @@ -(void)save_figure:(id)sender { lazy_init(); NavigationToolbar2Handler* handler = [NavigationToolbar2Handler alloc]; - if (!handler) return NULL; + if (!handler) { return NULL; } NavigationToolbar2 *self = (NavigationToolbar2*)type->tp_alloc(type, 0); - if (!self) - { + if (!self) { [handler release]; return NULL; } @@ -1037,41 +934,31 @@ -(void)save_figure:(id)sender static int NavigationToolbar2_init(NavigationToolbar2 *self, PyObject *args, PyObject *kwds) { - PyObject* obj; FigureCanvas* canvas; - View* view; - - int i; - NSRect rect; - NSSize size; - NSSize scale; + const char* images[7]; + const char* tooltips[7]; const float gap = 2; const int height = 36; const int imagesize = 24; - self->height = height; - - obj = PyObject_GetAttrString((PyObject*)self, "canvas"); - if (obj==NULL) - { - PyErr_SetString(PyExc_AttributeError, "Attempt to install toolbar for NULL canvas"); - return -1; - } - Py_DECREF(obj); /* Don't increase the reference count */ - if (!PyObject_IsInstance(obj, (PyObject*) &FigureCanvasType)) - { - PyErr_SetString(PyExc_TypeError, "Attempt to install toolbar for object that is not a FigureCanvas"); + if (!PyArg_ParseTuple(args, "O!(sssssss)(sssssss)", + &FigureCanvasType, &canvas, + &images[0], &images[1], &images[2], &images[3], + &images[4], &images[5], &images[6], + &tooltips[0], &tooltips[1], &tooltips[2], &tooltips[3], + &tooltips[4], &tooltips[5], &tooltips[6])) { return -1; } - canvas = (FigureCanvas*)obj; - view = canvas->view; - if(!view) - { + + View* view = canvas->view; + if (!view) { PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return -1; } + self->height = height; + NSRect bounds = [view bounds]; NSWindow* window = [view window]; @@ -1081,16 +968,6 @@ -(void)save_figure:(id)sender bounds.size.height += height; [window setContentSize: bounds.size]; - const char* images[7]; - const char* tooltips[7]; - if (!PyArg_ParseTuple(args, "(sssssss)(sssssss)", - &images[0], &images[1], &images[2], &images[3], - &images[4], &images[5], &images[6], - &tooltips[0], &tooltips[1], &tooltips[2], &tooltips[3], - &tooltips[4], &tooltips[5], &tooltips[6])) { - return -1; - } - NSButton* buttons[7]; SEL actions[7] = {@selector(home:), @selector(back:), @@ -1107,30 +984,29 @@ -(void)save_figure:(id)sender NSButtonTypeMomentaryLight, NSButtonTypeMomentaryLight}; - rect.origin.x = 0; - rect.origin.y = 0; - rect.size.width = imagesize; - rect.size.height = imagesize; -#ifdef COMPILING_FOR_10_7 + NSRect rect; + NSSize size; + NSSize scale; + + rect = NSMakeRect(0, 0, imagesize, imagesize); rect = [window convertRectToBacking: rect]; -#endif size = rect.size; - scale.width = imagesize / size.width; - scale.height = imagesize / size.height; + scale = NSMakeSize(imagesize / size.width, imagesize / size.height); rect.size.width = 32; rect.size.height = 32; rect.origin.x = gap; rect.origin.y = 0.5*(height - rect.size.height); - for (i = 0; i < 7; i++) { - NSString* filename = [NSString stringWithCString: images[i] - encoding: NSUTF8StringEncoding]; - NSString* tooltip = [NSString stringWithCString: tooltips[i] - encoding: NSUTF8StringEncoding]; + for (int i = 0; i < 7; i++) { + NSString* filename = [NSString stringWithUTF8String: images[i]]; + NSString* tooltip = [NSString stringWithUTF8String: tooltips[i]]; NSImage* image = [[NSImage alloc] initWithContentsOfFile: filename]; buttons[i] = [[NSButton alloc] initWithFrame: rect]; [image setSize: size]; + // Specify that it is a template image so the content tint + // color gets updated with the system theme (dark/light) + [image setTemplate: YES]; [buttons[i] setBezelStyle: NSBezelStyleShadowlessSquare]; [buttons[i] setButtonType: buttontypes[i]]; [buttons[i] setImage: image]; @@ -1147,22 +1023,20 @@ -(void)save_figure:(id)sender [self->handler installCallbacks: actions forButtons: buttons]; NSFont* font = [NSFont systemFontOfSize: 0.0]; - rect.size.width = 300; - rect.size.height = 0; - rect.origin.x += height; - NSTextView* messagebox = [[NSTextView alloc] initWithFrame: rect]; - if (@available(macOS 10.11, *)) { - messagebox.textContainer.maximumNumberOfLines = 2; - messagebox.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; - } + // rect.origin.x is now at the far right edge of the buttons + // we want the messagebox to take up the rest of the toolbar area + // Make it a zero-width box if we don't have enough room + rect.size.width = fmax(bounds.size.width - rect.origin.x, 0); + rect.origin.x = bounds.size.width - rect.size.width; + NSTextView* messagebox = [[[NSTextView alloc] initWithFrame: rect] autorelease]; + messagebox.textContainer.maximumNumberOfLines = 2; + messagebox.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + messagebox.alignment = NSTextAlignmentRight; [messagebox setFont: font]; [messagebox setDrawsBackground: NO]; [messagebox setSelectable: NO]; /* if selectable, the messagebox can become first responder, * which is not supposed to happen */ - rect = [messagebox frame]; - rect.origin.y = 0.5 * (height - rect.size.height); - [messagebox setFrameOrigin: rect.origin]; [[window contentView] addSubview: messagebox]; [messagebox release]; [[window contentView] display]; @@ -1184,88 +1058,57 @@ -(void)save_figure:(id)sender return PyUnicode_FromFormat("NavigationToolbar2 object %p", (void*)self); } -static char NavigationToolbar2_doc[] = -"NavigationToolbar2\n"; - static PyObject* NavigationToolbar2_set_message(NavigationToolbar2 *self, PyObject* args) { const char* message; - if(!PyArg_ParseTuple(args, "y", &message)) return NULL; + if (!PyArg_ParseTuple(args, "s", &message)) { return NULL; } NSTextView* messagebox = self->messagebox; - if (messagebox) - { + if (messagebox) { NSString* text = [NSString stringWithUTF8String: message]; [messagebox setString: text]; - // Adjust width with the window size + // Adjust width and height with the window size and content NSRect rectWindow = [messagebox.superview frame]; NSRect rect = [messagebox frame]; + // Entire region to the right of the buttons rect.size.width = rectWindow.size.width - rect.origin.x; [messagebox setFrame: rect]; - - // Adjust height with the content size + // We want to control the vertical position of + // the rect by the content size to center it vertically [messagebox.layoutManager ensureLayoutForTextContainer: messagebox.textContainer]; - NSRect contentSize = [messagebox.layoutManager usedRectForTextContainer: messagebox.textContainer]; - rect = [messagebox frame]; - rect.origin.y = 0.5 * (self->height - contentSize.size.height); + NSRect contentRect = [messagebox.layoutManager usedRectForTextContainer: messagebox.textContainer]; + rect.origin.y = 0.5 * (self->height - contentRect.size.height); + rect.size.height = contentRect.size.height; [messagebox setFrame: rect]; + // Disable cursorRects so that the cursor doesn't get updated by events + // in NSApp (like resizing TextViews), we want to handle the cursor + // changes from within MPL with set_cursor() ourselves + [[messagebox.superview window] disableCursorRects]; } Py_RETURN_NONE; } -static PyMethodDef NavigationToolbar2_methods[] = { - {"set_message", - (PyCFunction)NavigationToolbar2_set_message, - METH_VARARGS, - "Set the message to be displayed on the toolbar." - }, - {NULL} /* Sentinel */ -}; - static PyTypeObject NavigationToolbar2Type = { PyVarObject_HEAD_INIT(NULL, 0) - "_macosx.NavigationToolbar2", /*tp_name*/ - sizeof(NavigationToolbar2), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - (destructor)NavigationToolbar2_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_compare*/ - (reprfunc)NavigationToolbar2_repr, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash */ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ - NavigationToolbar2_doc, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - NavigationToolbar2_methods, /* tp_methods */ - 0, /* tp_members */ - 0, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - (initproc)NavigationToolbar2_init, /* tp_init */ - 0, /* tp_alloc */ - NavigationToolbar2_new, /* tp_new */ + .tp_name = "_macosx.NavigationToolbar2", + .tp_basicsize = sizeof(NavigationToolbar2), + .tp_dealloc = (destructor)NavigationToolbar2_dealloc, + .tp_repr = (reprfunc)NavigationToolbar2_repr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_init = (initproc)NavigationToolbar2_init, + .tp_new = (newfunc)NavigationToolbar2_new, + .tp_doc = "NavigationToolbar2", + .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + {"set_message", + (PyCFunction)NavigationToolbar2_set_message, + METH_VARARGS}, + {} // sentinel + }, }; static PyObject* @@ -1273,57 +1116,24 @@ -(void)save_figure:(id)sender { int result; const char* title; + const char* directory; const char* default_filename; - if (!PyArg_ParseTuple(args, "ss", &title, &default_filename)) { + if (!PyArg_ParseTuple(args, "sss", &title, &directory, &default_filename)) { return NULL; } NSSavePanel* panel = [NSSavePanel savePanel]; - [panel setTitle: [NSString stringWithCString: title - encoding: NSASCIIStringEncoding]]; - NSString* ns_default_filename = - [[NSString alloc] - initWithCString: default_filename - encoding: NSUTF8StringEncoding]; - [panel setNameFieldStringValue: ns_default_filename]; + [panel setTitle: [NSString stringWithUTF8String: title]]; + [panel setDirectoryURL: [NSURL fileURLWithPath: [NSString stringWithUTF8String: directory] + isDirectory: YES]]; + [panel setNameFieldStringValue: [NSString stringWithUTF8String: default_filename]]; result = [panel runModal]; - [ns_default_filename release]; -#ifdef COMPILING_FOR_10_10 - if (result == NSModalResponseOK) -#else - if (result == NSOKButton) -#endif - { - NSURL* url = [panel URL]; - NSString* filename = [url path]; + if (result == NSModalResponseOK) { + NSString *filename = [[panel URL] path]; if (!filename) { PyErr_SetString(PyExc_RuntimeError, "Failed to obtain filename"); return 0; } - unsigned int n = [filename length]; - unichar* buffer = malloc(n*sizeof(unichar)); - [filename getCharacters: buffer]; - PyObject* string = PyUnicode_FromKindAndData(PyUnicode_2BYTE_KIND, buffer, n); - free(buffer); - return string; - } - Py_RETURN_NONE; -} - -static PyObject* -set_cursor(PyObject* unused, PyObject* args) -{ - int i; - if(!PyArg_ParseTuple(args, "i", &i)) return NULL; - switch (i) { - case 1: [[NSCursor arrowCursor] set]; break; - case 2: [[NSCursor pointingHandCursor] set]; break; - case 3: [[NSCursor crosshairCursor] set]; break; - case 4: [[NSCursor openHandCursor] set]; break; - /* OSX handles busy state itself so no need to set a cursor here */ - case 5: break; - case 6: [[NSCursor resizeLeftRightCursor] set]; break; - case 7: [[NSCursor resizeUpDownCursor] set]; break; - default: return NULL; + return PyUnicode_FromString([filename UTF8String]); } Py_RETURN_NONE; } @@ -1333,8 +1143,7 @@ @implementation WindowServerConnectionManager + (WindowServerConnectionManager *)sharedManager { - if (sharedWindowServerConnectionManager == nil) - { + if (sharedWindowServerConnectionManager == nil) { sharedWindowServerConnectionManager = [[super allocWithZone:NULL] init]; } return sharedWindowServerConnectionManager; @@ -1376,8 +1185,8 @@ - (void)launch:(NSNotification*)notification CFMachPortRef port; CFRunLoopSourceRef source; NSDictionary* dictionary = [notification userInfo]; - if (! [[dictionary valueForKey:@"NSApplicationName"] - isEqualToString:@"Python"]) + if (![[dictionary valueForKey:@"NSApplicationName"] + localizedCaseInsensitiveContainsString:@"python"]) return; NSNumber* psnLow = [dictionary valueForKey: @"NSApplicationProcessSerialNumberLow"]; NSNumber* psnHigh = [dictionary valueForKey: @"NSApplicationProcessSerialNumberHigh"]; @@ -1423,15 +1232,7 @@ - (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen - (BOOL)closeButtonPressed { - PyObject* result; - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(manager, "close", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); + gil_call_method(manager, "_close_button_pressed"); return YES; } @@ -1476,7 +1277,7 @@ - (View*)initWithFrame:(NSRect)rect - (void)dealloc { FigureCanvas* fc = (FigureCanvas*)canvas; - if (fc) fc->view = NULL; + if (fc) { fc->view = NULL; } [super dealloc]; } @@ -1512,7 +1313,7 @@ static int _copy_agg_buffer(CGContextRef cr, PyObject *renderer) const size_t bitsPerPixel = bitsPerComponent * nComponents; const size_t bytesPerRow = nComponents * bytesPerComponent * ncols; - CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB); + CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); if (!colorspace) { _buffer_release(buffer, NULL, 0); return 1; @@ -1565,7 +1366,7 @@ -(void)drawRect:(NSRect)rect CGContextRef cr = [[NSGraphicsContext currentContext] CGContext]; - if (!(renderer = PyObject_CallMethod(canvas, "_draw", "", NULL)) + if (!(renderer = PyObject_CallMethod(canvas, "get_renderer", "")) || !(renderer_buffer = PyObject_GetAttrString(renderer, "_renderer"))) { PyErr_Print(); goto exit; @@ -1575,7 +1376,18 @@ -(void)drawRect:(NSRect)rect goto exit; } if (!NSIsEmptyRect(rubberband)) { - NSFrameRect(rubberband); + // We use bezier paths so we can stroke the outside with a dash + // pattern alternating white/black with two separate paths offset + // in phase. + NSBezierPath *white_path = [NSBezierPath bezierPathWithRect: rubberband]; + NSBezierPath *black_path = [NSBezierPath bezierPathWithRect: rubberband]; + CGFloat dash_pattern[2] = {3, 3}; + [white_path setLineDash: dash_pattern count: 2 phase: 0]; + [black_path setLineDash: dash_pattern count: 2 phase: 3]; + [[NSColor whiteColor] setStroke]; + [white_path stroke]; + [[NSColor blackColor] setStroke]; + [black_path stroke]; } exit: @@ -1591,11 +1403,16 @@ - (void)updateDevicePixelRatio:(double)scale PyGILState_STATE gstate = PyGILState_Ensure(); device_scale = scale; - if (!(change = PyObject_CallMethod(canvas, "_set_device_pixel_ratio", "d", device_scale, NULL))) { + if (!(change = PyObject_CallMethod(canvas, "_set_device_pixel_ratio", "d", device_scale))) { PyErr_Print(); goto exit; } if (PyObject_IsTrue(change)) { + // Notify that there was a resize_event that took place + process_event( + "ResizeEvent", "{s:s, s:O}", + "name", "resize_event", "canvas", canvas); + gil_call_method(canvas, "draw_idle"); [self setNeedsDisplay: YES]; } @@ -1626,7 +1443,7 @@ - (void)windowDidResize: (NSNotification*)notification PyGILState_STATE gstate = PyGILState_Ensure(); PyObject* result = PyObject_CallMethod( canvas, "resize", "ii", width, height); - if(result) + if (result) Py_DECREF(result); else PyErr_Print(); @@ -1636,16 +1453,9 @@ - (void)windowDidResize: (NSNotification*)notification - (void)windowWillClose:(NSNotification*)notification { - PyGILState_STATE gstate; - PyObject* result; - - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(canvas, "close_event", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); + process_event( + "CloseEvent", "{s:s, s:O}", + "name", "close_event", "canvas", canvas); } - (BOOL)windowShouldClose:(NSNotification*)notification @@ -1661,57 +1471,45 @@ - (BOOL)windowShouldClose:(NSNotification*)notification data1: 0 data2: 0]; [NSApp postEvent: event atStart: true]; - if ([window respondsToSelector: @selector(closeButtonPressed)]) - { BOOL closed = [((Window*) window) closeButtonPressed]; - /* If closed, the window has already been closed via the manager. */ - if (closed) return NO; + if ([window respondsToSelector: @selector(closeButtonPressed)]) { + BOOL closed = [((Window*) window) closeButtonPressed]; + /* If closed, the window has already been closed via the manager. */ + if (closed) { return NO; } } return YES; } - (void)mouseEntered:(NSEvent *)event { - PyGILState_STATE gstate; - PyObject* result; - int x, y; NSPoint location = [event locationInWindow]; location = [self convertPoint: location fromView: nil]; x = location.x * device_scale; y = location.y * device_scale; - - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(canvas, "enter_notify_event", "O(ii)", - Py_None, x, y); - - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); + process_event( + "LocationEvent", "{s:s, s:O, s:i, s:i, s:N}", + "name", "figure_enter_event", "canvas", canvas, "x", x, "y", y, + "modifiers", mpl_modifiers(event)); } - (void)mouseExited:(NSEvent *)event { - PyGILState_STATE gstate; - PyObject* result; - - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(canvas, "leave_notify_event", ""); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); + int x, y; + NSPoint location = [event locationInWindow]; + location = [self convertPoint: location fromView: nil]; + x = location.x * device_scale; + y = location.y * device_scale; + process_event( + "LocationEvent", "{s:s, s:O, s:i, s:i, s:N}", + "name", "figure_leave_event", "canvas", canvas, "x", x, "y", y, + "modifiers", mpl_modifiers(event)); } - (void)mouseDown:(NSEvent *)event { int x, y; - int num; + int button; int dblclick = 0; - PyObject* result; - PyGILState_STATE gstate; NSPoint location = [event locationInWindow]; location = [self convertPoint: location fromView: nil]; x = location.x * device_scale; @@ -1721,63 +1519,53 @@ - (void)mouseDown:(NSEvent *)event { unsigned int modifier = [event modifierFlags]; if (modifier & NSEventModifierFlagControl) /* emulate a right-button click */ - num = 3; + button = 3; else if (modifier & NSEventModifierFlagOption) /* emulate a middle-button click */ - num = 2; + button = 2; else { - num = 1; + button = 1; if ([NSCursor currentCursor]==[NSCursor openHandCursor]) [[NSCursor closedHandCursor] set]; } break; } - case NSEventTypeOtherMouseDown: num = 2; break; - case NSEventTypeRightMouseDown: num = 3; break; + case NSEventTypeOtherMouseDown: button = 2; break; + case NSEventTypeRightMouseDown: button = 3; break; default: return; /* Unknown mouse event */ } if ([event clickCount] == 2) { dblclick = 1; } - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(canvas, "button_press_event", "iiii", x, y, num, dblclick); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:i, s:N}", + "name", "button_press_event", "canvas", canvas, "x", x, "y", y, + "button", button, "dblclick", dblclick, "modifiers", mpl_modifiers(event)); } - (void)mouseUp:(NSEvent *)event { - int num; + int button; int x, y; - PyObject* result; - PyGILState_STATE gstate; NSPoint location = [event locationInWindow]; location = [self convertPoint: location fromView: nil]; x = location.x * device_scale; y = location.y * device_scale; switch ([event type]) { case NSEventTypeLeftMouseUp: - num = 1; + button = 1; if ([NSCursor currentCursor]==[NSCursor closedHandCursor]) [[NSCursor openHandCursor] set]; break; - case NSEventTypeOtherMouseUp: num = 2; break; - case NSEventTypeRightMouseUp: num = 3; break; + case NSEventTypeOtherMouseUp: button = 2; break; + case NSEventTypeRightMouseUp: button = 3; break; default: return; /* Unknown mouse event */ } - gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(canvas, "button_release_event", "iii", x, y, num); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:N}", + "name", "button_release_event", "canvas", canvas, "x", x, "y", y, + "button", button, "modifiers", mpl_modifiers(event)); } - (void)mouseMoved:(NSEvent *)event @@ -1787,14 +1575,10 @@ - (void)mouseMoved:(NSEvent *)event location = [self convertPoint: location fromView: nil]; x = location.x * device_scale; y = location.y * device_scale; - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* result = PyObject_CallMethod(canvas, "motion_notify_event", "ii", x, y); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:N}", + "name", "motion_notify_event", "canvas", canvas, "x", x, "y", y, + "modifiers", mpl_modifiers(event)); } - (void)mouseDragged:(NSEvent *)event @@ -1804,14 +1588,10 @@ - (void)mouseDragged:(NSEvent *)event location = [self convertPoint: location fromView: nil]; x = location.x * device_scale; y = location.y * device_scale; - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* result = PyObject_CallMethod(canvas, "motion_notify_event", "ii", x, y); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:N}", + "name", "motion_notify_event", "canvas", canvas, "x", x, "y", y, + "modifiers", mpl_modifiers(event)); } - (void)rightMouseDown:(NSEvent *)event { [self mouseDown: event]; } @@ -1823,147 +1603,156 @@ - (void)otherMouseDragged:(NSEvent *)event { [self mouseDragged: event]; } - (void)setRubberband:(NSRect)rect { - if (!NSIsEmptyRect(rubberband)) [self setNeedsDisplayInRect: rubberband]; + // The space we want to redraw is a union of the previous rubberband + // with the new rubberband and then expanded (negative inset) by one + // in each direction to account for the stroke linewidth. + [self setNeedsDisplayInRect: NSInsetRect(NSUnionRect(rect, rubberband), -1, -1)]; rubberband = rect; - [self setNeedsDisplayInRect: rubberband]; } - (void)removeRubberband { - if (NSIsEmptyRect(rubberband)) return; + if (NSIsEmptyRect(rubberband)) { return; } [self setNeedsDisplayInRect: rubberband]; rubberband = NSZeroRect; } - - - (const char*)convertKeyEvent:(NSEvent*)event { - NSDictionary* specialkeymappings = [NSDictionary dictionaryWithObjectsAndKeys: - @"left", [NSNumber numberWithUnsignedLong:NSLeftArrowFunctionKey], - @"right", [NSNumber numberWithUnsignedLong:NSRightArrowFunctionKey], - @"up", [NSNumber numberWithUnsignedLong:NSUpArrowFunctionKey], - @"down", [NSNumber numberWithUnsignedLong:NSDownArrowFunctionKey], - @"f1", [NSNumber numberWithUnsignedLong:NSF1FunctionKey], - @"f2", [NSNumber numberWithUnsignedLong:NSF2FunctionKey], - @"f3", [NSNumber numberWithUnsignedLong:NSF3FunctionKey], - @"f4", [NSNumber numberWithUnsignedLong:NSF4FunctionKey], - @"f5", [NSNumber numberWithUnsignedLong:NSF5FunctionKey], - @"f6", [NSNumber numberWithUnsignedLong:NSF6FunctionKey], - @"f7", [NSNumber numberWithUnsignedLong:NSF7FunctionKey], - @"f8", [NSNumber numberWithUnsignedLong:NSF8FunctionKey], - @"f9", [NSNumber numberWithUnsignedLong:NSF9FunctionKey], - @"f10", [NSNumber numberWithUnsignedLong:NSF10FunctionKey], - @"f11", [NSNumber numberWithUnsignedLong:NSF11FunctionKey], - @"f12", [NSNumber numberWithUnsignedLong:NSF12FunctionKey], - @"f13", [NSNumber numberWithUnsignedLong:NSF13FunctionKey], - @"f14", [NSNumber numberWithUnsignedLong:NSF14FunctionKey], - @"f15", [NSNumber numberWithUnsignedLong:NSF15FunctionKey], - @"f16", [NSNumber numberWithUnsignedLong:NSF16FunctionKey], - @"f17", [NSNumber numberWithUnsignedLong:NSF17FunctionKey], - @"f18", [NSNumber numberWithUnsignedLong:NSF18FunctionKey], - @"f19", [NSNumber numberWithUnsignedLong:NSF19FunctionKey], - @"scroll_lock", [NSNumber numberWithUnsignedLong:NSScrollLockFunctionKey], - @"break", [NSNumber numberWithUnsignedLong:NSBreakFunctionKey], - @"insert", [NSNumber numberWithUnsignedLong:NSInsertFunctionKey], - @"delete", [NSNumber numberWithUnsignedLong:NSDeleteFunctionKey], - @"home", [NSNumber numberWithUnsignedLong:NSHomeFunctionKey], - @"end", [NSNumber numberWithUnsignedLong:NSEndFunctionKey], - @"pagedown", [NSNumber numberWithUnsignedLong:NSPageDownFunctionKey], - @"pageup", [NSNumber numberWithUnsignedLong:NSPageUpFunctionKey], - @"backspace", [NSNumber numberWithUnsignedLong:NSDeleteCharacter], - @"enter", [NSNumber numberWithUnsignedLong:NSEnterCharacter], - @"tab", [NSNumber numberWithUnsignedLong:NSTabCharacter], - @"enter", [NSNumber numberWithUnsignedLong:NSCarriageReturnCharacter], - @"backtab", [NSNumber numberWithUnsignedLong:NSBackTabCharacter], - @"escape", [NSNumber numberWithUnsignedLong:27], - nil - ]; - NSMutableString* returnkey = [NSMutableString string]; - if ([event modifierFlags] & NSEventModifierFlagControl) - [returnkey appendString:@"ctrl+" ]; - if ([event modifierFlags] & NSEventModifierFlagOption) + if (keyChangeControl) { + // When control is the key that was pressed, return the full word + [returnkey appendString:@"control+"]; + } else if (([event modifierFlags] & NSEventModifierFlagControl)) { + // If control is already pressed, return the shortened version + [returnkey appendString:@"ctrl+"]; + } + if (([event modifierFlags] & NSEventModifierFlagOption) || keyChangeOption) { [returnkey appendString:@"alt+" ]; - if ([event modifierFlags] & NSEventModifierFlagCommand) + } + if (([event modifierFlags] & NSEventModifierFlagCommand) || keyChangeCommand) { [returnkey appendString:@"cmd+" ]; - - unichar uc = [[event charactersIgnoringModifiers] characterAtIndex:0]; - NSString* specialchar = [specialkeymappings objectForKey:[NSNumber numberWithUnsignedLong:uc]]; - if (specialchar){ - if ([event modifierFlags] & NSEventModifierFlagShift) - [returnkey appendString:@"shift+" ]; - [returnkey appendString:specialchar]; } - else - [returnkey appendString:[event charactersIgnoringModifiers]]; + // Don't print caps_lock unless it was the key that got pressed + if (keyChangeCapsLock) { + [returnkey appendString:@"caps_lock+" ]; + } + + // flagsChanged event can't handle charactersIgnoringModifiers + // because it was a modifier key that was pressed/released + if (event.type != NSEventTypeFlagsChanged) { + NSString* specialchar; + switch ([[event charactersIgnoringModifiers] characterAtIndex:0]) { + case NSLeftArrowFunctionKey: specialchar = @"left"; break; + case NSRightArrowFunctionKey: specialchar = @"right"; break; + case NSUpArrowFunctionKey: specialchar = @"up"; break; + case NSDownArrowFunctionKey: specialchar = @"down"; break; + case NSF1FunctionKey: specialchar = @"f1"; break; + case NSF2FunctionKey: specialchar = @"f2"; break; + case NSF3FunctionKey: specialchar = @"f3"; break; + case NSF4FunctionKey: specialchar = @"f4"; break; + case NSF5FunctionKey: specialchar = @"f5"; break; + case NSF6FunctionKey: specialchar = @"f6"; break; + case NSF7FunctionKey: specialchar = @"f7"; break; + case NSF8FunctionKey: specialchar = @"f8"; break; + case NSF9FunctionKey: specialchar = @"f9"; break; + case NSF10FunctionKey: specialchar = @"f10"; break; + case NSF11FunctionKey: specialchar = @"f11"; break; + case NSF12FunctionKey: specialchar = @"f12"; break; + case NSF13FunctionKey: specialchar = @"f13"; break; + case NSF14FunctionKey: specialchar = @"f14"; break; + case NSF15FunctionKey: specialchar = @"f15"; break; + case NSF16FunctionKey: specialchar = @"f16"; break; + case NSF17FunctionKey: specialchar = @"f17"; break; + case NSF18FunctionKey: specialchar = @"f18"; break; + case NSF19FunctionKey: specialchar = @"f19"; break; + case NSScrollLockFunctionKey: specialchar = @"scroll_lock"; break; + case NSBreakFunctionKey: specialchar = @"break"; break; + case NSInsertFunctionKey: specialchar = @"insert"; break; + case NSDeleteFunctionKey: specialchar = @"delete"; break; + case NSHomeFunctionKey: specialchar = @"home"; break; + case NSEndFunctionKey: specialchar = @"end"; break; + case NSPageDownFunctionKey: specialchar = @"pagedown"; break; + case NSPageUpFunctionKey: specialchar = @"pageup"; break; + case NSDeleteCharacter: specialchar = @"backspace"; break; + case NSEnterCharacter: specialchar = @"enter"; break; + case NSTabCharacter: specialchar = @"tab"; break; + case NSCarriageReturnCharacter: specialchar = @"enter"; break; + case NSBackTabCharacter: specialchar = @"backtab"; break; + case 27: specialchar = @"escape"; break; + default: specialchar = nil; + } + if (specialchar) { + if (([event modifierFlags] & NSEventModifierFlagShift) || keyChangeShift) { + [returnkey appendString:@"shift+"]; + } + [returnkey appendString:specialchar]; + } else { + [returnkey appendString:[event charactersIgnoringModifiers]]; + } + } else { + if (([event modifierFlags] & NSEventModifierFlagShift) || keyChangeShift) { + [returnkey appendString:@"shift+"]; + } + // Since it was a modifier event trim the final character of the string + // because we added in "+" earlier + [returnkey setString: [returnkey substringToIndex:[returnkey length] - 1]]; + } return [returnkey UTF8String]; } - (void)keyDown:(NSEvent*)event { - PyObject* result; const char* s = [self convertKeyEvent: event]; - PyGILState_STATE gstate = PyGILState_Ensure(); - if (s==NULL) - { - result = PyObject_CallMethod(canvas, "key_press_event", "O", Py_None); - } - else - { - result = PyObject_CallMethod(canvas, "key_press_event", "s", s); + NSPoint location = [[self window] mouseLocationOutsideOfEventStream]; + location = [self convertPoint: location fromView: nil]; + int x = location.x * device_scale, + y = location.y * device_scale; + if (s) { + process_event( + "KeyEvent", "{s:s, s:O, s:s, s:i, s:i}", + "name", "key_press_event", "canvas", canvas, "key", s, "x", x, "y", y); + } else { + process_event( + "KeyEvent", "{s:s, s:O, s:O, s:i, s:i}", + "name", "key_press_event", "canvas", canvas, "key", Py_None, "x", x, "y", y); } - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); } - (void)keyUp:(NSEvent*)event { - PyObject* result; const char* s = [self convertKeyEvent: event]; - PyGILState_STATE gstate = PyGILState_Ensure(); - if (s==NULL) - { - result = PyObject_CallMethod(canvas, "key_release_event", "O", Py_None); - } - else - { - result = PyObject_CallMethod(canvas, "key_release_event", "s", s); + NSPoint location = [[self window] mouseLocationOutsideOfEventStream]; + location = [self convertPoint: location fromView: nil]; + int x = location.x * device_scale, + y = location.y * device_scale; + if (s) { + process_event( + "KeyEvent", "{s:s, s:O, s:s, s:i, s:i}", + "name", "key_release_event", "canvas", canvas, "key", s, "x", x, "y", y); + } else { + process_event( + "KeyEvent", "{s:s, s:O, s:O, s:i, s:i}", + "name", "key_release_event", "canvas", canvas, "key", Py_None, "x", x, "y", y); } - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); } - (void)scrollWheel:(NSEvent*)event { int step; float d = [event deltaY]; - if (d > 0) step = 1; - else if (d < 0) step = -1; + if (d > 0) { step = 1; } + else if (d < 0) { step = -1; } else return; NSPoint location = [event locationInWindow]; NSPoint point = [self convertPoint: location fromView: nil]; int x = (int)round(point.x * device_scale); int y = (int)round(point.y * device_scale - 1); - - PyObject* result; - PyGILState_STATE gstate = PyGILState_Ensure(); - result = PyObject_CallMethod(canvas, "scroll_event", "iii", x, y, step); - if(result) - Py_DECREF(result); - else - PyErr_Print(); - - PyGILState_Release(gstate); + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:N}", + "name", "scroll_event", "canvas", canvas, + "x", x, "y", y, "step", step, "modifiers", mpl_modifiers(event)); } - (BOOL)acceptsFirstResponder @@ -1971,29 +1760,60 @@ - (BOOL)acceptsFirstResponder return YES; } -/* This is all wrong. Address of pointer is being passed instead of pointer, keynames don't - match up with what the front-end and does the front-end even handle modifier keys by themselves? +// flagsChanged gets called whenever a modifier key is pressed OR released +// so we need to handle both cases here +- (void)flagsChanged:(NSEvent *)event +{ + bool isPress = false; // true if key is pressed, false if key was released + + // Each if clause tests the two cases for each of the keys we can handle + // 1. If the modifier flag "command key" is pressed and it was not previously + // 2. If the modifier flag "command key" is not pressed and it was previously + // !! converts the result of the bitwise & operator to a logical boolean, + // which allows us to then bitwise xor (^) the result with a boolean (lastCommand). + if (!!([event modifierFlags] & NSEventModifierFlagCommand) ^ lastCommand) { + // Command pressed/released + lastCommand = !lastCommand; + keyChangeCommand = true; + isPress = lastCommand; + } else if (!!([event modifierFlags] & NSEventModifierFlagControl) ^ lastControl) { + // Control pressed/released + lastControl = !lastControl; + keyChangeControl = true; + isPress = lastControl; + } else if (!!([event modifierFlags] & NSEventModifierFlagShift) ^ lastShift) { + // Shift pressed/released + lastShift = !lastShift; + keyChangeShift = true; + isPress = lastShift; + } else if (!!([event modifierFlags] & NSEventModifierFlagOption) ^ lastOption) { + // Option pressed/released + lastOption = !lastOption; + keyChangeOption = true; + isPress = lastOption; + } else if (!!([event modifierFlags] & NSEventModifierFlagCapsLock) ^ lastCapsLock) { + // Capslock pressed/released + lastCapsLock = !lastCapsLock; + keyChangeCapsLock = true; + isPress = lastCapsLock; + } else { + // flag we don't handle + return; + } -- (void)flagsChanged:(NSEvent*)event -{ - const char *s = NULL; - if (([event modifierFlags] & NSControlKeyMask) == NSControlKeyMask) - s = "control"; - else if (([event modifierFlags] & NSShiftKeyMask) == NSShiftKeyMask) - s = "shift"; - else if (([event modifierFlags] & NSAlternateKeyMask) == NSAlternateKeyMask) - s = "alt"; - else return; - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* result = PyObject_CallMethod(canvas, "key_press_event", "s", &s); - if(result) - Py_DECREF(result); - else - PyErr_Print(); + if (isPress) { + [self keyDown:event]; + } else { + [self keyUp:event]; + } - PyGILState_Release(gstate); + // Reset the state for the key changes after handling the event + keyChangeCommand = false; + keyChangeControl = false; + keyChangeShift = false; + keyChangeOption = false; + keyChangeCapsLock = false; } - */ @end static PyObject* @@ -2022,7 +1842,7 @@ - (void)flagsChanged:(NSEvent*)event { lazy_init(); Timer* self = (Timer*)type->tp_alloc(type, 0); - if (!self) return NULL; + if (!self) { return NULL; } self->timer = NULL; return (PyObject*) self; } @@ -2034,20 +1854,9 @@ - (void)flagsChanged:(NSEvent*)event (void*) self, (void*)(self->timer)); } -static char Timer_doc[] = -"A Timer object wraps a CFRunLoopTimerRef and can add it to the event loop.\n"; - static void timer_callback(CFRunLoopTimerRef timer, void* info) { - PyObject* method = info; - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* result = PyObject_CallFunction(method, NULL); - if (result) { - Py_DECREF(result); - } else { - PyErr_Print(); - } - PyGILState_Release(gstate); + gil_call_method(info, "_on_timer"); } static void context_cleanup(const void* info) @@ -2086,12 +1895,12 @@ static void context_cleanup(const void* info) PyErr_SetString(PyExc_RuntimeError, "_on_timer should be a Python method"); goto exit; } - Py_INCREF(py_on_timer); + Py_INCREF(self); context.version = 0; context.retain = NULL; context.release = context_cleanup; context.copyDescription = NULL; - context.info = py_on_timer; + context.info = self; timer = CFRunLoopTimerCreate(kCFAllocatorDefault, firstFire, interval, @@ -2141,118 +1950,62 @@ static void context_cleanup(const void* info) Py_TYPE(self)->tp_free((PyObject*)self); } -static PyMethodDef Timer_methods[] = { - {"_timer_start", - (PyCFunction)Timer__timer_start, - METH_VARARGS, - "Initialize and start the timer." - }, - {"_timer_stop", - (PyCFunction)Timer__timer_stop, - METH_NOARGS, - "Stop the timer." - }, - {NULL} /* Sentinel */ -}; - static PyTypeObject TimerType = { PyVarObject_HEAD_INIT(NULL, 0) - "_macosx.Timer", /*tp_name*/ - sizeof(Timer), /*tp_basicsize*/ - 0, /*tp_itemsize*/ - (destructor)Timer_dealloc, /*tp_dealloc*/ - 0, /*tp_print*/ - 0, /*tp_getattr*/ - 0, /*tp_setattr*/ - 0, /*tp_compare*/ - (reprfunc)Timer_repr, /*tp_repr*/ - 0, /*tp_as_number*/ - 0, /*tp_as_sequence*/ - 0, /*tp_as_mapping*/ - 0, /*tp_hash */ - 0, /*tp_call*/ - 0, /*tp_str*/ - 0, /*tp_getattro*/ - 0, /*tp_setattro*/ - 0, /*tp_as_buffer*/ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ - Timer_doc, /* tp_doc */ - 0, /* tp_traverse */ - 0, /* tp_clear */ - 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ - 0, /* tp_iter */ - 0, /* tp_iternext */ - Timer_methods, /* tp_methods */ - 0, /* tp_members */ - 0, /* tp_getset */ - 0, /* tp_base */ - 0, /* tp_dict */ - 0, /* tp_descr_get */ - 0, /* tp_descr_set */ - 0, /* tp_dictoffset */ - 0, /* tp_init */ - 0, /* tp_alloc */ - Timer_new, /* tp_new */ -}; - -static struct PyMethodDef methods[] = { - {"event_loop_is_running", - (PyCFunction)event_loop_is_running, - METH_NOARGS, - "Return whether the OSX backend has set up the NSApp main event loop." - }, - {"show", - (PyCFunction)show, - METH_NOARGS, - "Show all the figures and enter the main loop.\n" - "\n" - "This function does not return until all Matplotlib windows are closed,\n" - "and is normally not needed in interactive sessions." - }, - {"choose_save_file", - (PyCFunction)choose_save_file, - METH_VARARGS, - "Closes the window." - }, - {"set_cursor", - (PyCFunction)set_cursor, - METH_VARARGS, - "Sets the active cursor." - }, - {NULL, NULL, 0, NULL} /* sentinel */ + .tp_name = "_macosx.Timer", + .tp_basicsize = sizeof(Timer), + .tp_dealloc = (destructor)Timer_dealloc, + .tp_repr = (reprfunc)Timer_repr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_new = (newfunc)Timer_new, + .tp_doc = "A Timer object wraps a CFRunLoopTimerRef and can add it to the event loop.", + .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + {"_timer_start", + (PyCFunction)Timer__timer_start, + METH_VARARGS}, + {"_timer_stop", + (PyCFunction)Timer__timer_stop, + METH_NOARGS}, + {} // sentinel + }, }; static struct PyModuleDef moduledef = { - PyModuleDef_HEAD_INIT, "_macosx", "Mac OS X native backend", -1, methods + PyModuleDef_HEAD_INIT, "_macosx", "Mac OS X native backend", -1, + (PyMethodDef[]){ + {"event_loop_is_running", + (PyCFunction)event_loop_is_running, + METH_NOARGS, + "Return whether the OSX backend has set up the NSApp main event loop."}, + {"show", + (PyCFunction)show, + METH_NOARGS, + "Show all the figures and enter the main loop.\n" + "\n" + "This function does not return until all Matplotlib windows are closed,\n" + "and is normally not needed in interactive sessions."}, + {"choose_save_file", + (PyCFunction)choose_save_file, + METH_VARARGS, + "Query the user for a location where to save a file."}, + {} /* Sentinel */ + }, }; #pragma GCC visibility push(default) PyObject* PyInit__macosx(void) { - PyObject *module; - - if (PyType_Ready(&FigureCanvasType) < 0 - || PyType_Ready(&FigureManagerType) < 0 - || PyType_Ready(&NavigationToolbar2Type) < 0 - || PyType_Ready(&TimerType) < 0) - return NULL; - - module = PyModule_Create(&moduledef); - if (!module) + PyObject *m; + if (!(m = PyModule_Create(&moduledef)) + || prepare_and_add_type(&FigureCanvasType, m) + || prepare_and_add_type(&FigureManagerType, m) + || prepare_and_add_type(&NavigationToolbar2Type, m) + || prepare_and_add_type(&TimerType, m)) { + Py_XDECREF(m); return NULL; - - Py_INCREF(&FigureCanvasType); - Py_INCREF(&FigureManagerType); - Py_INCREF(&NavigationToolbar2Type); - Py_INCREF(&TimerType); - PyModule_AddObject(module, "FigureCanvas", (PyObject*) &FigureCanvasType); - PyModule_AddObject(module, "FigureManager", (PyObject*) &FigureManagerType); - PyModule_AddObject(module, "NavigationToolbar2", (PyObject*) &NavigationToolbar2Type); - PyModule_AddObject(module, "Timer", (PyObject*) &TimerType); - - return module; + } + return m; } #pragma GCC visibility pop diff --git a/src/_path.h b/src/_path.h index f4c8fd036d2c..0c115e3d2735 100644 --- a/src/_path.h +++ b/src/_path.h @@ -840,7 +840,7 @@ inline bool segments_intersect(const double &x1, // If den == 0 we have two possibilities: if (isclose(den, 0.0)) { - float t_area = (x2*y3 - x3*y2) - x1*(y3 - y2) + y1*(x3 - x2); + double t_area = (x2*y3 - x3*y2) - x1*(y3 - y2) + y1*(x3 - x2); // 1 - If the area of the triangle made by the 3 first points (2 from the first segment // plus one from the second) is zero, they are collinear if (isclose(t_area, 0.0)) { @@ -852,7 +852,6 @@ inline bool segments_intersect(const double &x1, else { return (fmin(x1, x2) <= fmin(x3, x4) && fmin(x3, x4) <= fmax(x1, x2)) || (fmin(x3, x4) <= fmin(x1, x2) && fmin(x1, x2) <= fmax(x3, x4)); - } } // 2 - If t_area is not zero, the segments are parallel, but not collinear @@ -876,7 +875,6 @@ inline bool segments_intersect(const double &x1, template bool path_intersects_path(PathIterator1 &p1, PathIterator2 &p2) { - typedef PathNanRemover no_nans_t; typedef agg::conv_curve curve_t; @@ -901,7 +899,6 @@ bool path_intersects_path(PathIterator1 &p1, PathIterator2 &p2) } c2.rewind(0); c2.vertex(&x21, &y21); - while (c2.vertex(&x22, &y22) != agg::path_cmd_stop) { // if the segment in path 2 is (almost) 0 length, skip to next vertex @@ -1147,16 +1144,15 @@ bool __convert_to_string(PathIterator &path, double last_x = 0.0; double last_y = 0.0; - int size = 0; unsigned code; while ((code = path.vertex(&x[0], &y[0])) != agg::path_cmd_stop) { if (code == CLOSEPOLY) { buffer += codes[4]; } else if (code < 5) { - size = NUM_VERTICES[code]; + size_t size = NUM_VERTICES[code]; - for (int i = 1; i < size; ++i) { + for (size_t i = 1; i < size; ++i) { unsigned subcode = path.vertex(&x[i], &y[i]); if (subcode != code) { return false; @@ -1176,7 +1172,7 @@ bool __convert_to_string(PathIterator &path, buffer += ' '; } - for (int i = 0; i < size; ++i) { + for (size_t i = 0; i < size; ++i) { __add_number(x[i], format_code, precision, buffer); buffer += ' '; __add_number(y[i], format_code, precision, buffer); @@ -1226,7 +1222,7 @@ bool convert_to_string(PathIterator &path, clipped_t clipped(nan_removed, do_clip, clip_rect); simplify_t simplified(clipped, simplify, path.simplify_threshold()); - buffersize = path.total_vertices() * (precision + 5) * 4; + buffersize = (size_t) path.total_vertices() * (precision + 5) * 4; if (buffersize == 0) { return true; } @@ -1248,70 +1244,24 @@ bool convert_to_string(PathIterator &path, } template -struct _is_sorted +bool is_sorted(PyArrayObject *array) { - bool operator()(PyArrayObject *array) - { - npy_intp size; - npy_intp i; - T last_value; - T current_value; - - size = PyArray_DIM(array, 0); - - // std::isnan is only in C++11, which we don't yet require, - // so we use the "self == self" trick - for (i = 0; i < size; ++i) { - last_value = *((T *)PyArray_GETPTR1(array, i)); - if (last_value == last_value) { - break; - } - } - - if (i == size) { - // The whole array is non-finite - return false; - } - - for (; i < size; ++i) { - current_value = *((T *)PyArray_GETPTR1(array, i)); - if (current_value == current_value) { - if (current_value < last_value) { - return false; - } - last_value = current_value; - } - } - - return true; - } -}; - - -template -struct _is_sorted_int -{ - bool operator()(PyArrayObject *array) - { - npy_intp size; - npy_intp i; - T last_value; - T current_value; - - size = PyArray_DIM(array, 0); - - last_value = *((T *)PyArray_GETPTR1(array, 0)); - - for (i = 1; i < size; ++i) { - current_value = *((T *)PyArray_GETPTR1(array, i)); - if (current_value < last_value) { + npy_intp size = PyArray_DIM(array, 0); + using limits = std::numeric_limits; + T last = limits::has_infinity ? -limits::infinity() : limits::min(); + + for (npy_intp i = 0; i < size; ++i) { + T current = *(T *)PyArray_GETPTR1(array, i); + // The following tests !isnan(current), but also works for integral + // types. (The isnan(IntegralType) overload is absent on MSVC.) + if (current == current) { + if (current < last) { return false; } - last_value = current_value; + last = current; } - - return true; } + return true; }; diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp index 76b118f9e722..8c297907ab98 100644 --- a/src/_path_wrapper.cpp +++ b/src/_path_wrapper.cpp @@ -312,7 +312,7 @@ static PyObject *Py_get_path_collection_extents(PyObject *self, PyObject *args) const char *Py_point_in_path_collection__doc__ = "point_in_path_collection(" "x, y, radius, master_transform, paths, transforms, offsets, " - "offset_trans, filled, offset_position)\n" + "offset_trans, filled)\n" "--\n\n"; static PyObject *Py_point_in_path_collection(PyObject *self, PyObject *args) @@ -324,11 +324,10 @@ static PyObject *Py_point_in_path_collection(PyObject *self, PyObject *args) numpy::array_view offsets; agg::trans_affine offset_trans; bool filled; - PyObject *offset_position; // no longer used std::vector result; if (!PyArg_ParseTuple(args, - "dddO&O&O&O&O&O&O:point_in_path_collection", + "dddO&O&O&O&O&O&:point_in_path_collection", &x, &y, &radius, @@ -343,8 +342,7 @@ static PyObject *Py_point_in_path_collection(PyObject *self, PyObject *args) &convert_trans_affine, &offset_trans, &convert_bool, - &filled, - &offset_position)) { + &filled)) { return NULL; } @@ -699,7 +697,7 @@ const char *Py_convert_to_string__doc__ = "--\n\n" "Convert *path* to a bytestring.\n" "\n" - "The first five parameters (up to *sketch*) are interpreted as in \n" + "The first five parameters (up to *sketch*) are interpreted as in\n" "`.cleanup_path`. The following ones are detailed below.\n" "\n" "Parameters\n" @@ -711,7 +709,7 @@ const char *Py_convert_to_string__doc__ = "sketch : tuple of 3 floats, or None\n" "precision : int\n" " The precision used to \"%.*f\"-format the values. Trailing zeros\n" - " and decimal points are always removed. (precision=-1 is a special \n" + " and decimal points are always removed. (precision=-1 is a special\n" " case used to implement ttconv-back-compatible conversion.)\n" "codes : sequence of 5 bytestrings\n" " The bytes representation of each opcode (MOVETO, LINETO, CURVE3,\n" @@ -809,54 +807,29 @@ static PyObject *Py_is_sorted(PyObject *self, PyObject *obj) /* Handle just the most common types here, otherwise coerce to double */ - switch(PyArray_TYPE(array)) { + switch (PyArray_TYPE(array)) { case NPY_INT: - { - _is_sorted_int is_sorted; - result = is_sorted(array); - } + result = is_sorted(array); break; - case NPY_LONG: - { - _is_sorted_int is_sorted; - result = is_sorted(array); - } + result = is_sorted(array); break; - case NPY_LONGLONG: - { - _is_sorted_int is_sorted; - result = is_sorted(array); - } + result = is_sorted(array); break; - case NPY_FLOAT: - { - _is_sorted is_sorted; - result = is_sorted(array); - } + result = is_sorted(array); break; - case NPY_DOUBLE: - { - _is_sorted is_sorted; - result = is_sorted(array); - } + result = is_sorted(array); break; - default: - { - Py_DECREF(array); - array = (PyArrayObject *)PyArray_FromObject(obj, NPY_DOUBLE, 1, 1); - - if (array == NULL) { - return NULL; - } - - _is_sorted is_sorted; - result = is_sorted(array); + Py_DECREF(array); + array = (PyArrayObject *)PyArray_FromObject(obj, NPY_DOUBLE, 1, 1); + if (array == NULL) { + return NULL; } + result = is_sorted(array); } Py_DECREF(array); diff --git a/src/qhull_wrap.cpp b/src/_qhull_wrapper.cpp similarity index 94% rename from src/qhull_wrap.cpp rename to src/_qhull_wrapper.cpp index cdebaf730493..e27c4215b96e 100644 --- a/src/qhull_wrap.cpp +++ b/src/_qhull_wrapper.cpp @@ -301,8 +301,25 @@ version(PyObject *self, PyObject *arg) } static PyMethodDef qhull_methods[] = { - {"delaunay", delaunay, METH_VARARGS, ""}, - {"version", version, METH_NOARGS, ""}, + {"delaunay", delaunay, METH_VARARGS, + "delaunay(x, y, /)\n" + "--\n\n" + "Compute a Delaunay triangulation.\n" + "\n" + "Parameters\n" + "----------\n" + "x, y : 1d arrays\n" + " The coordinates of the point set, which must consist of at least\n" + " three unique points.\n" + "\n" + "Returns\n" + "-------\n" + "triangles, neighbors : int arrays, shape (ntri, 3)\n" + " Indices of triangle vertices and indices of triangle neighbors.\n" + }, + {"version", version, METH_NOARGS, + "version()\n--\n\n" + "Return the qhull version string."}, {NULL, NULL, 0, NULL} }; diff --git a/src/_tkagg.cpp b/src/_tkagg.cpp index 5a5616bea539..663c06fd0474 100644 --- a/src/_tkagg.cpp +++ b/src/_tkagg.cpp @@ -19,11 +19,15 @@ /* * Unfortunately cygwin's libdl inherits restrictions from the underlying * Windows OS, at least currently. Therefore, a symbol may be loaded from a - * module by dlsym() only if it is really located in the given modile, + * module by dlsym() only if it is really located in the given module, * dependencies are not included. So we have to use native WinAPI on Cygwin * also. */ #define WIN32_DLL +static inline PyObject *PyErr_SetFromWindowsErr(int ierr) { + PyErr_SetString(PyExc_OSError, "Call to EnumProcessModules failed"); + return NULL; +} #endif #ifdef WIN32_DLL @@ -51,11 +55,9 @@ static int convert_voidptr(PyObject *obj, void *p) // extension module or loaded Tk libraries at run-time. static Tk_FindPhoto_t TK_FIND_PHOTO; static Tk_PhotoPutBlock_t TK_PHOTO_PUT_BLOCK; -#ifdef WIN32_DLL // Global vars for Tcl functions. We load these symbols from the tkinter // extension module or loaded Tcl libraries at run-time. static Tcl_SetVar_t TCL_SETVAR; -#endif static PyObject *mpl_tk_blit(PyObject *self, PyObject *args) { @@ -225,28 +227,24 @@ static PyMethodDef functions[] = { // Functions to fill global Tcl/Tk function pointers by dynamic loading. template -int load_tk(T lib) +bool load_tcl_tk(T lib) { - // Try to fill Tk global vars with function pointers. Return the number of - // functions found. - return - !!(TK_FIND_PHOTO = - (Tk_FindPhoto_t)dlsym(lib, "Tk_FindPhoto")) + - !!(TK_PHOTO_PUT_BLOCK = - (Tk_PhotoPutBlock_t)dlsym(lib, "Tk_PhotoPutBlock")); + // Try to fill Tcl/Tk global vars with function pointers. Return whether + // all of them have been filled. + if (auto ptr = dlsym(lib, "Tcl_SetVar")) { + TCL_SETVAR = (Tcl_SetVar_t)ptr; + } + if (auto ptr = dlsym(lib, "Tk_FindPhoto")) { + TK_FIND_PHOTO = (Tk_FindPhoto_t)ptr; + } + if (auto ptr = dlsym(lib, "Tk_PhotoPutBlock")) { + TK_PHOTO_PUT_BLOCK = (Tk_PhotoPutBlock_t)ptr; + } + return TCL_SETVAR && TK_FIND_PHOTO && TK_PHOTO_PUT_BLOCK; } #ifdef WIN32_DLL -template -int load_tcl(T lib) -{ - // Try to fill Tcl global vars with function pointers. Return the number of - // functions found. - return - !!(TCL_SETVAR = (Tcl_SetVar_t)dlsym(lib, "Tcl_SetVar")); -} - /* On Windows, we can't load the tkinter module to get the Tcl/Tk symbols, * because Windows does not load symbols into the library name-space of * importing modules. So, knowing that tkinter has already been imported by @@ -271,13 +269,9 @@ void load_tkinter_funcs(void) PyErr_SetFromWindowsErr(0); goto exit; } - bool tcl_ok = false, tk_ok = false; for (unsigned i = 0; i < size / sizeof(HMODULE); ++i) { - if (!tcl_ok) { - tcl_ok = load_tcl(modules[i]); - } - if (!tk_ok) { - tk_ok = load_tk(modules[i]); + if (load_tcl_tk(modules[i])) { + return; } } exit: @@ -301,7 +295,7 @@ void load_tkinter_funcs(void) // Try loading from the main program namespace first. main_program = dlopen(NULL, RTLD_LAZY); - if (load_tk(main_program)) { + if (load_tcl_tk(main_program)) { goto exit; } // Clear exception triggered when we didn't find symbols above. @@ -324,7 +318,7 @@ void load_tkinter_funcs(void) PyErr_SetString(PyExc_RuntimeError, dlerror()); goto exit; } - if (load_tk(tkinter_lib)) { + if (load_tcl_tk(tkinter_lib)) { goto exit; } @@ -351,18 +345,22 @@ static PyModuleDef _tkagg_module = { PyMODINIT_FUNC PyInit__tkagg(void) { load_tkinter_funcs(); - if (PyErr_Occurred()) { + PyObject *type, *value, *traceback; + PyErr_Fetch(&type, &value, &traceback); + // Always raise ImportError (normalizing a previously set exception if + // needed) to interact properly with backend auto-fallback. + if (value) { + PyErr_NormalizeException(&type, &value, &traceback); + PyErr_SetObject(PyExc_ImportError, value); return NULL; -#ifdef WIN32_DLL } else if (!TCL_SETVAR) { - PyErr_SetString(PyExc_RuntimeError, "Failed to load Tcl_SetVar"); + PyErr_SetString(PyExc_ImportError, "Failed to load Tcl_SetVar"); return NULL; -#endif } else if (!TK_FIND_PHOTO) { - PyErr_SetString(PyExc_RuntimeError, "Failed to load Tk_FindPhoto"); + PyErr_SetString(PyExc_ImportError, "Failed to load Tk_FindPhoto"); return NULL; } else if (!TK_PHOTO_PUT_BLOCK) { - PyErr_SetString(PyExc_RuntimeError, "Failed to load Tk_PhotoPutBlock"); + PyErr_SetString(PyExc_ImportError, "Failed to load Tk_PhotoPutBlock"); return NULL; } return PyModule_Create(&_tkagg_module); diff --git a/src/_tkmini.h b/src/_tkmini.h index be7c13fa14d5..85f245815e4c 100644 --- a/src/_tkmini.h +++ b/src/_tkmini.h @@ -86,8 +86,8 @@ typedef struct Tk_PhotoImageBlock int offset[4]; } Tk_PhotoImageBlock; -#define TK_PHOTO_COMPOSITE_OVERLAY 0 // apply transparency rules pixel-wise -#define TK_PHOTO_COMPOSITE_SET 1 // set image buffer directly +#define TK_PHOTO_COMPOSITE_OVERLAY 0 // apply transparency rules pixel-wise +#define TK_PHOTO_COMPOSITE_SET 1 // set image buffer directly #define TCL_OK 0 #define TCL_ERROR 1 @@ -100,11 +100,10 @@ typedef int (*Tk_PhotoPutBlock_t) (Tcl_Interp *interp, Tk_PhotoHandle handle, Tk_PhotoImageBlock *blockPtr, int x, int y, int width, int height, int compRule); -#ifdef WIN32_DLL /* Typedefs derived from function signatures in Tcl header */ +/* Tcl_SetVar typedef */ typedef const char *(*Tcl_SetVar_t)(Tcl_Interp *interp, const char *varName, const char *newValue, int flags); -#endif #ifdef __cplusplus } diff --git a/src/ft2font.cpp b/src/ft2font.cpp index bb7fb1fa2980..1dc831545554 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -3,6 +3,7 @@ #define NO_IMPORT_ARRAY #include +#include #include #include #include @@ -43,9 +44,23 @@ FT_Library _ft2Library; +// FreeType error codes; loaded as per fterror.h. +static char const* ft_error_string(FT_Error error) { +#undef __FTERRORS_H__ +#define FT_ERROR_START_LIST switch (error) { +#define FT_ERRORDEF( e, v, s ) case v: return s; +#define FT_ERROR_END_LIST default: return NULL; } +#include FT_ERRORS_H +} + void throw_ft_error(std::string message, FT_Error error) { + char const* s = ft_error_string(error); std::ostringstream os(""); - os << message << " (error code 0x" << std::hex << error << ")"; + if (s) { + os << message << " (" << s << "; error code 0x" << std::hex << error << ")"; + } else { // Should not occur, but don't add another error from failed lookup. + os << message << " (error code 0x" << std::hex << error << ")"; + } throw std::runtime_error(os.str()); } @@ -169,12 +184,8 @@ FT2Image::draw_rect_filled(unsigned long x0, unsigned long y0, unsigned long x1, m_dirty = true; } -static FT_UInt ft_get_char_index_or_warn(FT_Face face, FT_ULong charcode) +static void ft_glyph_warn(FT_ULong charcode) { - FT_UInt glyph_index = FT_Get_Char_Index(face, charcode); - if (glyph_index) { - return glyph_index; - } PyObject *text_helpers = NULL, *tmp = NULL; if (!(text_helpers = PyImport_ImportModule("matplotlib._text_helpers")) || !(tmp = PyObject_CallMethod(text_helpers, "warn_on_missing_glyph", "k", charcode))) { @@ -186,9 +197,20 @@ static FT_UInt ft_get_char_index_or_warn(FT_Face face, FT_ULong charcode) if (PyErr_Occurred()) { throw py::exception(); } - return 0; } +static FT_UInt +ft_get_char_index_or_warn(FT_Face face, FT_ULong charcode, bool warn = true) +{ + FT_UInt glyph_index = FT_Get_Char_Index(face, charcode); + if (glyph_index) { + return glyph_index; + } + if (warn) { + ft_glyph_warn(charcode); + } + return 0; +} // ft_outline_decomposer should be passed to FT_Outline_Decompose. On the // first pass, vertices and codes are set to NULL, and index is simply @@ -319,19 +341,15 @@ FT2Font::get_path() return Py_BuildValue("NN", vertices.pyobj(), codes.pyobj()); } -FT2Font::FT2Font(FT_Open_Args &open_args, long hinting_factor_) : image(), face(NULL) +FT2Font::FT2Font(FT_Open_Args &open_args, + long hinting_factor_, + std::vector &fallback_list) + : image(), face(NULL) { clear(); FT_Error error = FT_Open_Face(_ft2Library, &open_args, 0, &face); - - if (error == FT_Err_Unknown_File_Format) { - throw std::runtime_error("Can not load face. Unknown file format."); - } else if (error == FT_Err_Cannot_Open_Resource) { - throw std::runtime_error("Can not load face. Can not open resource."); - } else if (error == FT_Err_Invalid_File_Format) { - throw std::runtime_error("Can not load face. Invalid file format."); - } else if (error) { + if (error) { throw_ft_error("Can not load face", error); } @@ -353,6 +371,9 @@ FT2Font::FT2Font(FT_Open_Args &open_args, long hinting_factor_) : image(), face( FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; FT_Set_Transform(face, &transform, 0); + + // Set fallbacks + std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); } FT2Font::~FT2Font() @@ -376,6 +397,12 @@ void FT2Font::clear() } glyphs.clear(); + glyph_to_font.clear(); + char_to_font.clear(); + + for (size_t i = 0; i < fallbacks.size(); i++) { + fallbacks[i]->clear(); + } } void FT2Font::set_size(double ptsize, double dpi) @@ -387,6 +414,10 @@ void FT2Font::set_size(double ptsize, double dpi) } FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; FT_Set_Transform(face, &transform, 0); + + for (size_t i = 0; i < fallbacks.size(); i++) { + fallbacks[i]->set_size(ptsize, dpi); + } } void FT2Font::set_charmap(int i) @@ -407,12 +438,32 @@ void FT2Font::select_charmap(unsigned long i) } } -int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode) +int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode, bool fallback = false) +{ + if (fallback && glyph_to_font.find(left) != glyph_to_font.end() && + glyph_to_font.find(right) != glyph_to_font.end()) { + FT2Font *left_ft_object = glyph_to_font[left]; + FT2Font *right_ft_object = glyph_to_font[right]; + if (left_ft_object != right_ft_object) { + // we do not know how to do kerning between different fonts + return 0; + } + // if left_ft_object is the same as right_ft_object, + // do the exact same thing which set_text does. + return right_ft_object->get_kerning(left, right, mode, false); + } + else + { + FT_Vector delta; + return get_kerning(left, right, mode, delta); + } +} + +int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode, FT_Vector &delta) { if (!FT_HAS_KERNING(face)) { return 0; } - FT_Vector delta; if (!FT_Get_Kerning(face, left, right, mode, &delta)) { return (int)(delta.x) / (hinting_factor << kerning_factor); @@ -424,6 +475,9 @@ int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode) void FT2Font::set_kerning_factor(int factor) { kerning_factor = factor; + for (size_t i = 0; i < fallbacks.size(); i++) { + fallbacks[i]->set_kerning_factor(factor); + } } void FT2Font::set_text( @@ -433,47 +487,54 @@ void FT2Font::set_text( angle = angle / 360.0 * 2 * M_PI; - // this computes width and height in subpixels so we have to divide by 64 + // this computes width and height in subpixels so we have to multiply by 64 matrix.xx = (FT_Fixed)(cos(angle) * 0x10000L); matrix.xy = (FT_Fixed)(-sin(angle) * 0x10000L); matrix.yx = (FT_Fixed)(sin(angle) * 0x10000L); matrix.yy = (FT_Fixed)(cos(angle) * 0x10000L); - FT_Bool use_kerning = FT_HAS_KERNING(face); - FT_UInt previous = 0; - clear(); bbox.xMin = bbox.yMin = 32000; bbox.xMax = bbox.yMax = -32000; - for (unsigned int n = 0; n < N; n++) { - FT_UInt glyph_index; + FT_UInt previous = 0; + FT2Font *previous_ft_object = NULL; + + for (size_t n = 0; n < N; n++) { + FT_UInt glyph_index = 0; FT_BBox glyph_bbox; FT_Pos last_advance; - glyph_index = ft_get_char_index_or_warn(face, codepoints[n]); + FT_Error charcode_error, glyph_error; + FT2Font *ft_object_with_glyph = this; + bool was_found = load_char_with_fallback(ft_object_with_glyph, glyph_index, glyphs, + char_to_font, glyph_to_font, codepoints[n], flags, + charcode_error, glyph_error, false); + if (!was_found) { + ft_glyph_warn((FT_ULong)codepoints[n]); + + // render missing glyph tofu + // come back to top-most font + ft_object_with_glyph = this; + char_to_font[codepoints[n]] = ft_object_with_glyph; + glyph_to_font[glyph_index] = ft_object_with_glyph; + ft_object_with_glyph->load_glyph(glyph_index, flags, ft_object_with_glyph, false); + } // retrieve kerning distance and move pen position - if (use_kerning && previous && glyph_index) { + if ((ft_object_with_glyph == previous_ft_object) && // if both fonts are the same + ft_object_with_glyph->has_kerning() && // if the font knows how to kern + previous && glyph_index // and we really have 2 glyphs + ) { FT_Vector delta; - FT_Get_Kerning(face, previous, glyph_index, FT_KERNING_DEFAULT, &delta); - pen.x += delta.x / (hinting_factor << kerning_factor); + pen.x += ft_object_with_glyph->get_kerning(previous, glyph_index, FT_KERNING_DEFAULT, delta); } - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load glyph", error); - } - // ignore errors, jump to next glyph // extract glyph image and store it in our table + FT_Glyph &thisGlyph = glyphs[glyphs.size() - 1]; - FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } - // ignore errors, jump to next glyph - - last_advance = face->glyph->advance.x; + last_advance = ft_object_with_glyph->get_face()->glyph->advance.x; FT_Glyph_Transform(thisGlyph, 0, &pen); FT_Glyph_Transform(thisGlyph, &matrix, 0); xys.push_back(pen.x); @@ -489,7 +550,8 @@ void FT2Font::set_text( pen.x += last_advance; previous = glyph_index; - glyphs.push_back(thisGlyph); + previous_ft_object = ft_object_with_glyph; + } FT_Vector_Transform(&pen, &matrix); @@ -500,17 +562,135 @@ void FT2Font::set_text( } } -void FT2Font::load_char(long charcode, FT_Int32 flags) +void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool fallback = false) +{ + // if this is parent FT2Font, cache will be filled in 2 ways: + // 1. set_text was previously called + // 2. set_text was not called and fallback was enabled + if (fallback && char_to_font.find(charcode) != char_to_font.end()) { + ft_object = char_to_font[charcode]; + // since it will be assigned to ft_object anyway + FT2Font *throwaway = NULL; + ft_object->load_char(charcode, flags, throwaway, false); + } else if (fallback) { + FT_UInt final_glyph_index; + FT_Error charcode_error, glyph_error; + FT2Font *ft_object_with_glyph = this; + bool was_found = load_char_with_fallback(ft_object_with_glyph, final_glyph_index, glyphs, char_to_font, + glyph_to_font, charcode, flags, charcode_error, glyph_error, true); + if (!was_found) { + ft_glyph_warn(charcode); + if (charcode_error) { + throw_ft_error("Could not load charcode", charcode_error); + } + else if (glyph_error) { + throw_ft_error("Could not load charcode", glyph_error); + } + } + ft_object = ft_object_with_glyph; + } else { + ft_object = this; + FT_UInt glyph_index = ft_get_char_index_or_warn(face, (FT_ULong)charcode); + + if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { + throw_ft_error("Could not load charcode", error); + } + FT_Glyph thisGlyph; + if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { + throw_ft_error("Could not get glyph", error); + } + glyphs.push_back(thisGlyph); + } +} + + +bool FT2Font::get_char_fallback_index(FT_ULong charcode, int& index) const { - FT_UInt glyph_index = ft_get_char_index_or_warn(face, (FT_ULong)charcode); - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load charcode", error); + FT_UInt glyph_index = FT_Get_Char_Index(face, charcode); + if (glyph_index) { + // -1 means the host has the char and we do not need to fallback + index = -1; + return true; + } else { + int inner_index = 0; + bool was_found; + + for (size_t i = 0; i < fallbacks.size(); ++i) { + // TODO handle recursion somehow! + was_found = fallbacks[i]->get_char_fallback_index(charcode, inner_index); + if (was_found) { + index = i; + return true; + } + } } - FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); + return false; +} + + +bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph, + FT_UInt &final_glyph_index, + std::vector &parent_glyphs, + std::unordered_map &parent_char_to_font, + std::unordered_map &parent_glyph_to_font, + long charcode, + FT_Int32 flags, + FT_Error &charcode_error, + FT_Error &glyph_error, + bool override = false) +{ + FT_UInt glyph_index = FT_Get_Char_Index(face, charcode); + + if (glyph_index || override) { + charcode_error = FT_Load_Glyph(face, glyph_index, flags); + if (charcode_error) { + return false; + } + + FT_Glyph thisGlyph; + glyph_error = FT_Get_Glyph(face->glyph, &thisGlyph); + if (glyph_error) { + return false; + } + + final_glyph_index = glyph_index; + + // cache the result for future + // need to store this for anytime a character is loaded from a parent + // FT2Font object or to generate a mapping of individual characters to fonts + ft_object_with_glyph = this; + parent_glyph_to_font[final_glyph_index] = this; + parent_char_to_font[charcode] = this; + parent_glyphs.push_back(thisGlyph); + return true; + } + + else { + for (size_t i = 0; i < fallbacks.size(); ++i) { + bool was_found = fallbacks[i]->load_char_with_fallback( + ft_object_with_glyph, final_glyph_index, parent_glyphs, parent_char_to_font, + parent_glyph_to_font, charcode, flags, charcode_error, glyph_error, override); + if (was_found) { + return true; + } + } + return false; } - glyphs.push_back(thisGlyph); +} + +void FT2Font::load_glyph(FT_UInt glyph_index, + FT_Int32 flags, + FT2Font *&ft_object, + bool fallback = false) +{ + // cache is only for parent FT2Font + if (fallback && glyph_to_font.find(glyph_index) != glyph_to_font.end()) { + ft_object = glyph_to_font[glyph_index]; + } else { + ft_object = this; + } + + ft_object->load_glyph(glyph_index, flags); } void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags) @@ -525,6 +705,28 @@ void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags) glyphs.push_back(thisGlyph); } +FT_UInt FT2Font::get_char_index(FT_ULong charcode, bool fallback = false) +{ + FT2Font *ft_object = NULL; + if (fallback && char_to_font.find(charcode) != char_to_font.end()) { + // fallback denotes whether we want to search fallback list. + // should call set_text/load_char_with_fallback to parent FT2Font before + // wanting to use fallback list here. (since that populates the cache) + ft_object = char_to_font[charcode]; + } else { + // set as self + ft_object = this; + } + + // historically, get_char_index never raises a warning + return ft_get_char_index_or_warn(ft_object->get_face(), charcode, false); +} + +void FT2Font::get_cbox(FT_BBox &bbox) +{ + FT_Glyph_Get_CBox(glyphs.back(), ft_glyph_bbox_subpixels, &bbox); +} + void FT2Font::get_width_height(long *width, long *height) { *width = advance; @@ -544,8 +746,8 @@ void FT2Font::get_bitmap_offset(long *x, long *y) void FT2Font::draw_glyphs_to_bitmap(bool antialiased) { - size_t width = (bbox.xMax - bbox.xMin) / 64 + 2; - size_t height = (bbox.yMax - bbox.yMin) / 64 + 2; + long width = (bbox.xMax - bbox.xMin) / 64 + 2; + long height = (bbox.yMax - bbox.yMin) / 64 + 2; image.resize(width, height); @@ -615,8 +817,14 @@ void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, im.draw_bitmap(&bitmap->bitmap, x + bitmap->left, y); } -void FT2Font::get_glyph_name(unsigned int glyph_number, char *buffer) +void FT2Font::get_glyph_name(unsigned int glyph_number, char *buffer, bool fallback = false) { + if (fallback && glyph_to_font.find(glyph_number) != glyph_to_font.end()) { + // cache is only for parent FT2Font + FT2Font *ft_object = glyph_to_font[glyph_number]; + ft_object->get_glyph_name(glyph_number, buffer, false); + return; + } if (!FT_HAS_GLYPH_NAMES(face)) { /* Note that this generated name must match the name that is generated by ttconv in ttfont_CharStrings_getname. */ diff --git a/src/ft2font.h b/src/ft2font.h index 0863f3450b36..dc157f0e2887 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -1,10 +1,12 @@ /* -*- mode: c++; c-basic-offset: 4 -*- */ /* A python interface to FreeType */ +#pragma once #ifndef MPL_FT2FONT_H #define MPL_FT2FONT_H #include #include +#include extern "C" { #include @@ -69,7 +71,7 @@ class FT2Font { public: - FT2Font(FT_Open_Args &open_args, long hinting_factor); + FT2Font(FT_Open_Args &open_args, long hinting_factor, std::vector &fallback_list); virtual ~FT2Font(); void clear(); void set_size(double ptsize, double dpi); @@ -77,9 +79,21 @@ class FT2Font void select_charmap(unsigned long i); void set_text( size_t N, uint32_t *codepoints, double angle, FT_Int32 flags, std::vector &xys); - int get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode); + int get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode, bool fallback); + int get_kerning(FT_UInt left, FT_UInt right, FT_UInt mode, FT_Vector &delta); void set_kerning_factor(int factor); - void load_char(long charcode, FT_Int32 flags); + void load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool fallback); + bool load_char_with_fallback(FT2Font *&ft_object_with_glyph, + FT_UInt &final_glyph_index, + std::vector &parent_glyphs, + std::unordered_map &parent_char_to_font, + std::unordered_map &parent_glyph_to_font, + long charcode, + FT_Int32 flags, + FT_Error &charcode_error, + FT_Error &glyph_error, + bool override); + void load_glyph(FT_UInt glyph_index, FT_Int32 flags, FT2Font *&ft_object, bool fallback); void load_glyph(FT_UInt glyph_index, FT_Int32 flags); void get_width_height(long *width, long *height); void get_bitmap_offset(long *x, long *y); @@ -89,40 +103,51 @@ class FT2Font void get_xys(bool antialiased, std::vector &xys); void draw_glyphs_to_bitmap(bool antialiased); void draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, bool antialiased); - void get_glyph_name(unsigned int glyph_number, char *buffer); + void get_glyph_name(unsigned int glyph_number, char *buffer, bool fallback); long get_name_index(char *name); + FT_UInt get_char_index(FT_ULong charcode, bool fallback); + void get_cbox(FT_BBox &bbox); PyObject* get_path(); + bool get_char_fallback_index(FT_ULong charcode, int& index) const; - FT_Face &get_face() + FT_Face const &get_face() const { return face; } + FT2Image &get_image() { return image; } - FT_Glyph &get_last_glyph() + FT_Glyph const &get_last_glyph() const { return glyphs.back(); } - size_t get_last_glyph_index() + size_t get_last_glyph_index() const { return glyphs.size() - 1; } - size_t get_num_glyphs() + size_t get_num_glyphs() const { return glyphs.size(); } - long get_hinting_factor() + long get_hinting_factor() const { return hinting_factor; } + FT_Bool has_kerning() const + { + return FT_HAS_KERNING(face); + } private: FT2Image image; FT_Face face; FT_Vector pen; /* untransformed origin */ std::vector glyphs; + std::vector fallbacks; + std::unordered_map glyph_to_font; + std::unordered_map char_to_font; FT_BBox bbox; FT_Pos advance; long hinting_factor; diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index a219961b21a9..8b415bf3efdb 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -7,6 +7,9 @@ // From Python #include +#include +#include + #define STRINGIFY(s) XSTRINGIFY(s) #define XSTRINGIFY(s) #s @@ -33,6 +36,8 @@ typedef struct Py_ssize_t suboffsets[2]; } PyFT2Image; +static PyTypeObject PyFT2ImageType; + static PyObject *PyFT2Image_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { PyFT2Image *self; @@ -62,7 +67,7 @@ static void PyFT2Image_dealloc(PyFT2Image *self) } const char *PyFT2Image_draw_rect__doc__ = - "draw_rect(x0, y0, x1, y1)\n" + "draw_rect(self, x0, y0, x1, y1)\n" "--\n\n" "Draw an empty rectangle to the image.\n"; @@ -80,7 +85,7 @@ static PyObject *PyFT2Image_draw_rect(PyFT2Image *self, PyObject *args) } const char *PyFT2Image_draw_rect_filled__doc__ = - "draw_rect_filled(x0, y0, x1, y1)\n" + "draw_rect_filled(self, x0, y0, x1, y1)\n" "--\n\n" "Draw a filled rectangle to the image.\n"; @@ -121,9 +126,7 @@ static int PyFT2Image_get_buffer(PyFT2Image *self, Py_buffer *buf, int flags) return 1; } -static PyTypeObject PyFT2ImageType; - -static PyTypeObject *PyFT2Image_init_type(PyObject *m, PyTypeObject *type) +static PyTypeObject* PyFT2Image_init_type() { static PyMethodDef methods[] = { {"draw_rect", (PyCFunction)PyFT2Image_draw_rect, METH_VARARGS, PyFT2Image_draw_rect__doc__}, @@ -132,28 +135,18 @@ static PyTypeObject *PyFT2Image_init_type(PyObject *m, PyTypeObject *type) }; static PyBufferProcs buffer_procs; - memset(&buffer_procs, 0, sizeof(PyBufferProcs)); buffer_procs.bf_getbuffer = (getbufferproc)PyFT2Image_get_buffer; - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib.ft2font.FT2Image"; - type->tp_basicsize = sizeof(PyFT2Image); - type->tp_dealloc = (destructor)PyFT2Image_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; - type->tp_methods = methods; - type->tp_new = PyFT2Image_new; - type->tp_init = (initproc)PyFT2Image_init; - type->tp_as_buffer = &buffer_procs; - - if (PyType_Ready(type) < 0) { - return NULL; - } + PyFT2ImageType.tp_name = "matplotlib.ft2font.FT2Image"; + PyFT2ImageType.tp_basicsize = sizeof(PyFT2Image); + PyFT2ImageType.tp_dealloc = (destructor)PyFT2Image_dealloc; + PyFT2ImageType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; + PyFT2ImageType.tp_methods = methods; + PyFT2ImageType.tp_new = PyFT2Image_new; + PyFT2ImageType.tp_init = (initproc)PyFT2Image_init; + PyFT2ImageType.tp_as_buffer = &buffer_procs; - if (PyModule_AddObject(m, "FT2Image", (PyObject *)type)) { - return NULL; - } - - return type; + return &PyFT2ImageType; } /********************************************************************** @@ -178,14 +171,16 @@ typedef struct static PyTypeObject PyGlyphType; -static PyObject * -PyGlyph_new(const FT_Face &face, const FT_Glyph &glyph, size_t ind, long hinting_factor) +static PyObject *PyGlyph_from_FT2Font(const FT2Font *font) { + const FT_Face &face = font->get_face(); + const long hinting_factor = font->get_hinting_factor(); + const FT_Glyph &glyph = font->get_last_glyph(); + PyGlyph *self; self = (PyGlyph *)PyGlyphType.tp_alloc(&PyGlyphType, 0); - self->glyphInd = ind; - + self->glyphInd = font->get_last_glyph_index(); FT_Glyph_Get_CBox(glyph, ft_glyph_bbox_subpixels, &self->bbox); self->width = face->glyph->metrics.width / hinting_factor; @@ -212,7 +207,7 @@ static PyObject *PyGlyph_get_bbox(PyGlyph *self, void *closure) "llll", self->bbox.xMin, self->bbox.yMin, self->bbox.xMax, self->bbox.yMax); } -static PyTypeObject *PyGlyph_init_type(PyObject *m, PyTypeObject *type) +static PyTypeObject *PyGlyph_init_type() { static PyMemberDef members[] = { {(char *)"width", T_LONG, offsetof(PyGlyph, width), READONLY, (char *)""}, @@ -232,39 +227,33 @@ static PyTypeObject *PyGlyph_init_type(PyObject *m, PyTypeObject *type) {NULL} }; - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib.ft2font.Glyph"; - type->tp_basicsize = sizeof(PyGlyph); - type->tp_dealloc = (destructor)PyGlyph_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; - type->tp_members = members; - type->tp_getset = getset; - - if (PyType_Ready(type) < 0) { - return NULL; - } + PyGlyphType.tp_name = "matplotlib.ft2font.Glyph"; + PyGlyphType.tp_basicsize = sizeof(PyGlyph); + PyGlyphType.tp_dealloc = (destructor)PyGlyph_dealloc; + PyGlyphType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; + PyGlyphType.tp_members = members; + PyGlyphType.tp_getset = getset; - /* Don't need to add to module, since you can't create glyphs - directly from Python */ - - return type; + return &PyGlyphType; } /********************************************************************** * FT2Font * */ -typedef struct +struct PyFT2Font { PyObject_HEAD FT2Font *x; - PyObject *fname; PyObject *py_file; FT_StreamRec stream; Py_ssize_t shape[2]; Py_ssize_t strides[2]; Py_ssize_t suboffsets[2]; -} PyFT2Font; + std::vector fallbacks; +}; + +static PyTypeObject PyFT2FontType; static unsigned long read_from_file_callback(FT_Stream stream, unsigned long offset, @@ -292,11 +281,13 @@ static unsigned long read_from_file_callback(FT_Stream stream, return 1; // Non-zero signals error, when count == 0. } } - return n_read; + return (unsigned long)n_read; } static void close_file_callback(FT_Stream stream) { + PyObject *type, *value, *traceback; + PyErr_Fetch(&type, &value, &traceback); PyFT2Font *self = (PyFT2Font *)stream->descriptor.pointer; PyObject *close_result = NULL; if (!(close_result = PyObject_CallMethod(self->py_file, "close", ""))) { @@ -308,26 +299,38 @@ static void close_file_callback(FT_Stream stream) if (PyErr_Occurred()) { PyErr_WriteUnraisable((PyObject*)self); } + PyErr_Restore(type, value, traceback); } -static PyTypeObject PyFT2FontType; - static PyObject *PyFT2Font_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { PyFT2Font *self; self = (PyFT2Font *)type->tp_alloc(type, 0); self->x = NULL; - self->fname = NULL; self->py_file = NULL; memset(&self->stream, 0, sizeof(FT_StreamRec)); return (PyObject *)self; } const char *PyFT2Font_init__doc__ = - "FT2Font(ttffile)\n" + "FT2Font(filename, hinting_factor=8, *, _fallback_list=None, _kerning_factor=0)\n" "--\n\n" "Create a new FT2Font object.\n" "\n" + "Parameters\n" + "----------\n" + "filename : str or file-like\n" + " The source of the font data in a format (ttf or ttc) that FreeType can read\n\n" + "hinting_factor : int, optional\n" + " Must be positive. Used to scale the hinting in the x-direction\n" + "_fallback_list : list of FT2Font, optional\n" + " A list of FT2Font objects used to find missing glyphs.\n\n" + " .. warning::\n" + " This API is both private and provisional: do not use it directly\n\n" + "_kerning_factor : int, optional\n" + " Used to adjust the degree of kerning.\n\n" + " .. warning::\n" + " This API is private: do not use it directly\n\n" "Attributes\n" "----------\n" "num_faces\n" @@ -361,17 +364,24 @@ const char *PyFT2Font_init__doc__ = static int PyFT2Font_init(PyFT2Font *self, PyObject *args, PyObject *kwds) { - PyObject *filename = NULL, *open = NULL, *data = NULL; + PyObject *filename = NULL, *open = NULL, *data = NULL, *fallback_list = NULL; FT_Open_Args open_args; long hinting_factor = 8; int kerning_factor = 0; - const char *names[] = { "filename", "hinting_factor", "_kerning_factor", NULL }; - + const char *names[] = { + "filename", "hinting_factor", "_fallback_list", "_kerning_factor", NULL + }; + std::vector fallback_fonts; if (!PyArg_ParseTupleAndKeywords( - args, kwds, "O|l$i:FT2Font", (char **)names, &filename, - &hinting_factor, &kerning_factor)) { + args, kwds, "O|l$Oi:FT2Font", (char **)names, &filename, + &hinting_factor, &fallback_list, &kerning_factor)) { return -1; } + if (hinting_factor <= 0) { + PyErr_SetString(PyExc_ValueError, + "hinting_factor must be greater than 0"); + goto exit; + } self->stream.base = NULL; self->stream.size = 0x7fffffff; // Unknown size. @@ -382,6 +392,37 @@ static int PyFT2Font_init(PyFT2Font *self, PyObject *args, PyObject *kwds) open_args.flags = FT_OPEN_STREAM; open_args.stream = &self->stream; + if (fallback_list) { + if (!PyList_Check(fallback_list)) { + PyErr_SetString(PyExc_TypeError, "Fallback list must be a list"); + goto exit; + } + Py_ssize_t size = PyList_Size(fallback_list); + + // go through fallbacks once to make sure the types are right + for (Py_ssize_t i = 0; i < size; ++i) { + // this returns a borrowed reference + PyObject* item = PyList_GetItem(fallback_list, i); + if (!PyObject_IsInstance(item, PyObject_Type(reinterpret_cast(self)))) { + PyErr_SetString(PyExc_TypeError, "Fallback fonts must be FT2Font objects."); + goto exit; + } + } + // go through a second time to add them to our lists + for (Py_ssize_t i = 0; i < size; ++i) { + // this returns a borrowed reference + PyObject* item = PyList_GetItem(fallback_list, i); + // Increase the ref count, we will undo this in dealloc this makes + // sure things do not get gc'd under us! + Py_INCREF(item); + self->fallbacks.push_back(item); + // Also (locally) cache the underlying FT2Font objects. As long as + // the Python objects are kept alive, these pointer are good. + FT2Font *fback = reinterpret_cast(item)->x; + fallback_fonts.push_back(fback); + } + } + if (PyBytes_Check(filename) || PyUnicode_Check(filename)) { if (!(open = PyDict_GetItemString(PyEval_GetBuiltins(), "open")) // Borrowed reference. || !(self->py_file = PyObject_CallFunction(open, "Os", filename, "rb"))) { @@ -392,7 +433,7 @@ static int PyFT2Font_init(PyFT2Font *self, PyObject *args, PyObject *kwds) || !(data = PyObject_CallMethod(filename, "read", "i", 0)) || !PyBytes_Check(data)) { PyErr_SetString(PyExc_TypeError, - "First argument must be a path or binary-mode file object"); + "First argument must be a path to a font file or a binary-mode file object"); Py_CLEAR(data); goto exit; } else { @@ -403,14 +444,11 @@ static int PyFT2Font_init(PyFT2Font *self, PyObject *args, PyObject *kwds) Py_CLEAR(data); CALL_CPP_FULL( - "FT2Font", (self->x = new FT2Font(open_args, hinting_factor)), + "FT2Font", (self->x = new FT2Font(open_args, hinting_factor, fallback_fonts)), Py_CLEAR(self->py_file), -1); CALL_CPP_INIT("FT2Font->set_kerning_factor", (self->x->set_kerning_factor(kerning_factor))); - Py_INCREF(filename); - self->fname = filename; - exit: return PyErr_Occurred() ? -1 : 0; } @@ -418,13 +456,16 @@ static int PyFT2Font_init(PyFT2Font *self, PyObject *args, PyObject *kwds) static void PyFT2Font_dealloc(PyFT2Font *self) { delete self->x; + for (size_t i = 0; i < self->fallbacks.size(); i++) { + Py_DECREF(self->fallbacks[i]); + } + Py_XDECREF(self->py_file); - Py_XDECREF(self->fname); Py_TYPE(self)->tp_free((PyObject *)self); } const char *PyFT2Font_clear__doc__ = - "clear()\n" + "clear(self)\n" "--\n\n" "Clear all the glyphs, reset for a new call to `.set_text`.\n"; @@ -436,7 +477,7 @@ static PyObject *PyFT2Font_clear(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_set_size__doc__ = - "set_size(ptsize, dpi)\n" + "set_size(self, ptsize, dpi)\n" "--\n\n" "Set the point size and dpi of the text.\n"; @@ -455,7 +496,7 @@ static PyObject *PyFT2Font_set_size(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_set_charmap__doc__ = - "set_charmap(i)\n" + "set_charmap(self, i)\n" "--\n\n" "Make the i-th charmap current.\n"; @@ -473,7 +514,7 @@ static PyObject *PyFT2Font_set_charmap(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_select_charmap__doc__ = - "select_charmap(i)\n" + "select_charmap(self, i)\n" "--\n\n" "Select a charmap by its FT_Encoding number.\n"; @@ -491,30 +532,101 @@ static PyObject *PyFT2Font_select_charmap(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_kerning__doc__ = - "get_kerning(left, right, mode)\n" + "get_kerning(self, left, right, mode)\n" "--\n\n" "Get the kerning between *left* and *right* glyph indices.\n" - "*mode* is a kerning mode constant:\n" - " KERNING_DEFAULT - Return scaled and grid-fitted kerning distances\n" - " KERNING_UNFITTED - Return scaled but un-grid-fitted kerning distances\n" - " KERNING_UNSCALED - Return the kerning vector in original font units\n"; + "*mode* is a kerning mode constant:\n\n" + " - KERNING_DEFAULT - Return scaled and grid-fitted kerning distances\n" + " - KERNING_UNFITTED - Return scaled but un-grid-fitted kerning distances\n" + " - KERNING_UNSCALED - Return the kerning vector in original font units\n"; static PyObject *PyFT2Font_get_kerning(PyFT2Font *self, PyObject *args) { FT_UInt left, right, mode; int result; + int fallback = 1; if (!PyArg_ParseTuple(args, "III:get_kerning", &left, &right, &mode)) { return NULL; } - CALL_CPP("get_kerning", (result = self->x->get_kerning(left, right, mode))); + CALL_CPP("get_kerning", (result = self->x->get_kerning(left, right, mode, (bool)fallback))); return PyLong_FromLong(result); } +const char *PyFT2Font_get_fontmap__doc__ = + "_get_fontmap(self, string)\n" + "--\n\n" + "Get a mapping between characters and the font that includes them.\n" + "A dictionary mapping unicode characters to PyFT2Font objects."; +static PyObject *PyFT2Font_get_fontmap(PyFT2Font *self, PyObject *args, PyObject *kwds) +{ + PyObject *textobj; + const char *names[] = { "string", NULL }; + + if (!PyArg_ParseTupleAndKeywords( + args, kwds, "O:_get_fontmap", (char **)names, &textobj)) { + return NULL; + } + + std::set codepoints; + size_t size; + + if (PyUnicode_Check(textobj)) { + size = PyUnicode_GET_LENGTH(textobj); +#if defined(PYPY_VERSION) && (PYPY_VERSION_NUM < 0x07040000) + // PyUnicode_ReadChar is available from PyPy 7.3.2, but wheels do not + // specify the micro-release version, so put the version bound at 7.4 + // to prevent generating wheels unusable on PyPy 7.3.{0,1}. + Py_UNICODE *unistr = PyUnicode_AsUnicode(textobj); + for (size_t i = 0; i < size; ++i) { + codepoints.insert(unistr[i]); + } +#else + for (size_t i = 0; i < size; ++i) { + codepoints.insert(PyUnicode_ReadChar(textobj, i)); + } +#endif + } else { + PyErr_SetString(PyExc_TypeError, "string must be str"); + return NULL; + } + PyObject *char_to_font; + if (!(char_to_font = PyDict_New())) { + return NULL; + } + for (auto it = codepoints.begin(); it != codepoints.end(); ++it) { + auto x = *it; + PyObject* target_font; + int index; + if (self->x->get_char_fallback_index(x, index)) { + if (index >= 0) { + target_font = self->fallbacks[index]; + } else { + target_font = (PyObject *)self; + } + } else { + // TODO Handle recursion! + target_font = (PyObject *)self; + } + + PyObject *key = NULL; + bool error = (!(key = PyUnicode_FromFormat("%c", x)) + || (PyDict_SetItem(char_to_font, key, target_font) == -1)); + Py_XDECREF(key); + if (error) { + Py_DECREF(char_to_font); + PyErr_SetString(PyExc_ValueError, "Something went very wrong"); + return NULL; + } + } + return char_to_font; +} + + const char *PyFT2Font_set_text__doc__ = - "set_text(string, angle, flags=32)\n" + "set_text(self, string, angle, flags=32)\n" "--\n\n" "Set the text *string* and *angle*.\n" "*flags* can be a bitwise-or of the LOAD_XXX constants;\n" @@ -557,22 +669,8 @@ static PyObject *PyFT2Font_set_text(PyFT2Font *self, PyObject *args, PyObject *k codepoints[i] = PyUnicode_ReadChar(textobj, i); } #endif - } else if (PyBytes_Check(textobj)) { - if (PyErr_WarnEx( - PyExc_FutureWarning, - "Passing bytes to FTFont.set_text is deprecated since Matplotlib " - "3.4 and support will be removed in Matplotlib 3.6; pass str instead", - 1)) { - return NULL; - } - size = PyBytes_Size(textobj); - codepoints.resize(size); - char *bytestr = PyBytes_AsString(textobj); - for (size_t i = 0; i < size; ++i) { - codepoints[i] = bytestr[i]; - } } else { - PyErr_SetString(PyExc_TypeError, "String must be str or bytes"); + PyErr_SetString(PyExc_TypeError, "set_text requires str-input."); return NULL; } @@ -586,95 +684,93 @@ static PyObject *PyFT2Font_set_text(PyFT2Font *self, PyObject *args, PyObject *k } const char *PyFT2Font_get_num_glyphs__doc__ = - "get_num_glyphs()\n" + "get_num_glyphs(self)\n" "--\n\n" "Return the number of loaded glyphs.\n"; static PyObject *PyFT2Font_get_num_glyphs(PyFT2Font *self, PyObject *args) { - return PyLong_FromLong(self->x->get_num_glyphs()); + return PyLong_FromSize_t(self->x->get_num_glyphs()); } const char *PyFT2Font_load_char__doc__ = - "load_char(charcode, flags=32)\n" + "load_char(self, charcode, flags=32)\n" "--\n\n" "Load character with *charcode* in current fontfile and set glyph.\n" "*flags* can be a bitwise-or of the LOAD_XXX constants;\n" "the default value is LOAD_FORCE_AUTOHINT.\n" - "Return value is a Glyph object, with attributes\n" - " width # glyph width\n" - " height # glyph height\n" - " bbox # the glyph bbox (xmin, ymin, xmax, ymax)\n" - " horiBearingX # left side bearing in horizontal layouts\n" - " horiBearingY # top side bearing in horizontal layouts\n" - " horiAdvance # advance width for horizontal layout\n" - " vertBearingX # left side bearing in vertical layouts\n" - " vertBearingY # top side bearing in vertical layouts\n" - " vertAdvance # advance height for vertical layout\n"; + "Return value is a Glyph object, with attributes\n\n" + "- width: glyph width\n" + "- height: glyph height\n" + "- bbox: the glyph bbox (xmin, ymin, xmax, ymax)\n" + "- horiBearingX: left side bearing in horizontal layouts\n" + "- horiBearingY: top side bearing in horizontal layouts\n" + "- horiAdvance: advance width for horizontal layout\n" + "- vertBearingX: left side bearing in vertical layouts\n" + "- vertBearingY: top side bearing in vertical layouts\n" + "- vertAdvance: advance height for vertical layout\n"; static PyObject *PyFT2Font_load_char(PyFT2Font *self, PyObject *args, PyObject *kwds) { long charcode; + int fallback = 1; FT_Int32 flags = FT_LOAD_FORCE_AUTOHINT; const char *names[] = { "charcode", "flags", NULL }; /* This makes a technically incorrect assumption that FT_Int32 is int. In theory it can also be long, if the size of int is less than 32 bits. This is very unlikely on modern platforms. */ - if (!PyArg_ParseTupleAndKeywords( - args, kwds, "l|i:load_char", (char **)names, &charcode, &flags)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "l|i:load_char", (char **)names, &charcode, + &flags)) { return NULL; } - CALL_CPP("load_char", (self->x->load_char(charcode, flags))); + FT2Font *ft_object = NULL; + CALL_CPP("load_char", (self->x->load_char(charcode, flags, ft_object, (bool)fallback))); - return PyGlyph_new(self->x->get_face(), - self->x->get_last_glyph(), - self->x->get_last_glyph_index(), - self->x->get_hinting_factor()); + return PyGlyph_from_FT2Font(ft_object); } const char *PyFT2Font_load_glyph__doc__ = - "load_glyph(glyphindex, flags=32)\n" + "load_glyph(self, glyphindex, flags=32)\n" "--\n\n" "Load character with *glyphindex* in current fontfile and set glyph.\n" "*flags* can be a bitwise-or of the LOAD_XXX constants;\n" "the default value is LOAD_FORCE_AUTOHINT.\n" - "Return value is a Glyph object, with attributes\n" - " width # glyph width\n" - " height # glyph height\n" - " bbox # the glyph bbox (xmin, ymin, xmax, ymax)\n" - " horiBearingX # left side bearing in horizontal layouts\n" - " horiBearingY # top side bearing in horizontal layouts\n" - " horiAdvance # advance width for horizontal layout\n" - " vertBearingX # left side bearing in vertical layouts\n" - " vertBearingY # top side bearing in vertical layouts\n" - " vertAdvance # advance height for vertical layout\n"; + "Return value is a Glyph object, with attributes\n\n" + "- width: glyph width\n" + "- height: glyph height\n" + "- bbox: the glyph bbox (xmin, ymin, xmax, ymax)\n" + "- horiBearingX: left side bearing in horizontal layouts\n" + "- horiBearingY: top side bearing in horizontal layouts\n" + "- horiAdvance: advance width for horizontal layout\n" + "- vertBearingX: left side bearing in vertical layouts\n" + "- vertBearingY: top side bearing in vertical layouts\n" + "- vertAdvance: advance height for vertical layout\n"; static PyObject *PyFT2Font_load_glyph(PyFT2Font *self, PyObject *args, PyObject *kwds) { FT_UInt glyph_index; FT_Int32 flags = FT_LOAD_FORCE_AUTOHINT; + int fallback = 1; const char *names[] = { "glyph_index", "flags", NULL }; /* This makes a technically incorrect assumption that FT_Int32 is int. In theory it can also be long, if the size of int is less than 32 bits. This is very unlikely on modern platforms. */ - if (!PyArg_ParseTupleAndKeywords( - args, kwds, "I|i:load_glyph", (char **)names, &glyph_index, &flags)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "I|i:load_glyph", (char **)names, &glyph_index, + &flags)) { return NULL; } - CALL_CPP("load_glyph", (self->x->load_glyph(glyph_index, flags))); + FT2Font *ft_object = NULL; + CALL_CPP("load_glyph", (self->x->load_glyph(glyph_index, flags, ft_object, (bool)fallback))); - return PyGlyph_new(self->x->get_face(), - self->x->get_last_glyph(), - self->x->get_last_glyph_index(), - self->x->get_hinting_factor()); + return PyGlyph_from_FT2Font(ft_object); } const char *PyFT2Font_get_width_height__doc__ = - "get_width_height()\n" + "get_width_height(self)\n" "--\n\n" "Get the width and height in 26.6 subpixels of the current string set by `.set_text`.\n" "The rotation of the string is accounted for. To get width and height\n" @@ -690,7 +786,7 @@ static PyObject *PyFT2Font_get_width_height(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_bitmap_offset__doc__ = - "get_bitmap_offset()\n" + "get_bitmap_offset(self)\n" "--\n\n" "Get the (x, y) offset in 26.6 subpixels for the bitmap if ink hangs left or below (0, 0).\n" "Since Matplotlib only supports left-to-right text, y is always 0.\n"; @@ -705,7 +801,7 @@ static PyObject *PyFT2Font_get_bitmap_offset(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_descent__doc__ = - "get_descent()\n" + "get_descent(self)\n" "--\n\n" "Get the descent in 26.6 subpixels of the current string set by `.set_text`.\n" "The rotation of the string is accounted for. To get the descent\n" @@ -721,7 +817,7 @@ static PyObject *PyFT2Font_get_descent(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_draw_glyphs_to_bitmap__doc__ = - "draw_glyphs_to_bitmap()\n" + "draw_glyphs_to_bitmap(self, antialiased=True)\n" "--\n\n" "Draw the glyphs that were loaded by `.set_text` to the bitmap.\n" "The bitmap size will be automatically set to include the glyphs.\n"; @@ -742,7 +838,7 @@ static PyObject *PyFT2Font_draw_glyphs_to_bitmap(PyFT2Font *self, PyObject *args } const char *PyFT2Font_get_xys__doc__ = - "get_xys()\n" + "get_xys(self, antialiased=True)\n" "--\n\n" "Get the xy locations of the current glyphs.\n"; @@ -763,7 +859,7 @@ static PyObject *PyFT2Font_get_xys(PyFT2Font *self, PyObject *args, PyObject *kw } const char *PyFT2Font_draw_glyph_to_bitmap__doc__ = - "draw_glyph_to_bitmap(bitmap, x, y, glyph)\n" + "draw_glyph_to_bitmap(self, image, x, y, glyph, antialiased=True)\n" "--\n\n" "Draw a single glyph to the bitmap at pixel locations x, y\n" "Note it is your responsibility to set up the bitmap manually\n" @@ -804,7 +900,7 @@ static PyObject *PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, PyObject *args, } const char *PyFT2Font_get_glyph_name__doc__ = - "get_glyph_name(index)\n" + "get_glyph_name(self, index)\n" "--\n\n" "Retrieve the ASCII name of a given glyph *index* in a face.\n" "\n" @@ -816,15 +912,17 @@ static PyObject *PyFT2Font_get_glyph_name(PyFT2Font *self, PyObject *args) { unsigned int glyph_number; char buffer[128]; + int fallback = 1; + if (!PyArg_ParseTuple(args, "I:get_glyph_name", &glyph_number)) { return NULL; } - CALL_CPP("get_glyph_name", (self->x->get_glyph_name(glyph_number, buffer))); + CALL_CPP("get_glyph_name", (self->x->get_glyph_name(glyph_number, buffer, (bool)fallback))); return PyUnicode_FromString(buffer); } const char *PyFT2Font_get_charmap__doc__ = - "get_charmap()\n" + "get_charmap(self)\n" "--\n\n" "Return a dict that maps the character codes of the selected charmap\n" "(Unicode by default) to their corresponding glyph indices.\n"; @@ -855,7 +953,7 @@ static PyObject *PyFT2Font_get_charmap(PyFT2Font *self, PyObject *args) const char *PyFT2Font_get_char_index__doc__ = - "get_char_index(codepoint)\n" + "get_char_index(self, codepoint)\n" "--\n\n" "Return the glyph index corresponding to a character *codepoint*.\n"; @@ -863,19 +961,20 @@ static PyObject *PyFT2Font_get_char_index(PyFT2Font *self, PyObject *args) { FT_UInt index; FT_ULong ccode; + int fallback = 1; if (!PyArg_ParseTuple(args, "k:get_char_index", &ccode)) { return NULL; } - index = FT_Get_Char_Index(self->x->get_face(), ccode); + CALL_CPP("get_char_index", index = self->x->get_char_index(ccode, (bool)fallback)); return PyLong_FromLong(index); } const char *PyFT2Font_get_sfnt__doc__ = - "get_sfnt()\n" + "get_sfnt(self)\n" "--\n\n" "Load the entire SFNT names table, as a dict whose keys are\n" "(platform-ID, ISO-encoding-scheme, language-code, and description)\n" @@ -936,7 +1035,7 @@ static PyObject *PyFT2Font_get_sfnt(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_name_index__doc__ = - "get_name_index(name)\n" + "get_name_index(self, name)\n" "--\n\n" "Return the glyph index of a given glyph *name*.\n" "The glyph index 0 means 'undefined character code'.\n"; @@ -953,7 +1052,7 @@ static PyObject *PyFT2Font_get_name_index(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_ps_font_info__doc__ = - "get_ps_font_info()\n" + "get_ps_font_info(self)\n" "--\n\n" "Return the information in the PS Font Info structure.\n"; @@ -980,7 +1079,7 @@ static PyObject *PyFT2Font_get_ps_font_info(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_sfnt_table__doc__ = - "get_sfnt_table(name)\n" + "get_sfnt_table(self, name)\n" "--\n\n" "Return one of the following SFNT tables: head, maxp, OS/2, hhea, " "vhea, post, or pclt.\n"; @@ -1282,7 +1381,7 @@ static PyObject *PyFT2Font_get_sfnt_table(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_path__doc__ = - "get_path()\n" + "get_path(self)\n" "--\n\n" "Get the path data from the currently loaded glyph as a tuple of vertices, " "codes.\n"; @@ -1293,7 +1392,7 @@ static PyObject *PyFT2Font_get_path(PyFT2Font *self, PyObject *args) } const char *PyFT2Font_get_image__doc__ = - "get_image()\n" + "get_image(self)\n" "--\n\n" "Return the underlying image buffer for this font object.\n"; @@ -1420,12 +1519,12 @@ static PyObject *PyFT2Font_underline_thickness(PyFT2Font *self, void *closure) static PyObject *PyFT2Font_fname(PyFT2Font *self, void *closure) { - if (self->fname) { - Py_INCREF(self->fname); - return self->fname; + if (self->stream.close) { // Called passed a filename to the constructor. + return PyObject_GetAttrString(self->py_file, "name"); + } else { + Py_INCREF(self->py_file); + return self->py_file; } - - Py_RETURN_NONE; } static int PyFT2Font_get_buffer(PyFT2Font *self, Py_buffer *buf, int flags) @@ -1452,7 +1551,7 @@ static int PyFT2Font_get_buffer(PyFT2Font *self, Py_buffer *buf, int flags) return 1; } -static PyTypeObject *PyFT2Font_init_type(PyObject *m, PyTypeObject *type) +static PyTypeObject *PyFT2Font_init_type() { static PyGetSetDef getset[] = { {(char *)"postscript_name", (getter)PyFT2Font_postscript_name, NULL, NULL, NULL}, @@ -1485,6 +1584,7 @@ static PyTypeObject *PyFT2Font_init_type(PyObject *m, PyTypeObject *type) {"select_charmap", (PyCFunction)PyFT2Font_select_charmap, METH_VARARGS, PyFT2Font_select_charmap__doc__}, {"get_kerning", (PyCFunction)PyFT2Font_get_kerning, METH_VARARGS, PyFT2Font_get_kerning__doc__}, {"set_text", (PyCFunction)PyFT2Font_set_text, METH_VARARGS|METH_KEYWORDS, PyFT2Font_set_text__doc__}, + {"_get_fontmap", (PyCFunction)PyFT2Font_get_fontmap, METH_VARARGS|METH_KEYWORDS, PyFT2Font_get_fontmap__doc__}, {"get_num_glyphs", (PyCFunction)PyFT2Font_get_num_glyphs, METH_NOARGS, PyFT2Font_get_num_glyphs__doc__}, {"load_char", (PyCFunction)PyFT2Font_load_char, METH_VARARGS|METH_KEYWORDS, PyFT2Font_load_char__doc__}, {"load_glyph", (PyCFunction)PyFT2Font_load_glyph, METH_VARARGS|METH_KEYWORDS, PyFT2Font_load_glyph__doc__}, @@ -1507,30 +1607,20 @@ static PyTypeObject *PyFT2Font_init_type(PyObject *m, PyTypeObject *type) }; static PyBufferProcs buffer_procs; - memset(&buffer_procs, 0, sizeof(PyBufferProcs)); buffer_procs.bf_getbuffer = (getbufferproc)PyFT2Font_get_buffer; - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib.ft2font.FT2Font"; - type->tp_doc = PyFT2Font_init__doc__; - type->tp_basicsize = sizeof(PyFT2Font); - type->tp_dealloc = (destructor)PyFT2Font_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; - type->tp_methods = methods; - type->tp_getset = getset; - type->tp_new = PyFT2Font_new; - type->tp_init = (initproc)PyFT2Font_init; - type->tp_as_buffer = &buffer_procs; - - if (PyType_Ready(type) < 0) { - return NULL; - } + PyFT2FontType.tp_name = "matplotlib.ft2font.FT2Font"; + PyFT2FontType.tp_doc = PyFT2Font_init__doc__; + PyFT2FontType.tp_basicsize = sizeof(PyFT2Font); + PyFT2FontType.tp_dealloc = (destructor)PyFT2Font_dealloc; + PyFT2FontType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE; + PyFT2FontType.tp_methods = methods; + PyFT2FontType.tp_getset = getset; + PyFT2FontType.tp_new = PyFT2Font_new; + PyFT2FontType.tp_init = (initproc)PyFT2Font_init; + PyFT2FontType.tp_as_buffer = &buffer_procs; - if (PyModule_AddObject(m, "FT2Font", (PyObject *)type)) { - return NULL; - } - - return type; + return &PyFT2FontType; } static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "ft2font" }; @@ -1539,32 +1629,26 @@ static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "ft2font" }; PyMODINIT_FUNC PyInit_ft2font(void) { - PyObject *m; - import_array(); - m = PyModule_Create(&moduledef); - - if (m == NULL) { - return NULL; - } - - if (!PyFT2Image_init_type(m, &PyFT2ImageType)) { - Py_DECREF(m); - return NULL; - } - - if (!PyGlyph_init_type(m, &PyGlyphType)) { - Py_DECREF(m); - return NULL; - } - - if (!PyFT2Font_init_type(m, &PyFT2FontType)) { - Py_DECREF(m); - return NULL; + if (FT_Init_FreeType(&_ft2Library)) { // initialize library + return PyErr_Format( + PyExc_RuntimeError, "Could not initialize the freetype2 library"); } + FT_Int major, minor, patch; + char version_string[64]; + FT_Library_Version(_ft2Library, &major, &minor, &patch); + snprintf(version_string, sizeof(version_string), "%d.%d.%d", major, minor, patch); - if (PyModule_AddIntConstant(m, "SCALABLE", FT_FACE_FLAG_SCALABLE) || + PyObject *m; + if (!(m = PyModule_Create(&moduledef)) || + prepare_and_add_type(PyFT2Image_init_type(), m) || + prepare_and_add_type(PyFT2Font_init_type(), m) || + // Glyph is not constructible from Python, thus not added to the module. + PyType_Ready(PyGlyph_init_type()) || + PyModule_AddStringConstant(m, "__freetype_version__", version_string) || + PyModule_AddStringConstant(m, "__freetype_build_type__", STRINGIFY(FREETYPE_BUILD_TYPE)) || + PyModule_AddIntConstant(m, "SCALABLE", FT_FACE_FLAG_SCALABLE) || PyModule_AddIntConstant(m, "FIXED_SIZES", FT_FACE_FLAG_FIXED_SIZES) || PyModule_AddIntConstant(m, "FIXED_WIDTH", FT_FACE_FLAG_FIXED_WIDTH) || PyModule_AddIntConstant(m, "SFNT", FT_FACE_FLAG_SFNT) || @@ -1600,33 +1684,8 @@ PyMODINIT_FUNC PyInit_ft2font(void) PyModule_AddIntConstant(m, "LOAD_TARGET_MONO", (unsigned long)FT_LOAD_TARGET_MONO) || PyModule_AddIntConstant(m, "LOAD_TARGET_LCD", (unsigned long)FT_LOAD_TARGET_LCD) || PyModule_AddIntConstant(m, "LOAD_TARGET_LCD_V", (unsigned long)FT_LOAD_TARGET_LCD_V)) { - Py_DECREF(m); - return NULL; - } - - // initialize library - int error = FT_Init_FreeType(&_ft2Library); - - if (error) { - PyErr_SetString(PyExc_RuntimeError, "Could not initialize the freetype2 library"); - Py_DECREF(m); - return NULL; - } - - { - FT_Int major, minor, patch; - char version_string[64]; - - FT_Library_Version(_ft2Library, &major, &minor, &patch); - sprintf(version_string, "%d.%d.%d", major, minor, patch); - if (PyModule_AddStringConstant(m, "__freetype_version__", version_string)) { - Py_DECREF(m); - return NULL; - } - } - - if (PyModule_AddStringConstant(m, "__freetype_build_type__", STRINGIFY(FREETYPE_BUILD_TYPE))) { - Py_DECREF(m); + FT_Done_FreeType(_ft2Library); + Py_XDECREF(m); return NULL; } diff --git a/src/mplutils.h b/src/mplutils.h index bcdcdf68e63e..39d98ed02e8f 100644 --- a/src/mplutils.h +++ b/src/mplutils.h @@ -46,4 +46,20 @@ enum { const size_t NUM_VERTICES[] = { 1, 1, 1, 2, 3, 1 }; +inline int prepare_and_add_type(PyTypeObject *type, PyObject *module) +{ + if (PyType_Ready(type)) { + return -1; + } + char const* ptr = strrchr(type->tp_name, '.'); + if (!ptr) { + PyErr_SetString(PyExc_ValueError, "tp_name should be a qualified name"); + return -1; + } + if (PyModule_AddObject(module, ptr + 1, (PyObject *)type)) { + return -1; + } + return 0; +} + #endif diff --git a/src/py_converters.cpp b/src/py_converters.cpp index d2c53c553c48..515291aa302d 100644 --- a/src/py_converters.cpp +++ b/src/py_converters.cpp @@ -191,21 +191,28 @@ int convert_rect(PyObject *rectobj, void *rectp) int convert_rgba(PyObject *rgbaobj, void *rgbap) { agg::rgba *rgba = (agg::rgba *)rgbap; - + PyObject *rgbatuple = NULL; + int success = 1; if (rgbaobj == NULL || rgbaobj == Py_None) { rgba->r = 0.0; rgba->g = 0.0; rgba->b = 0.0; rgba->a = 0.0; } else { + if (!(rgbatuple = PySequence_Tuple(rgbaobj))) { + success = 0; + goto exit; + } rgba->a = 1.0; if (!PyArg_ParseTuple( - rgbaobj, "ddd|d:rgba", &(rgba->r), &(rgba->g), &(rgba->b), &(rgba->a))) { - return 0; + rgbatuple, "ddd|d:rgba", &(rgba->r), &(rgba->g), &(rgba->b), &(rgba->a))) { + success = 0; + goto exit; } } - - return 1; +exit: + Py_XDECREF(rgbatuple); + return success; } int convert_dashes(PyObject *dashobj, void *dashesp) diff --git a/src/tri/_tri.cpp b/src/tri/_tri.cpp index b7a87783de29..548e65b3e52d 100644 --- a/src/tri/_tri.cpp +++ b/src/tri/_tri.cpp @@ -5,12 +5,11 @@ * undef_macros=['NDEBUG'] * to the appropriate make_extension call in setupext.py, and then rebuilding. */ -#define NO_IMPORT_ARRAY - #include "../mplutils.h" #include "_tri.h" #include +#include #include @@ -220,7 +219,7 @@ Triangulation::Triangulation(const CoordinateArray& x, const MaskArray& mask, const EdgeArray& edges, const NeighborArray& neighbors, - int correct_triangle_orientations) + bool correct_triangle_orientations) : _x(x), _y(y), _triangles(triangles), @@ -228,6 +227,29 @@ Triangulation::Triangulation(const CoordinateArray& x, _edges(edges), _neighbors(neighbors) { + if (_x.ndim() != 1 || _y.ndim() != 1 || _x.shape(0) != _y.shape(0)) + throw std::invalid_argument("x and y must be 1D arrays of the same length"); + + if (_triangles.ndim() != 2 || _triangles.shape(1) != 3) + throw std::invalid_argument("triangles must be a 2D array of shape (?,3)"); + + // Optional mask. + if (_mask.size() > 0 && + (_mask.ndim() != 1 || _mask.shape(0) != _triangles.shape(0))) + throw std::invalid_argument( + "mask must be a 1D array with the same length as the triangles array"); + + // Optional edges. + if (_edges.size() > 0 && + (_edges.ndim() != 2 || _edges.shape(1) != 2)) + throw std::invalid_argument("edges must be a 2D array with shape (?,2)"); + + // Optional neighbors. + if (_neighbors.size() > 0 && + (_neighbors.ndim() != 2 || _neighbors.shape() != _triangles.shape())) + throw std::invalid_argument( + "neighbors must be a 2D array with the same shape as the triangles array"); + if (correct_triangle_orientations) correct_triangles(); } @@ -290,7 +312,7 @@ void Triangulation::calculate_boundaries() void Triangulation::calculate_edges() { - assert(_edges.empty() && "Expected empty edges array"); + assert(!has_edges() && "Expected empty edges array"); // Create set of all edges, storing them with start point index less than // end point index. @@ -307,29 +329,28 @@ void Triangulation::calculate_edges() } // Convert to python _edges array. - npy_intp dims[2] = {static_cast(edge_set.size()), 2}; + py::ssize_t dims[2] = {static_cast(edge_set.size()), 2}; _edges = EdgeArray(dims); + auto edges = _edges.mutable_data(); int i = 0; for (EdgeSet::const_iterator it = edge_set.begin(); it != edge_set.end(); ++it) { - _edges(i, 0) = it->start; - _edges(i++, 1) = it->end; + edges[i++] = it->start; + edges[i++] = it->end; } } void Triangulation::calculate_neighbors() { - assert(_neighbors.empty() && "Expected empty neighbors array"); + assert(!has_neighbors() && "Expected empty neighbors array"); // Create _neighbors array with shape (ntri,3) and initialise all to -1. - npy_intp dims[2] = {get_ntri(), 3}; + py::ssize_t dims[2] = {get_ntri(), 3}; _neighbors = NeighborArray(dims); + auto* neighbors = _neighbors.mutable_data(); int tri, edge; - for (tri = 0; tri < get_ntri(); ++tri) { - for (edge = 0; edge < 3; ++edge) - _neighbors(tri, edge) = -1; - } + std::fill(neighbors, neighbors+3*get_ntri(), -1); // For each triangle edge (start to end point), find corresponding neighbor // edge from end to start point. Do this by traversing all edges and @@ -352,8 +373,8 @@ void Triangulation::calculate_neighbors() } else { // Neighbor edge found, set the two elements of _neighbors // and remove edge from edge_to_tri_edge_map. - _neighbors(tri, edge)= it->second.tri; - _neighbors(it->second.tri, it->second.edge) = tri; + neighbors[3*tri + edge] = it->second.tri; + neighbors[3*it->second.tri + it->second.edge] = tri; edge_to_tri_edge_map.erase(it); } } @@ -367,8 +388,17 @@ void Triangulation::calculate_neighbors() Triangulation::TwoCoordinateArray Triangulation::calculate_plane_coefficients( const CoordinateArray& z) { - npy_intp dims[2] = {get_ntri(), 3}; - Triangulation::TwoCoordinateArray planes(dims); + if (z.ndim() != 1 || z.shape(0) != _x.shape(0)) + throw std::invalid_argument( + "z must be a 1D array with the same length as the triangulation x and y arrays"); + + int dims[2] = {get_ntri(), 3}; + Triangulation::TwoCoordinateArray planes_array(dims); + auto planes = planes_array.mutable_unchecked<2>(); + auto triangles = _triangles.unchecked<2>(); + auto x = _x.unchecked<1>(); + auto y = _y.unchecked<1>(); + auto z_ptr = z.unchecked<1>(); int point; for (int tri = 0; tri < get_ntri(); ++tri) { @@ -385,12 +415,12 @@ Triangulation::TwoCoordinateArray Triangulation::calculate_plane_coefficients( // and rearrange to give // r_z = (-normal_x/normal_z)*r_x + (-normal_y/normal_z)*r_y + // p/normal_z - point = _triangles(tri, 0); - XYZ point0(_x(point), _y(point), z(point)); - point = _triangles(tri, 1); - XYZ side01 = XYZ(_x(point), _y(point), z(point)) - point0; - point = _triangles(tri, 2); - XYZ side02 = XYZ(_x(point), _y(point), z(point)) - point0; + point = triangles(tri, 0); + XYZ point0(x(point), y(point), z_ptr(point)); + point = triangles(tri, 1); + XYZ side01 = XYZ(x(point), y(point), z_ptr(point)) - point0; + point = triangles(tri, 2); + XYZ side02 = XYZ(x(point), y(point), z_ptr(point)) - point0; XYZ normal = side01.cross(side02); @@ -414,20 +444,23 @@ Triangulation::TwoCoordinateArray Triangulation::calculate_plane_coefficients( } } - return planes; + return planes_array; } void Triangulation::correct_triangles() { + auto triangles = _triangles.mutable_data(); + auto neighbors = _neighbors.mutable_data(); + for (int tri = 0; tri < get_ntri(); ++tri) { - XY point0 = get_point_coords(_triangles(tri, 0)); - XY point1 = get_point_coords(_triangles(tri, 1)); - XY point2 = get_point_coords(_triangles(tri, 2)); + XY point0 = get_point_coords(triangles[3*tri]); + XY point1 = get_point_coords(triangles[3*tri+1]); + XY point2 = get_point_coords(triangles[3*tri+2]); if ( (point1 - point0).cross_z(point2 - point0) < 0.0) { // Triangle points are clockwise, so change them to anticlockwise. - std::swap(_triangles(tri, 1), _triangles(tri, 2)); - if (!_neighbors.empty()) - std::swap(_neighbors(tri, 1), _neighbors(tri, 2)); + std::swap(triangles[3*tri+1], triangles[3*tri+2]); + if (has_neighbors()) + std::swap(neighbors[3*tri+1], neighbors[3*tri+2]); } } } @@ -456,8 +489,11 @@ int Triangulation::get_edge_in_triangle(int tri, int point) const { assert(tri >= 0 && tri < get_ntri() && "Triangle index out of bounds"); assert(point >= 0 && point < get_npoints() && "Point index out of bounds."); + + auto triangles = _triangles.data(); + for (int edge = 0; edge < 3; ++edge) { - if (_triangles(tri, edge) == point) + if (triangles[3*tri + edge] == point) return edge; } return -1; // point is not in triangle. @@ -465,7 +501,7 @@ int Triangulation::get_edge_in_triangle(int tri, int point) const Triangulation::EdgeArray& Triangulation::get_edges() { - if (_edges.empty()) + if (!has_edges()) calculate_edges(); return _edges; } @@ -474,9 +510,9 @@ int Triangulation::get_neighbor(int tri, int edge) const { assert(tri >= 0 && tri < get_ntri() && "Triangle index out of bounds"); assert(edge >= 0 && edge < 3 && "Edge index out of bounds"); - if (_neighbors.empty()) + if (!has_neighbors()) const_cast(*this).calculate_neighbors(); - return _neighbors(tri, edge); + return _neighbors.data()[3*tri + edge]; } TriEdge Triangulation::get_neighbor_edge(int tri, int edge) const @@ -493,32 +529,32 @@ TriEdge Triangulation::get_neighbor_edge(int tri, int edge) const Triangulation::NeighborArray& Triangulation::get_neighbors() { - if (_neighbors.empty()) + if (!has_neighbors()) calculate_neighbors(); return _neighbors; } int Triangulation::get_npoints() const { - return _x.size(); + return _x.shape(0); } int Triangulation::get_ntri() const { - return _triangles.size(); + return _triangles.shape(0); } XY Triangulation::get_point_coords(int point) const { assert(point >= 0 && point < get_npoints() && "Point index out of bounds."); - return XY(_x(point), _y(point)); + return XY(_x.data()[point], _y.data()[point]); } int Triangulation::get_triangle_point(int tri, int edge) const { assert(tri >= 0 && tri < get_ntri() && "Triangle index out of bounds"); assert(edge >= 0 && edge < 3 && "Edge index out of bounds"); - return _triangles(tri, edge); + return _triangles.data()[3*tri + edge]; } int Triangulation::get_triangle_point(const TriEdge& tri_edge) const @@ -526,15 +562,34 @@ int Triangulation::get_triangle_point(const TriEdge& tri_edge) const return get_triangle_point(tri_edge.tri, tri_edge.edge); } +bool Triangulation::has_edges() const +{ + return _edges.size() > 0; +} + +bool Triangulation::has_mask() const +{ + return _mask.size() > 0; +} + +bool Triangulation::has_neighbors() const +{ + return _neighbors.size() > 0; +} + bool Triangulation::is_masked(int tri) const { assert(tri >= 0 && tri < get_ntri() && "Triangle index out of bounds."); - const npy_bool* mask_ptr = reinterpret_cast(_mask.data()); - return !_mask.empty() && mask_ptr[tri]; + return has_mask() && _mask.data()[tri]; } void Triangulation::set_mask(const MaskArray& mask) { + if (mask.size() > 0 && + (mask.ndim() != 1 || mask.shape(0) != _triangles.shape(0))) + throw std::invalid_argument( + "mask must be a 1D array with the same length as the triangles array"); + _mask = mask; // Clear derived fields so they are recalculated when needed. @@ -566,7 +621,11 @@ TriContourGenerator::TriContourGenerator(Triangulation& triangulation, _interior_visited(2*_triangulation.get_ntri()), _boundaries_visited(0), _boundaries_used(0) -{} +{ + if (_z.ndim() != 1 || _z.shape(0) != _triangulation.get_npoints()) + throw std::invalid_argument( + "z must be a 1D array with the same length as the x and y arrays"); +} void TriContourGenerator::clear_visited_flags(bool include_boundaries) { @@ -597,7 +656,7 @@ void TriContourGenerator::clear_visited_flags(bool include_boundaries) } } -PyObject* TriContourGenerator::contour_line_to_segs_and_kinds(const Contour& contour) +py::tuple TriContourGenerator::contour_line_to_segs_and_kinds(const Contour& contour) { // Convert all of the lines generated by a call to create_contour() into // their Python equivalents for return to the calling function. @@ -611,27 +670,20 @@ PyObject* TriContourGenerator::contour_line_to_segs_and_kinds(const Contour& con // and they are appended to the Python lists vertices_list and codes_list // respectively for return to the Python calling function. - PyObject* vertices_list = PyList_New(contour.size()); - if (vertices_list == 0) - throw std::runtime_error("Failed to create Python list"); - - PyObject* codes_list = PyList_New(contour.size()); - if (codes_list == 0) { - Py_XDECREF(vertices_list); - throw std::runtime_error("Failed to create Python list"); - } + py::list vertices_list(contour.size()); + py::list codes_list(contour.size()); for (Contour::size_type i = 0; i < contour.size(); ++i) { const ContourLine& contour_line = contour[i]; - npy_intp npoints = static_cast(contour_line.size()); + py::ssize_t npoints = static_cast(contour_line.size()); - npy_intp segs_dims[2] = {npoints, 2}; - numpy::array_view segs(segs_dims); - double* segs_ptr = segs.data(); + py::ssize_t segs_dims[2] = {npoints, 2}; + CoordinateArray segs(segs_dims); + double* segs_ptr = segs.mutable_data(); - npy_intp codes_dims[1] = {npoints}; - numpy::array_view codes(codes_dims); - unsigned char* codes_ptr = codes.data(); + py::ssize_t codes_dims[1] = {npoints}; + CodeArray codes(codes_dims); + unsigned char* codes_ptr = codes.mutable_data(); for (ContourLine::const_iterator it = contour_line.begin(); it != contour_line.end(); ++it) { @@ -645,25 +697,14 @@ PyObject* TriContourGenerator::contour_line_to_segs_and_kinds(const Contour& con contour_line.front() == contour_line.back()) *(codes_ptr-1) = CLOSEPOLY; - PyList_SET_ITEM(vertices_list, i, segs.pyobj()); - PyList_SET_ITEM(codes_list, i, codes.pyobj()); - } - - PyObject* result = PyTuple_New(2); - if (result == 0) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - throw std::runtime_error("Failed to create Python tuple"); + vertices_list[i] = segs; + codes_list[i] = codes; } - // No error checking here as filling in a brand new pre-allocated result. - PyTuple_SET_ITEM(result, 0, vertices_list); - PyTuple_SET_ITEM(result, 1, codes_list); - - return result; + return py::make_tuple(vertices_list, codes_list); } -PyObject* TriContourGenerator::contour_to_segs_and_kinds(const Contour& contour) +py::tuple TriContourGenerator::contour_to_segs_and_kinds(const Contour& contour) { // Convert all of the polygons generated by a call to // create_filled_contour() into their Python equivalents for return to the @@ -683,19 +724,19 @@ PyObject* TriContourGenerator::contour_to_segs_and_kinds(const Contour& contour) ContourLine::const_iterator point; // Find total number of points in all contour lines. - npy_intp n_points = 0; + py::ssize_t n_points = 0; for (line = contour.begin(); line != contour.end(); ++line) - n_points += (npy_intp)line->size(); + n_points += static_cast(line->size()); // Create segs array for point coordinates. - npy_intp segs_dims[2] = {n_points, 2}; - numpy::array_view segs(segs_dims); - double* segs_ptr = segs.data(); + py::ssize_t segs_dims[2] = {n_points, 2}; + TwoCoordinateArray segs(segs_dims); + double* segs_ptr = segs.mutable_data(); // Create kinds array for code types. - npy_intp codes_dims[1] = {n_points}; - numpy::array_view codes(codes_dims); - unsigned char* codes_ptr = codes.data(); + py::ssize_t codes_dims[1] = {n_points}; + CodeArray codes(codes_dims); + unsigned char* codes_ptr = codes.mutable_data(); for (line = contour.begin(); line != contour.end(); ++line) { for (point = line->begin(); point != line->end(); point++) { @@ -705,38 +746,16 @@ PyObject* TriContourGenerator::contour_to_segs_and_kinds(const Contour& contour) } } - PyObject* vertices_list = PyList_New(0); - if (vertices_list == 0) - throw std::runtime_error("Failed to create Python list"); - - PyObject* codes_list = PyList_New(0); - if (codes_list == 0) { - Py_XDECREF(vertices_list); - throw std::runtime_error("Failed to create Python list"); - } - - if (PyList_Append(vertices_list, segs.pyobj_steal()) || - PyList_Append(codes_list, codes.pyobj_steal())) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - throw std::runtime_error("Unable to add contour to vertices and codes lists"); - } - - PyObject* result = PyTuple_New(2); - if (result == 0) { - Py_XDECREF(vertices_list); - Py_XDECREF(codes_list); - throw std::runtime_error("Failed to create Python tuple"); - } + py::list vertices_list(1); + vertices_list[0] = segs; - // No error checking here as filling in a brand new pre-allocated tuple. - PyTuple_SET_ITEM(result, 0, vertices_list); - PyTuple_SET_ITEM(result, 1, codes_list); + py::list codes_list(1); + codes_list[0] = codes; - return result; + return py::make_tuple(vertices_list, codes_list); } -PyObject* TriContourGenerator::create_contour(const double& level) +py::tuple TriContourGenerator::create_contour(const double& level) { clear_visited_flags(false); Contour contour; @@ -747,9 +766,12 @@ PyObject* TriContourGenerator::create_contour(const double& level) return contour_line_to_segs_and_kinds(contour); } -PyObject* TriContourGenerator::create_filled_contour(const double& lower_level, +py::tuple TriContourGenerator::create_filled_contour(const double& lower_level, const double& upper_level) { + if (lower_level >= upper_level) + throw std::invalid_argument("filled contour levels must be increasing"); + clear_visited_flags(true); Contour contour; @@ -1046,7 +1068,7 @@ const double& TriContourGenerator::get_z(int point) const { assert(point >= 0 && point < _triangulation.get_npoints() && "Point index out of bounds."); - return _z(point); + return _z.data()[point]; } XY TriContourGenerator::interp(int point1, @@ -1307,16 +1329,22 @@ TrapezoidMapTriFinder::TriIndexArray TrapezoidMapTriFinder::find_many(const CoordinateArray& x, const CoordinateArray& y) { + if (x.ndim() != 1 || x.shape(0) != y.shape(0)) + throw std::invalid_argument( + "x and y must be array-like with same shape"); + // Create integer array to return. - npy_intp n = x.dim(0); - npy_intp dims[1] = {n}; - TriIndexArray tri_indices(dims); + auto n = x.shape(0); + TriIndexArray tri_indices_array(n); + auto tri_indices = tri_indices_array.mutable_unchecked<1>(); + auto x_data = x.data(); + auto y_data = y.data(); // Fill returned array. - for (npy_intp i = 0; i < n; ++i) - tri_indices(i) = find_one(XY(x(i), y(i))); + for (py::ssize_t i = 0; i < n; ++i) + tri_indices(i) = find_one(XY(x_data[i], y_data[i])); - return tri_indices; + return tri_indices_array; } int @@ -1370,20 +1398,21 @@ TrapezoidMapTriFinder::find_trapezoids_intersecting_edge( return true; } -PyObject* +py::list TrapezoidMapTriFinder::get_tree_stats() { NodeStats stats; _tree->get_stats(0, stats); - return Py_BuildValue("[l,l,l,l,l,l,d]", - stats.node_count, - stats.unique_nodes.size(), - stats.trapezoid_count, - stats.unique_trapezoid_nodes.size(), - stats.max_parent_count, - stats.max_depth, - stats.sum_trapezoid_depth / stats.trapezoid_count); + py::list ret(7); + ret[0] = stats.node_count; + ret[1] = stats.unique_nodes.size(), + ret[2] = stats.trapezoid_count, + ret[3] = stats.unique_trapezoid_nodes.size(), + ret[4] = stats.max_parent_count, + ret[5] = stats.max_depth, + ret[6] = stats.sum_trapezoid_depth / stats.trapezoid_count; + return ret; } void @@ -1465,8 +1494,8 @@ TrapezoidMapTriFinder::initialize() _tree->assert_valid(false); // Randomly shuffle all edges other than first 2. - RandomNumberGenerator rng(1234); - std::random_shuffle(_edges.begin()+2, _edges.end(), rng); + std::mt19937 rng(1234); + std::shuffle(_edges.begin()+2, _edges.end(), rng); // Add edges, one at a time, to tree. size_t nedges = _edges.size(); @@ -2056,16 +2085,3 @@ TrapezoidMapTriFinder::Trapezoid::set_upper_right(Trapezoid* upper_right_) if (upper_right != 0) upper_right->upper_left = this; } - - - -RandomNumberGenerator::RandomNumberGenerator(unsigned long seed) - : _m(21870), _a(1291), _c(4621), _seed(seed % _m) -{} - -unsigned long -RandomNumberGenerator::operator()(unsigned long max_value) -{ - _seed = (_seed*_a + _c) % _m; - return (_seed*max_value) / _m; -} diff --git a/src/tri/_tri.h b/src/tri/_tri.h index 28c8e07933cc..6c6c66a01120 100644 --- a/src/tri/_tri.h +++ b/src/tri/_tri.h @@ -63,7 +63,8 @@ #ifndef MPL_TRI_H #define MPL_TRI_H -#include "../numpy_cpp.h" +#include +#include #include #include @@ -71,6 +72,7 @@ #include #include +namespace py = pybind11; /* An edge of a triangle consisting of an triangle index in the range 0 to @@ -161,12 +163,12 @@ void write_contour(const Contour& contour); class Triangulation { public: - typedef numpy::array_view CoordinateArray; - typedef numpy::array_view TwoCoordinateArray; - typedef numpy::array_view TriangleArray; - typedef numpy::array_view MaskArray; - typedef numpy::array_view EdgeArray; - typedef numpy::array_view NeighborArray; + typedef py::array_t CoordinateArray; + typedef py::array_t TwoCoordinateArray; + typedef py::array_t TriangleArray; + typedef py::array_t MaskArray; + typedef py::array_t EdgeArray; + typedef py::array_t NeighborArray; /* A single boundary is a vector of the TriEdges that make up that boundary * following it around with unmasked triangles on the left. */ @@ -196,7 +198,7 @@ class Triangulation const MaskArray& mask, const EdgeArray& edges, const NeighborArray& neighbors, - int correct_triangle_orientations); + bool correct_triangle_orientations); /* Calculate plane equation coefficients for all unmasked triangles from * the point (x,y) coordinates and point z-array of shape (npoints) passed @@ -296,7 +298,11 @@ class Triangulation * the specified triangle, or -1 if the point is not in the triangle. */ int get_edge_in_triangle(int tri, int point) const; + bool has_edges() const; + bool has_mask() const; + + bool has_neighbors() const; // Variables shared with python, always set. @@ -304,11 +310,11 @@ class Triangulation TriangleArray _triangles; // int array (ntri,3) of triangle point indices, // ordered anticlockwise. - // Variables shared with python, may be zero. + // Variables shared with python, may be unset (size == 0). MaskArray _mask; // bool array (ntri). - // Derived variables shared with python, may be zero. If zero, are - // recalculated when needed. + // Derived variables shared with python, may be unset (size == 0). + // If unset, are recalculated when needed. EdgeArray _edges; // int array (?,2) of start & end point indices. NeighborArray _neighbors; // int array (ntri,3), neighbor triangle indices // or -1 if no neighbor. @@ -329,6 +335,8 @@ class TriContourGenerator { public: typedef Triangulation::CoordinateArray CoordinateArray; + typedef Triangulation::TwoCoordinateArray TwoCoordinateArray; + typedef py::array_t CodeArray; /* Constructor. * triangulation: Triangulation to generate contours for. @@ -342,7 +350,7 @@ class TriContourGenerator * Returns new python list [segs0, segs1, ...] where * segs0: double array of shape (?,2) of point coordinates of first * contour line, etc. */ - PyObject* create_contour(const double& level); + py::tuple create_contour(const double& level); /* Create and return a filled contour. * lower_level: Lower contour level. @@ -350,7 +358,7 @@ class TriContourGenerator * Returns new python tuple (segs, kinds) where * segs: double array of shape (n_points,2) of all point coordinates, * kinds: ubyte array of shape (n_points) of all point code types. */ - PyObject* create_filled_contour(const double& lower_level, + py::tuple create_filled_contour(const double& lower_level, const double& upper_level); private: @@ -369,13 +377,13 @@ class TriContourGenerator * contour line, etc. * kinds0: ubyte array of shape (n_points) of kinds codes of first contour * line, etc. */ - PyObject* contour_line_to_segs_and_kinds(const Contour& contour); + py::tuple contour_line_to_segs_and_kinds(const Contour& contour); /* Convert a filled Contour from C++ to Python. * Returns new python tuple ([segs], [kinds]) where * segs: double array of shape (n_points,2) of all point coordinates, * kinds: ubyte array of shape (n_points) of all point code types. */ - PyObject* contour_to_segs_and_kinds(const Contour& contour); + py::tuple contour_to_segs_and_kinds(const Contour& contour); /* Return the point on the specified TriEdge that intersects the specified * level. */ @@ -460,7 +468,7 @@ class TriContourGenerator // Variables shared with python, always set. - Triangulation& _triangulation; + Triangulation _triangulation; CoordinateArray _z; // double array (npoints). // Variables internal to C++ only. @@ -507,7 +515,7 @@ class TrapezoidMapTriFinder { public: typedef Triangulation::CoordinateArray CoordinateArray; - typedef numpy::array_view TriIndexArray; + typedef py::array_t TriIndexArray; /* Constructor. A separate call to initialize() is required to initialize * the object before use. @@ -533,7 +541,7 @@ class TrapezoidMapTriFinder * comparisons needed to search through the tree) * 6: mean of all trapezoid depths (one more than the average number of * comparisons needed to search through the tree) */ - PyObject* get_tree_stats(); + py::list get_tree_stats(); /* Initialize this object before use. May be called multiple times, if, * for example, the triangulation is changed by setting the mask. */ @@ -791,28 +799,4 @@ class TrapezoidMapTriFinder Node* _tree; // Root node of the trapezoid map search tree. Owned. }; - - -/* Linear congruential random number generator. Edges in the triangulation are - * randomly shuffled before being added to the trapezoid map. Want the - * shuffling to be identical across different operating systems and the same - * regardless of previous random number use. Would prefer to use a STL or - * Boost random number generator, but support is not consistent across - * different operating systems so implementing own here. - * - * This is not particularly random, but is perfectly adequate for the use here. - * Coefficients taken from Numerical Recipes in C. */ -class RandomNumberGenerator -{ -public: - RandomNumberGenerator(unsigned long seed); - - // Return random integer in the range 0 to max_value-1. - unsigned long operator()(unsigned long max_value); - -private: - const unsigned long _m, _a, _c; - unsigned long _seed; -}; - #endif diff --git a/src/tri/_tri_wrapper.cpp b/src/tri/_tri_wrapper.cpp index 84974bc7138e..1b0c3d75555e 100644 --- a/src/tri/_tri_wrapper.cpp +++ b/src/tri/_tri_wrapper.cpp @@ -1,524 +1,58 @@ #include "_tri.h" -#include "../mplutils.h" -#include "../py_exceptions.h" - -/* Triangulation */ - -typedef struct -{ - PyObject_HEAD - Triangulation* ptr; -} PyTriangulation; - -static PyTypeObject PyTriangulationType; - -static PyObject* PyTriangulation_new(PyTypeObject* type, PyObject* args, PyObject* kwds) -{ - PyTriangulation* self; - self = (PyTriangulation*)type->tp_alloc(type, 0); - self->ptr = NULL; - return (PyObject*)self; -} - -const char* PyTriangulation_init__doc__ = - "Triangulation(x, y, triangles, mask, edges, neighbors)\n" - "--\n\n" - "Create a new C++ Triangulation object\n" - "This should not be called directly, instead use the python class\n" - "matplotlib.tri.Triangulation instead.\n"; - -static int PyTriangulation_init(PyTriangulation* self, PyObject* args, PyObject* kwds) -{ - Triangulation::CoordinateArray x, y; - Triangulation::TriangleArray triangles; - Triangulation::MaskArray mask; - Triangulation::EdgeArray edges; - Triangulation::NeighborArray neighbors; - int correct_triangle_orientations; - - if (!PyArg_ParseTuple(args, - "O&O&O&O&O&O&i", - &x.converter, &x, - &y.converter, &y, - &triangles.converter, &triangles, - &mask.converter, &mask, - &edges.converter, &edges, - &neighbors.converter, &neighbors, - &correct_triangle_orientations)) { - return -1; - } - - // x and y. - if (x.empty() || y.empty() || x.dim(0) != y.dim(0)) { - PyErr_SetString(PyExc_ValueError, - "x and y must be 1D arrays of the same length"); - return -1; - } - - // triangles. - if (triangles.empty() || triangles.dim(1) != 3) { - PyErr_SetString(PyExc_ValueError, - "triangles must be a 2D array of shape (?,3)"); - return -1; - } - - // Optional mask. - if (!mask.empty() && mask.dim(0) != triangles.dim(0)) { - PyErr_SetString(PyExc_ValueError, - "mask must be a 1D array with the same length as the triangles array"); - return -1; - } - - // Optional edges. - if (!edges.empty() && edges.dim(1) != 2) { - PyErr_SetString(PyExc_ValueError, - "edges must be a 2D array with shape (?,2)"); - return -1; - } - - // Optional neighbors. - if (!neighbors.empty() && (neighbors.dim(0) != triangles.dim(0) || - neighbors.dim(1) != triangles.dim(1))) { - PyErr_SetString(PyExc_ValueError, - "neighbors must be a 2D array with the same shape as the triangles array"); - return -1; - } - - CALL_CPP_INIT("Triangulation", - (self->ptr = new Triangulation(x, y, triangles, mask, - edges, neighbors, - correct_triangle_orientations))); - return 0; -} - -static void PyTriangulation_dealloc(PyTriangulation* self) -{ - delete self->ptr; - Py_TYPE(self)->tp_free((PyObject*)self); -} - -const char* PyTriangulation_calculate_plane_coefficients__doc__ = - "calculate_plane_coefficients(z, plane_coefficients)\n" - "--\n\n" - "Calculate plane equation coefficients for all unmasked triangles"; - -static PyObject* PyTriangulation_calculate_plane_coefficients(PyTriangulation* self, PyObject* args) -{ - Triangulation::CoordinateArray z; - if (!PyArg_ParseTuple(args, "O&:calculate_plane_coefficients", - &z.converter, &z)) { - return NULL; - } - - if (z.empty() || z.dim(0) != self->ptr->get_npoints()) { - PyErr_SetString(PyExc_ValueError, - "z array must have same length as triangulation x and y arrays"); - return NULL; - } - - Triangulation::TwoCoordinateArray result; - CALL_CPP("calculate_plane_coefficients", - (result = self->ptr->calculate_plane_coefficients(z))); - return result.pyobj(); -} - -const char* PyTriangulation_get_edges__doc__ = - "get_edges()\n" - "--\n\n" - "Return edges array"; - -static PyObject* PyTriangulation_get_edges(PyTriangulation* self, PyObject* args) -{ - Triangulation::EdgeArray* result; - CALL_CPP("get_edges", (result = &self->ptr->get_edges())); - - if (result->empty()) { - Py_RETURN_NONE; - } - else - return result->pyobj(); -} - -const char* PyTriangulation_get_neighbors__doc__ = - "get_neighbors()\n" - "--\n\n" - "Return neighbors array"; - -static PyObject* PyTriangulation_get_neighbors(PyTriangulation* self, PyObject* args) -{ - Triangulation::NeighborArray* result; - CALL_CPP("get_neighbors", (result = &self->ptr->get_neighbors())); - - if (result->empty()) { - Py_RETURN_NONE; - } - else - return result->pyobj(); -} - -const char* PyTriangulation_set_mask__doc__ = - "set_mask(mask)\n" - "--\n\n" - "Set or clear the mask array."; - -static PyObject* PyTriangulation_set_mask(PyTriangulation* self, PyObject* args) -{ - Triangulation::MaskArray mask; - - if (!PyArg_ParseTuple(args, "O&:set_mask", &mask.converter, &mask)) { - return NULL; - } - - if (!mask.empty() && mask.dim(0) != self->ptr->get_ntri()) { - PyErr_SetString(PyExc_ValueError, - "mask must be a 1D array with the same length as the triangles array"); - return NULL; - } - - CALL_CPP("set_mask", (self->ptr->set_mask(mask))); - Py_RETURN_NONE; -} - -static PyTypeObject* PyTriangulation_init_type(PyObject* m, PyTypeObject* type) -{ - static PyMethodDef methods[] = { - {"calculate_plane_coefficients", (PyCFunction)PyTriangulation_calculate_plane_coefficients, METH_VARARGS, PyTriangulation_calculate_plane_coefficients__doc__}, - {"get_edges", (PyCFunction)PyTriangulation_get_edges, METH_NOARGS, PyTriangulation_get_edges__doc__}, - {"get_neighbors", (PyCFunction)PyTriangulation_get_neighbors, METH_NOARGS, PyTriangulation_get_neighbors__doc__}, - {"set_mask", (PyCFunction)PyTriangulation_set_mask, METH_VARARGS, PyTriangulation_set_mask__doc__}, - {NULL} - }; - - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib._tri.Triangulation"; - type->tp_doc = PyTriangulation_init__doc__; - type->tp_basicsize = sizeof(PyTriangulation); - type->tp_dealloc = (destructor)PyTriangulation_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT; - type->tp_methods = methods; - type->tp_new = PyTriangulation_new; - type->tp_init = (initproc)PyTriangulation_init; - - if (PyType_Ready(type) < 0) { - return NULL; - } - - if (PyModule_AddObject(m, "Triangulation", (PyObject*)type)) { - return NULL; - } - - return type; -} - - -/* TriContourGenerator */ - -typedef struct -{ - PyObject_HEAD - TriContourGenerator* ptr; - PyTriangulation* py_triangulation; -} PyTriContourGenerator; - -static PyTypeObject PyTriContourGeneratorType; - -static PyObject* PyTriContourGenerator_new(PyTypeObject* type, PyObject* args, PyObject* kwds) -{ - PyTriContourGenerator* self; - self = (PyTriContourGenerator*)type->tp_alloc(type, 0); - self->ptr = NULL; - self->py_triangulation = NULL; - return (PyObject*)self; -} - -const char* PyTriContourGenerator_init__doc__ = - "TriContourGenerator(triangulation, z)\n" - "--\n\n" - "Create a new C++ TriContourGenerator object\n" - "This should not be called directly, instead use the functions\n" - "matplotlib.axes.tricontour and tricontourf instead.\n"; - -static int PyTriContourGenerator_init(PyTriContourGenerator* self, PyObject* args, PyObject* kwds) -{ - PyObject* triangulation_arg; - TriContourGenerator::CoordinateArray z; - - if (!PyArg_ParseTuple(args, "O!O&", - &PyTriangulationType, &triangulation_arg, - &z.converter, &z)) { - return -1; - } - - PyTriangulation* py_triangulation = (PyTriangulation*)triangulation_arg; - Py_INCREF(py_triangulation); - self->py_triangulation = py_triangulation; - Triangulation& triangulation = *(py_triangulation->ptr); - - if (z.empty() || z.dim(0) != triangulation.get_npoints()) { - PyErr_SetString(PyExc_ValueError, - "z must be a 1D array with the same length as the x and y arrays"); - return -1; - } - - CALL_CPP_INIT("TriContourGenerator", - (self->ptr = new TriContourGenerator(triangulation, z))); - return 0; -} - -static void PyTriContourGenerator_dealloc(PyTriContourGenerator* self) -{ - delete self->ptr; - Py_XDECREF(self->py_triangulation); - Py_TYPE(self)->tp_free((PyObject *)self); -} - -const char* PyTriContourGenerator_create_contour__doc__ = - "create_contour(level)\n" - "\n" - "Create and return a non-filled contour."; - -static PyObject* PyTriContourGenerator_create_contour(PyTriContourGenerator* self, PyObject* args) -{ - double level; - if (!PyArg_ParseTuple(args, "d:create_contour", &level)) { - return NULL; - } - - PyObject* result; - CALL_CPP("create_contour", (result = self->ptr->create_contour(level))); - return result; -} - -const char* PyTriContourGenerator_create_filled_contour__doc__ = - "create_filled_contour(lower_level, upper_level)\n" - "\n" - "Create and return a filled contour"; - -static PyObject* PyTriContourGenerator_create_filled_contour(PyTriContourGenerator* self, PyObject* args) -{ - double lower_level, upper_level; - if (!PyArg_ParseTuple(args, "dd:create_filled_contour", - &lower_level, &upper_level)) { - return NULL; - } - - if (lower_level >= upper_level) - { - PyErr_SetString(PyExc_ValueError, - "filled contour levels must be increasing"); - return NULL; - } - - PyObject* result; - CALL_CPP("create_filled_contour", - (result = self->ptr->create_filled_contour(lower_level, - upper_level))); - return result; +PYBIND11_MODULE(_tri, m) { + py::class_(m, "Triangulation") + .def(py::init(), + py::arg("x"), + py::arg("y"), + py::arg("triangles"), + py::arg("mask"), + py::arg("edges"), + py::arg("neighbors"), + py::arg("correct_triangle_orientations"), + "Create a new C++ Triangulation object.\n" + "This should not be called directly, use the python class\n" + "matplotlib.tri.Triangulation instead.\n") + .def("calculate_plane_coefficients", &Triangulation::calculate_plane_coefficients, + "Calculate plane equation coefficients for all unmasked triangles.") + .def("get_edges", &Triangulation::get_edges, + "Return edges array.") + .def("get_neighbors", &Triangulation::get_neighbors, + "Return neighbors array.") + .def("set_mask", &Triangulation::set_mask, + "Set or clear the mask array."); + + py::class_(m, "TriContourGenerator") + .def(py::init(), + py::arg("triangulation"), + py::arg("z"), + "Create a new C++ TriContourGenerator object.\n" + "This should not be called directly, use the functions\n" + "matplotlib.axes.tricontour and tricontourf instead.\n") + .def("create_contour", &TriContourGenerator::create_contour, + "Create and return a non-filled contour.") + .def("create_filled_contour", &TriContourGenerator::create_filled_contour, + "Create and return a filled contour."); + + py::class_(m, "TrapezoidMapTriFinder") + .def(py::init(), + py::arg("triangulation"), + "Create a new C++ TrapezoidMapTriFinder object.\n" + "This should not be called directly, use the python class\n" + "matplotlib.tri.TrapezoidMapTriFinder instead.\n") + .def("find_many", &TrapezoidMapTriFinder::find_many, + "Find indices of triangles containing the point coordinates (x, y).") + .def("get_tree_stats", &TrapezoidMapTriFinder::get_tree_stats, + "Return statistics about the tree used by the trapezoid map.") + .def("initialize", &TrapezoidMapTriFinder::initialize, + "Initialize this object, creating the trapezoid map from the triangulation.") + .def("print_tree", &TrapezoidMapTriFinder::print_tree, + "Print the search tree as text to stdout; useful for debug purposes."); } - -static PyTypeObject* PyTriContourGenerator_init_type(PyObject* m, PyTypeObject* type) -{ - static PyMethodDef methods[] = { - {"create_contour", (PyCFunction)PyTriContourGenerator_create_contour, METH_VARARGS, PyTriContourGenerator_create_contour__doc__}, - {"create_filled_contour", (PyCFunction)PyTriContourGenerator_create_filled_contour, METH_VARARGS, PyTriContourGenerator_create_filled_contour__doc__}, - {NULL} - }; - - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib._tri.TriContourGenerator"; - type->tp_doc = PyTriContourGenerator_init__doc__; - type->tp_basicsize = sizeof(PyTriContourGenerator); - type->tp_dealloc = (destructor)PyTriContourGenerator_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT; - type->tp_methods = methods; - type->tp_new = PyTriContourGenerator_new; - type->tp_init = (initproc)PyTriContourGenerator_init; - - if (PyType_Ready(type) < 0) { - return NULL; - } - - if (PyModule_AddObject(m, "TriContourGenerator", (PyObject*)type)) { - return NULL; - } - - return type; -} - - -/* TrapezoidMapTriFinder */ - -typedef struct -{ - PyObject_HEAD - TrapezoidMapTriFinder* ptr; - PyTriangulation* py_triangulation; -} PyTrapezoidMapTriFinder; - -static PyTypeObject PyTrapezoidMapTriFinderType; - -static PyObject* PyTrapezoidMapTriFinder_new(PyTypeObject* type, PyObject* args, PyObject* kwds) -{ - PyTrapezoidMapTriFinder* self; - self = (PyTrapezoidMapTriFinder*)type->tp_alloc(type, 0); - self->ptr = NULL; - self->py_triangulation = NULL; - return (PyObject*)self; -} - -const char* PyTrapezoidMapTriFinder_init__doc__ = - "TrapezoidMapTriFinder(triangulation)\n" - "--\n\n" - "Create a new C++ TrapezoidMapTriFinder object\n" - "This should not be called directly, instead use the python class\n" - "matplotlib.tri.TrapezoidMapTriFinder instead.\n"; - -static int PyTrapezoidMapTriFinder_init(PyTrapezoidMapTriFinder* self, PyObject* args, PyObject* kwds) -{ - PyObject* triangulation_arg; - if (!PyArg_ParseTuple(args, "O!", - &PyTriangulationType, &triangulation_arg)) { - return -1; - } - - PyTriangulation* py_triangulation = (PyTriangulation*)triangulation_arg; - Py_INCREF(py_triangulation); - self->py_triangulation = py_triangulation; - Triangulation& triangulation = *(py_triangulation->ptr); - - CALL_CPP_INIT("TrapezoidMapTriFinder", - (self->ptr = new TrapezoidMapTriFinder(triangulation))); - return 0; -} - -static void PyTrapezoidMapTriFinder_dealloc(PyTrapezoidMapTriFinder* self) -{ - delete self->ptr; - Py_XDECREF(self->py_triangulation); - Py_TYPE(self)->tp_free((PyObject *)self); -} - -const char* PyTrapezoidMapTriFinder_find_many__doc__ = - "find_many(x, y)\n" - "\n" - "Find indices of triangles containing the point coordinates (x, y)"; - -static PyObject* PyTrapezoidMapTriFinder_find_many(PyTrapezoidMapTriFinder* self, PyObject* args) -{ - TrapezoidMapTriFinder::CoordinateArray x, y; - if (!PyArg_ParseTuple(args, "O&O&:find_many", - &x.converter, &x, - &y.converter, &y)) { - return NULL; - } - - if (x.empty() || y.empty() || x.dim(0) != y.dim(0)) { - PyErr_SetString(PyExc_ValueError, - "x and y must be array-like with same shape"); - return NULL; - } - - TrapezoidMapTriFinder::TriIndexArray result; - CALL_CPP("find_many", (result = self->ptr->find_many(x, y))); - return result.pyobj(); -} - -const char* PyTrapezoidMapTriFinder_get_tree_stats__doc__ = - "get_tree_stats()\n" - "\n" - "Return statistics about the tree used by the trapezoid map"; - -static PyObject* PyTrapezoidMapTriFinder_get_tree_stats(PyTrapezoidMapTriFinder* self, PyObject* args) -{ - PyObject* result; - CALL_CPP("get_tree_stats", (result = self->ptr->get_tree_stats())); - return result; -} - -const char* PyTrapezoidMapTriFinder_initialize__doc__ = - "initialize()\n" - "\n" - "Initialize this object, creating the trapezoid map from the triangulation"; - -static PyObject* PyTrapezoidMapTriFinder_initialize(PyTrapezoidMapTriFinder* self, PyObject* args) -{ - CALL_CPP("initialize", (self->ptr->initialize())); - Py_RETURN_NONE; -} - -const char* PyTrapezoidMapTriFinder_print_tree__doc__ = - "print_tree()\n" - "\n" - "Print the search tree as text to stdout; useful for debug purposes"; - -static PyObject* PyTrapezoidMapTriFinder_print_tree(PyTrapezoidMapTriFinder* self, PyObject* args) -{ - CALL_CPP("print_tree", (self->ptr->print_tree())); - Py_RETURN_NONE; -} - -static PyTypeObject* PyTrapezoidMapTriFinder_init_type(PyObject* m, PyTypeObject* type) -{ - static PyMethodDef methods[] = { - {"find_many", (PyCFunction)PyTrapezoidMapTriFinder_find_many, METH_VARARGS, PyTrapezoidMapTriFinder_find_many__doc__}, - {"get_tree_stats", (PyCFunction)PyTrapezoidMapTriFinder_get_tree_stats, METH_NOARGS, PyTrapezoidMapTriFinder_get_tree_stats__doc__}, - {"initialize", (PyCFunction)PyTrapezoidMapTriFinder_initialize, METH_NOARGS, PyTrapezoidMapTriFinder_initialize__doc__}, - {"print_tree", (PyCFunction)PyTrapezoidMapTriFinder_print_tree, METH_NOARGS, PyTrapezoidMapTriFinder_print_tree__doc__}, - {NULL} - }; - - memset(type, 0, sizeof(PyTypeObject)); - type->tp_name = "matplotlib._tri.TrapezoidMapTriFinder"; - type->tp_doc = PyTrapezoidMapTriFinder_init__doc__; - type->tp_basicsize = sizeof(PyTrapezoidMapTriFinder); - type->tp_dealloc = (destructor)PyTrapezoidMapTriFinder_dealloc; - type->tp_flags = Py_TPFLAGS_DEFAULT; - type->tp_methods = methods; - type->tp_new = PyTrapezoidMapTriFinder_new; - type->tp_init = (initproc)PyTrapezoidMapTriFinder_init; - - if (PyType_Ready(type) < 0) { - return NULL; - } - - if (PyModule_AddObject(m, "TrapezoidMapTriFinder", (PyObject*)type)) { - return NULL; - } - - return type; -} - -static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "_tri" }; - -#pragma GCC visibility push(default) - -PyMODINIT_FUNC PyInit__tri(void) -{ - PyObject *m; - - import_array(); - - m = PyModule_Create(&moduledef); - - if (m == NULL) { - return NULL; - } - - if (!PyTriangulation_init_type(m, &PyTriangulationType)) { - Py_DECREF(m); - return NULL; - } - if (!PyTriContourGenerator_init_type(m, &PyTriContourGeneratorType)) { - Py_DECREF(m); - return NULL; - } - if (!PyTrapezoidMapTriFinder_init_type(m, &PyTrapezoidMapTriFinderType)) { - Py_DECREF(m); - return NULL; - } - - return m; -} - -#pragma GCC visibility pop diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 46c8d3656373..0b00d7a12b4a 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -79,7 +79,16 @@ def {name}{signature}: return gcf().{called_name}{call} """ -CMAP_TEMPLATE = "def {name}(): set_cmap({name!r})\n" # Colormap functions. +CMAP_TEMPLATE = ''' +def {name}(): + """ + Set the colormap to {name!r}. + + This changes the default colormap as well as the colormap of the current + image if there is one. See ``help(colormaps)`` for more information. + """ + set_cmap({name!r}) +''' # Colormap functions. class value_formatter: @@ -173,11 +182,12 @@ def generate_function(name, called_fullname, template, **kwargs): if param.kind in [ Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY] else + '{0}' + if param.kind is Parameter.POSITIONAL_ONLY else '*{0}' if param.kind is Parameter.VAR_POSITIONAL else '**{0}' if param.kind is Parameter.VAR_KEYWORD else - # Intentionally crash for Parameter.POSITIONAL_ONLY. None).format(param.name) for param in params) + ')' MAX_CALL_PREFIX = 18 # len(' __ret = gca().') @@ -330,8 +340,6 @@ def boilerplate_gen(): yield generate_function(name, f'Axes.{called_name}', template, sci_command=cmappable.get(name)) - yield AUTOGEN_MSG - yield '\n' cmaps = ( 'autumn', 'bone', @@ -355,11 +363,9 @@ def boilerplate_gen(): ) # add all the colormaps (autumn, hsv, ....) for name in cmaps: + yield AUTOGEN_MSG yield CMAP_TEMPLATE.format(name=name) - yield '\n\n' - yield '_setup_pyplot_info_docstrings()' - def build_pyplot(pyplot_path): pyplot_orig = pyplot_path.read_text().splitlines(keepends=True) @@ -372,7 +378,6 @@ def build_pyplot(pyplot_path): with pyplot_path.open('w') as pyplot: pyplot.writelines(pyplot_orig) pyplot.writelines(boilerplate_gen()) - pyplot.write('\n') if __name__ == '__main__': diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index 98018d9bf5f6..8a3c0450d626 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,12 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.6.3": "7527665", + "v3.6.2": "7275322", + "v3.6.1": "7162185", + "v3.6.0": "7084615", + "v3.5.3": "6982547", + "v3.5.2": "6513224", "v3.5.1": "5773480", "v3.5.0": "5706396", "v3.4.3": "5194481", diff --git a/tools/create_DejaVuDisplay.sh b/tools/create_DejaVuDisplay.sh index 1b9d49935fd0..8ee3b1659e62 100755 --- a/tools/create_DejaVuDisplay.sh +++ b/tools/create_DejaVuDisplay.sh @@ -2,10 +2,10 @@ # Subsetting DejaVu fonts to create a display-math-only font -# The DejaVu fonts include math display variants outside of the unicode range, -# and it is currently hard to access them from matploltib. The subset.py script +# The DejaVu fonts include math display variants outside of the Unicode range, +# and it is currently hard to access them from matplotlib. The subset.py script # in `tools` has been modified to move the math display variants found in DejaVu -# fonts into a new TTF font with these variants in the unicode range. +# fonts into a new TTF font with these variants in the Unicode range. # This bash script calls the subset.py scripts with the appropriate options to # generate the new font files `DejaVuSansDisplay.ttf` and diff --git a/tools/gh_api.py b/tools/gh_api.py index 71879264a4ac..dad57df9f119 100644 --- a/tools/gh_api.py +++ b/tools/gh_api.py @@ -216,7 +216,7 @@ def encode_multipart_formdata(fields, boundary=None): bytes. If the value is a tuple of two elements, then the first element is treated as the filename of the form-data section. - Field names and filenames must be unicode. + Field names and filenames must be str. :param boundary: If not specified, then a random boundary will be generated using diff --git a/tools/github_stats.py b/tools/github_stats.py index 2d1fbc47e81a..f6e190324194 100755 --- a/tools/github_stats.py +++ b/tools/github_stats.py @@ -78,8 +78,8 @@ def issues_closed_since(period=timedelta(days=365), project="matplotlib/matplotl filtered = [ i for i in allclosed if _parse_datetime(i['closed_at']) > since ] if pulls: filtered = [ i for i in filtered if _parse_datetime(i['merged_at']) > since ] - # filter out PRs not against master (backports) - filtered = [ i for i in filtered if i['base']['ref'] == 'master' ] + # filter out PRs not against main (backports) + filtered = [ i for i in filtered if i['base']['ref'] == 'main' ] else: filtered = [ i for i in filtered if not is_pull_request(i) ] diff --git a/tools/make_icons.py b/tools/make_icons.py index 773b4c717e28..0424c0d03dad 100755 --- a/tools/make_icons.py +++ b/tools/make_icons.py @@ -46,8 +46,8 @@ def save_icon(fig, dest_dir, name): def make_icon(font_path, ccode): fig = plt.figure(figsize=(1, 1)) fig.patch.set_alpha(0.0) - text = fig.text(0.5, 0.48, chr(ccode), ha='center', va='center', - font=font_path, fontsize=68) + fig.text(0.5, 0.48, chr(ccode), ha='center', va='center', + font=font_path, fontsize=68) return fig diff --git a/tools/run_examples.py b/tools/run_examples.py index 97dfe5be7ec5..e6542e9ddf23 100644 --- a/tools/run_examples.py +++ b/tools/run_examples.py @@ -10,6 +10,7 @@ import sys from tempfile import TemporaryDirectory import time +import tokenize _preamble = """\ @@ -73,8 +74,9 @@ def main(): cwd.mkdir(parents=True) else: cwd = stack.enter_context(TemporaryDirectory()) - Path(cwd, relpath.name).write_text( - _preamble + (root / relpath).read_text()) + with tokenize.open(root / relpath) as src: + Path(cwd, relpath.name).write_text( + _preamble + src.read(), encoding="utf-8") for backend in args.backend or [None]: env = {**os.environ} if backend is not None: diff --git a/tools/subset.py b/tools/subset.py index 53e8d5fd9400..9fdf3789b0df 100644 --- a/tools/subset.py +++ b/tools/subset.py @@ -35,20 +35,21 @@ import fontforge -def log_namelist(nam, unicode): - if nam and isinstance(unicode, int): - print(f"0x{unicode:04X}", fontforge.nameFromUnicode(unicode), file=nam) +def log_namelist(name, unicode): + if name and isinstance(unicode, int): + print(f"0x{unicode:04X}", fontforge.nameeFromUnicode(unicode), + file=name) -def select_with_refs(font, unicode, newfont, pe=None, nam=None): +def select_with_refs(font, unicode, newfont, pe=None, name=None): newfont.selection.select(('more', 'unicode'), unicode) - log_namelist(nam, unicode) + log_namelist(name, unicode) if pe: print(f"SelectMore({unicode})", file=pe) try: for ref in font[unicode].references: newfont.selection.select(('more',), ref[0]) - log_namelist(nam, ref[0]) + log_namelist(name, ref[0]) if pe: print(f'SelectMore("{ref[0]}")', file=pe) except Exception: @@ -60,11 +61,11 @@ def subset_font_raw(font_in, font_out, unicodes, opts): # 2010-12-06 DC To allow setting namelist filenames, # change getopt.gnu_getopt from namelist to namelist= # and invert comments on following 2 lines - # nam_fn = opts['--namelist'] - nam_fn = f'{font_out}.nam' - nam = open(nam_fn, 'w') + # name_fn = opts['--namelist'] + name_fn = f'{font_out}.name' + name = open(name_fn, 'w') else: - nam = None + name = None if '--script' in opts: pe_fn = "/tmp/script.pe" pe = open(pe_fn, 'w') @@ -75,7 +76,7 @@ def subset_font_raw(font_in, font_out, unicodes, opts): print(f'Open("{font_in}")', file=pe) extract_vert_to_script(font_in, pe) for i in unicodes: - select_with_refs(font, i, font, pe, nam) + select_with_refs(font, i, font, pe, name) addl_glyphs = [] if '--nmr' in opts: @@ -86,9 +87,9 @@ def subset_font_raw(font_in, font_out, unicodes, opts): addl_glyphs.append('.notdef') for glyph in addl_glyphs: font.selection.select(('more',), glyph) - if nam: + if name: print(f"0x{fontforge.unicodeFromName(glyph):0.4X}", glyph, - file=nam) + file=name) if pe: print(f'SelectMore("{glyph}")', file=pe) @@ -112,7 +113,7 @@ def subset_font_raw(font_in, font_out, unicodes, opts): new.em = font.em new.layers['Fore'].is_quadratic = font.layers['Fore'].is_quadratic for i in unicodes: - select_with_refs(font, i, new, pe, nam) + select_with_refs(font, i, new, pe, name) new.paste() # This is a hack - it should have been taken care of above. font.selection.select('space') @@ -128,7 +129,7 @@ def subset_font_raw(font_in, font_out, unicodes, opts): print("Clear()", file=pe) if '--move-display' in opts: - print("Moving display glyphs into unicode ranges...") + print("Moving display glyphs into Unicode ranges...") font.familyname += " Display" font.fullname += " Display" font.fontname += "Display" @@ -149,9 +150,9 @@ def subset_font_raw(font_in, font_out, unicodes, opts): font.selection.select(glname) font.cut() - if nam: + if name: print("Writing NameList", end="") - nam.close() + name.close() if pe: print(f'Generate("{font_out}")', file=pe) @@ -177,12 +178,11 @@ def subset_font(font_in, font_out, unicodes, opts): if font_out != font_out_raw: os.rename(font_out_raw, font_out) # 2011-02-14 DC this needs to only happen with --namelist is used -# os.rename(font_out_raw + '.nam', font_out + '.nam') +# os.rename(font_out_raw + '.name', font_out + '.name') def getsubset(subset, font_in): subsets = subset.split('+') - quotes = [ 0x2013, # endash 0x2014, # emdash @@ -220,16 +220,16 @@ def getsubset(subset, font_in): result = quotes - if 'menu' in subset: + if 'menu' in subsets: font = fontforge.open(font_in) result = [ *map(ord, font.familyname), 0x0020, ] - if 'latin' in subset: + if 'latin' in subsets: result += latin - if 'latin-ext' in subset: + if 'latin-ext' in subsets: # These ranges include Extended A, B, C, D, and Additional with the # exception of Vietnamese, which is a separate range result += [ @@ -240,7 +240,7 @@ def getsubset(subset, font_in): *range(0x2c60, 0x2c80), *range(0xa700, 0xa800), ] - if 'vietnamese' in subset: + if 'vietnamese' in subsets: # 2011-07-16 DC: Charset from # http://vietunicode.sourceforge.net/charset/ + U+1ef9 from Fontaine result += [0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00C8, 0x00C9, @@ -251,16 +251,16 @@ def getsubset(subset, font_in): 0x00FA, 0x00FD, 0x0102, 0x0103, 0x0110, 0x0111, 0x0128, 0x0129, 0x0168, 0x0169, 0x01A0, 0x01A1, 0x01AF, 0x01B0, 0x20AB, *range(0x1EA0, 0x1EFA)] - if 'greek' in subset: + if 'greek' in subsets: # Could probably be more aggressive here and exclude archaic # characters, but lack data result += [*range(0x370, 0x400)] - if 'greek-ext' in subset: + if 'greek-ext' in subsets: result += [*range(0x370, 0x400), *range(0x1f00, 0x2000)] - if 'cyrillic' in subset: + if 'cyrillic' in subsets: # Based on character frequency analysis result += [*range(0x400, 0x460), 0x490, 0x491, 0x4b0, 0x4b1, 0x2116] - if 'cyrillic-ext' in subset: + if 'cyrillic-ext' in subsets: result += [ *range(0x400, 0x530), 0x20b4, @@ -270,7 +270,7 @@ def getsubset(subset, font_in): *range(0x2de0, 0x2e00), *range(0xa640, 0xa6a0), ] - if 'arabic' in subset: + if 'arabic' in subsets: # Based on Droid Arabic Kufi 1.0 result += [0x000D, 0x0020, 0x0621, 0x0627, 0x062D, 0x062F, 0x0631, 0x0633, 0x0635, 0x0637, 0x0639, @@ -322,7 +322,7 @@ def getsubset(subset, font_in): 0x063b, 0x063c, 0x063d, 0x063e, 0x063f, 0x0620, 0x0674, 0x0674, 0x06EC] - if 'dejavu-ext' in subset: + if 'dejavu-ext' in subsets: # add all glyphnames ending in .display font = fontforge.open(font_in) for glyph in font.glyphs(): @@ -335,10 +335,10 @@ def getsubset(subset, font_in): # code for extracting vertical metrics from a TrueType font class Sfnt: def __init__(self, data): - version, numTables, _, _, _ = struct.unpack('>IHHHH', data[:12]) + _, numTables, _, _, _ = struct.unpack('>IHHHH', data[:12]) self.tables = {} for i in range(numTables): - tag, checkSum, offset, length = struct.unpack( + tag, _, offset, length = struct.unpack( '>4sIII', data[12 + 16 * i: 28 + 16 * i]) self.tables[tag] = data[offset: offset + length] diff --git a/tools/triage_tests.py b/tools/triage_tests.py index 0fd1c76109a8..fc245fea1a2f 100644 --- a/tools/triage_tests.py +++ b/tools/triage_tests.py @@ -40,13 +40,13 @@ BASELINE_IMAGES = [ Path('lib/matplotlib/tests/baseline_images'), - Path('lib/mpl_toolkits/tests/baseline_images'), + *Path('lib/mpl_toolkits').glob('*/tests/baseline_images'), ] # Non-png image extensions -exts = ['pdf', 'svg'] +exts = ['pdf', 'svg', 'eps'] class Thumbnail(QtWidgets.QFrame): diff --git a/tools/visualize_tests.py b/tools/visualize_tests.py index 7786a232f09f..239c1b53de69 100644 --- a/tools/visualize_tests.py +++ b/tools/visualize_tests.py @@ -126,7 +126,7 @@ def run(show_browser=True): show_message = True if show_message: - print("Open {} in a browser for a visual comparison.".format(index)) + print(f"Open {index} in a browser for a visual comparison.") if __name__ == '__main__': diff --git a/tox.ini b/tox.ini index b6b94c41b5fc..fc8479269160 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py37, py38 +envlist = py38, py39, py310 [testenv] changedir = /tmp diff --git a/tutorials/README.txt b/tutorials/README.txt index 74d339265bdb..da744b3224c7 100644 --- a/tutorials/README.txt +++ b/tutorials/README.txt @@ -7,6 +7,6 @@ This page contains more in-depth guides for using Matplotlib. It is broken up into beginner, intermediate, and advanced sections, as well as sections covering specific topics. -For shorter examples, see our `examples page <../gallery/index.html>`_. -You can also find `external resources <../resources/index.html>`_ and -a `FAQ <../faq/index.html>`_ in our `user guide <../contents.html>`_. +For shorter examples, see our :ref:`examples page `. +You can also find :ref:`external resources ` and +a :ref:`FAQ ` in our :ref:`user guide `. diff --git a/tutorials/advanced/blitting.py b/tutorials/advanced/blitting.py index 5481e533ca57..54aeddecc6ef 100644 --- a/tutorials/advanced/blitting.py +++ b/tutorials/advanced/blitting.py @@ -41,7 +41,7 @@ .. warning:: This code does not work with the OSX backend (but does work with other - GUI backends on mac). + GUI backends on Mac). Minimal example --------------- @@ -106,7 +106,7 @@ # pixels changes (due to either the size or dpi of the figure # changing) , the background will be invalid and result in incorrect # (but sometimes cool looking!) images. There is also a global -# variable and a fair amount of boiler plate which suggests we should +# variable and a fair amount of boilerplate which suggests we should # wrap this in a class. # # Class-based example @@ -125,7 +125,7 @@ def __init__(self, canvas, animated_artists=()): Parameters ---------- canvas : FigureCanvasAgg - The canvas to work with, this only works for sub-classes of the Agg + The canvas to work with, this only works for subclasses of the Agg canvas which have the `~FigureCanvasAgg.copy_from_bbox` and `~FigureCanvasAgg.restore_region` methods. diff --git a/tutorials/advanced/path_tutorial.py b/tutorials/advanced/path_tutorial.py index 19632ce42964..caf5f04b5a1e 100644 --- a/tutorials/advanced/path_tutorial.py +++ b/tutorials/advanced/path_tutorial.py @@ -9,7 +9,7 @@ the :class:`~matplotlib.path.Path`, which supports the standard set of moveto, lineto, curveto commands to draw simple and compound outlines consisting of line segments and splines. The ``Path`` is instantiated -with a (N, 2) array of (x, y) vertices, and a N-length array of path +with a (N, 2) array of (x, y) vertices, and an N-length array of path codes. For example to draw the unit rectangle from (0, 0) to (1, 1), we could use this code: """ @@ -76,11 +76,11 @@ # ============== # # Some of the path components require multiple vertices to specify them: -# for example CURVE 3 is a `bézier +# for example CURVE 3 is a `Bézier # `_ curve with one # control point and one end point, and CURVE4 has three vertices for the # two control points and the end point. The example below shows a -# CURVE4 Bézier spline -- the bézier curve will be contained in the +# CURVE4 Bézier spline -- the Bézier curve will be contained in the # convex hull of the start point, the two control points, and the end # point @@ -139,8 +139,8 @@ # for each histogram bar: the rectangle width is the bin width and the # rectangle height is the number of datapoints in that bin. First we'll # create some random normally distributed data and compute the -# histogram. Because numpy returns the bin edges and not centers, the -# length of ``bins`` is 1 greater than the length of ``n`` in the +# histogram. Because NumPy returns the bin edges and not centers, the +# length of ``bins`` is one greater than the length of ``n`` in the # example below:: # # # histogram our data with numpy @@ -148,7 +148,7 @@ # n, bins = np.histogram(data, 100) # # We'll now extract the corners of the rectangles. Each of the -# ``left``, ``bottom``, etc, arrays below is ``len(n)``, where ``n`` is +# ``left``, ``bottom``, etc., arrays below is ``len(n)``, where ``n`` is # the array of counts for each histogram bar:: # # # get the corners of the rectangles for the histogram @@ -159,10 +159,10 @@ # # Now we have to construct our compound path, which will consist of a # series of ``MOVETO``, ``LINETO`` and ``CLOSEPOLY`` for each rectangle. -# For each rectangle, we need 5 vertices: 1 for the ``MOVETO``, 3 for -# the ``LINETO``, and 1 for the ``CLOSEPOLY``. As indicated in the -# table above, the vertex for the closepoly is ignored but we still need -# it to keep the codes aligned with the vertices:: +# For each rectangle, we need five vertices: one for the ``MOVETO``, +# three for the ``LINETO``, and one for the ``CLOSEPOLY``. As indicated +# in the table above, the vertex for the closepoly is ignored, but we still +# need it to keep the codes aligned with the vertices:: # # nverts = nrects*(1+3+1) # verts = np.zeros((nverts, 2)) diff --git a/tutorials/advanced/transforms_tutorial.py b/tutorials/advanced/transforms_tutorial.py index 3fe230f3fd6a..26b936ef5b95 100644 --- a/tutorials/advanced/transforms_tutorial.py +++ b/tutorials/advanced/transforms_tutorial.py @@ -3,80 +3,100 @@ Transformations Tutorial ======================== -Like any graphics packages, Matplotlib is built on top of a -transformation framework to easily move between coordinate systems, -the userland *data* coordinate system, the *axes* coordinate system, -the *figure* coordinate system, and the *display* coordinate system. -In 95% of your plotting, you won't need to think about this, as it -happens under the hood, but as you push the limits of custom figure -generation, it helps to have an understanding of these objects so you -can reuse the existing transformations Matplotlib makes available to -you, or create your own (see :mod:`matplotlib.transforms`). The table -below summarizes the some useful coordinate systems, the transformation -object you should use to work in that coordinate system, and the -description of that system. In the ``Transformation Object`` column, -``ax`` is a :class:`~matplotlib.axes.Axes` instance, and ``fig`` is a -:class:`~matplotlib.figure.Figure` instance. - -+----------------+-----------------------------+-----------------------------------+ -|Coordinates |Transformation object |Description | -+================+=============================+===================================+ -|"data" |``ax.transData`` |The coordinate system for the data,| -| | |controlled by xlim and ylim. | -+----------------+-----------------------------+-----------------------------------+ -|"axes" |``ax.transAxes`` |The coordinate system of the | -| | |`~matplotlib.axes.Axes`; (0, 0) | -| | |is bottom left of the axes, and | -| | |(1, 1) is top right of the axes. | -+----------------+-----------------------------+-----------------------------------+ -|"subfigure" |``subfigure.transSubfigure`` |The coordinate system of the | -| | |`.SubFigure`; (0, 0) is bottom left| -| | |of the subfigure, and (1, 1) is top| -| | |right of the subfigure. If a | -| | |figure has no subfigures, this is | -| | |the same as ``transFigure``. | -+----------------+-----------------------------+-----------------------------------+ -|"figure" |``fig.transFigure`` |The coordinate system of the | -| | |`.Figure`; (0, 0) is bottom left | -| | |of the figure, and (1, 1) is top | -| | |right of the figure. | -+----------------+-----------------------------+-----------------------------------+ -|"figure-inches" |``fig.dpi_scale_trans`` |The coordinate system of the | -| | |`.Figure` in inches; (0, 0) is | -| | |bottom left of the figure, and | -| | |(width, height) is the top right | -| | |of the figure in inches. | -+----------------+-----------------------------+-----------------------------------+ -|"display" |``None``, or |The pixel coordinate system of the | -| |``IdentityTransform()`` |display window; (0, 0) is bottom | -| | |left of the window, and (width, | -| | |height) is top right of the | -| | |display window in pixels. | -+----------------+-----------------------------+-----------------------------------+ -|"xaxis", |``ax.get_xaxis_transform()``,|Blended coordinate systems; use | -|"yaxis" |``ax.get_yaxis_transform()`` |data coordinates on one of the axis| -| | |and axes coordinates on the other. | -+----------------+-----------------------------+-----------------------------------+ - -All of the transformation objects in the table above take inputs in -their coordinate system, and transform the input to the *display* -coordinate system. That is why the *display* coordinate system has -``None`` for the ``Transformation Object`` column -- it already is in -*display* coordinates. The transformations also know how to invert -themselves, to go from *display* back to the native coordinate system. -This is particularly useful when processing events from the user -interface, which typically occur in display space, and you want to -know where the mouse click or key-press occurred in your *data* -coordinate system. - -Note that specifying objects in *display* coordinates will change their -location if the ``dpi`` of the figure changes. This can cause confusion when -printing or changing screen resolution, because the object can change location -and size. Therefore it is most common -for artists placed in an axes or figure to have their transform set to -something *other* than the `~.transforms.IdentityTransform()`; the default when -an artist is placed on an axes using `~.axes.Axes.add_artist` is for the -transform to be ``ax.transData``. +Like any graphics packages, Matplotlib is built on top of a transformation +framework to easily move between coordinate systems, the userland *data* +coordinate system, the *axes* coordinate system, the *figure* coordinate +system, and the *display* coordinate system. In 95% of your plotting, you +won't need to think about this, as it happens under the hood, but as you push +the limits of custom figure generation, it helps to have an understanding of +these objects, so you can reuse the existing transformations Matplotlib makes +available to you, or create your own (see :mod:`matplotlib.transforms`). The +table below summarizes some useful coordinate systems, a description of each +system, and the transformation object for going from each coordinate system to +the *display* coordinates. In the "Transformation Object" column, ``ax`` is a +:class:`~matplotlib.axes.Axes` instance, ``fig`` is a +:class:`~matplotlib.figure.Figure` instance, and ``subfigure`` is a +:class:`~matplotlib.figure.SubFigure` instance. + + ++----------------+-----------------------------------+---------------------------------------------------+ +|Coordinate |Description |Transformation object | +|system | |from system to display | ++================+===================================+===================================================+ +|"data" |The coordinate system of the data |``ax.transData`` | +| |in the Axes. | | ++----------------+-----------------------------------+---------------------------------------------------+ +|"axes" |The coordinate system of the |``ax.transAxes`` | +| |`~matplotlib.axes.Axes`; (0, 0) | | +| |is bottom left of the axes, and | | +| |(1, 1) is top right of the axes. | | ++----------------+-----------------------------------+---------------------------------------------------+ +|"subfigure" |The coordinate system of the |``subfigure.transSubfigure`` | +| |`.SubFigure`; (0, 0) is bottom left| | +| |of the subfigure, and (1, 1) is top| | +| |right of the subfigure. If a | | +| |figure has no subfigures, this is | | +| |the same as ``transFigure``. | | ++----------------+-----------------------------------+---------------------------------------------------+ +|"figure" |The coordinate system of the |``fig.transFigure`` | +| |`.Figure`; (0, 0) is bottom left | | +| |of the figure, and (1, 1) is top | | +| |right of the figure. | | ++----------------+-----------------------------------+---------------------------------------------------+ +|"figure-inches" |The coordinate system of the |``fig.dpi_scale_trans`` | +| |`.Figure` in inches; (0, 0) is | | +| |bottom left of the figure, and | | +| |(width, height) is the top right | | +| |of the figure in inches. | | ++----------------+-----------------------------------+---------------------------------------------------+ +|"xaxis", |Blended coordinate systems, using |``ax.get_xaxis_transform()``, | +|"yaxis" |data coordinates on one direction |``ax.get_yaxis_transform()`` | +| |and axes coordinates on the other. | | ++----------------+-----------------------------------+---------------------------------------------------+ +|"display" |The native coordinate system of the|`None`, or | +| |output ; (0, 0) is the bottom left |:class:`~matplotlib.transforms.IdentityTransform()`| +| |of the window, and (width, height) | | +| |is top right of the output in | | +| |"display units". | | +| | | | +| |The exact interpretation of the | | +| |units depends on the back end. For | | +| |example it is pixels for Agg and | | +| |points for svg/pdf. | | ++----------------+-----------------------------------+---------------------------------------------------+ + + + + + +The `~matplotlib.transforms.Transform` objects are naive to the source and +destination coordinate systems, however the objects referred to in the table +above are constructed to take inputs in their coordinate system, and transform +the input to the *display* coordinate system. That is why the *display* +coordinate system has `None` for the "Transformation Object" column -- it +already is in *display* coordinates. The naming and destination conventions +are an aid to keeping track of the available "standard" coordinate systems and +transforms. + +The transformations also know how to invert themselves (via +`.Transform.inverted`) to generate a transform from output coordinate system +back to the input coordinate system. For example, ``ax.transData`` converts +values in data coordinates to display coordinates and +``ax.transData.inversed()`` is a :class:`matplotlib.transforms.Transform` that +goes from display coordinates to data coordinates. This is particularly useful +when processing events from the user interface, which typically occur in +display space, and you want to know where the mouse click or key-press occurred +in your *data* coordinate system. + +Note that specifying the position of Artists in *display* coordinates may +change their relative location if the ``dpi`` or size of the figure changes. +This can cause confusion when printing or changing screen resolution, because +the object can change location and size. Therefore, it is most common for +artists placed in an Axes or figure to have their transform set to something +*other* than the `~.transforms.IdentityTransform()`; the default when an artist +is added to an Axes using `~.axes.Axes.add_artist` is for the transform to be +``ax.transData`` so that you can work and think in *data* coordinates and let +Matplotlib take care of the transformation to *display*. .. _data-coords: @@ -89,6 +109,7 @@ :meth:`~matplotlib.axes.Axes.set_ylim` methods. For example, in the figure below, the data limits stretch from 0 to 10 on the x-axis, and -1 to 1 on the y-axis. + """ import numpy as np @@ -270,7 +291,7 @@ # coordinates is extremely useful, for example to create a horizontal # span which highlights some region of the y-data but spans across the # x-axis regardless of the data limits, pan or zoom level, etc. In fact -# these blended lines and spans are so useful, we have built in +# these blended lines and spans are so useful, we have built-in # functions to make them easy to plot (see # :meth:`~matplotlib.axes.Axes.axhline`, # :meth:`~matplotlib.axes.Axes.axvline`, @@ -409,7 +430,7 @@ # Another use of :class:`~matplotlib.transforms.ScaledTranslation` is to create # a new transformation that is # offset from another transformation, e.g., to place one object shifted a -# bit relative to another object. Typically you want the shift to be in +# bit relative to another object. Typically, you want the shift to be in # some physical dimension, like points or inches rather than in *data* # coordinates, so that the shift effect is constant at different zoom # levels and dpi settings. @@ -458,7 +479,7 @@ # a new transform with an added offset. So above we could have done:: # # shadow_transform = transforms.offset_copy(ax.transData, -# fig=fig, dx, dy, units='inches') +# fig, dx, dy, units='inches') # # # .. _transformation-pipeline: @@ -546,8 +567,9 @@ # the typical separable matplotlib Axes, with one additional piece # ``transProjection``:: # -# self.transData = self.transScale + self.transProjection + \ -# (self.transProjectionAffine + self.transAxes) +# self.transData = ( +# self.transScale + self.transShift + self.transProjection + +# (self.transProjectionAffine + self.transWedge + self.transAxes)) # # ``transProjection`` handles the projection from the space, # e.g., latitude and longitude for map data, or radius and theta for polar diff --git a/tutorials/colors/colorbar_only.py b/tutorials/colors/colorbar_only.py index daa44b752085..ce48ec50c26b 100644 --- a/tutorials/colors/colorbar_only.py +++ b/tutorials/colors/colorbar_only.py @@ -77,8 +77,7 @@ # `~.Figure.colorbar`. For the out-of-range values to display on the colorbar # without using the *extend* keyword with # `.colors.BoundaryNorm`, we have to use the *extend* keyword argument directly -# in the colorbar call, and supply an additional boundary on each end of the -# range. Here we also +# in the colorbar call. Here we also # use the spacing argument to make # the length of each colorbar segment proportional to its corresponding # interval. @@ -94,7 +93,6 @@ fig.colorbar( mpl.cm.ScalarMappable(cmap=cmap, norm=norm), cax=ax, - boundaries=[0] + bounds + [13], # Adding values for extensions. extend='both', ticks=bounds, spacing='proportional', @@ -121,7 +119,6 @@ fig.colorbar( mpl.cm.ScalarMappable(cmap=cmap, norm=norm), cax=ax, - boundaries=[-10] + bounds + [10], extend='both', extendfrac='auto', ticks=bounds, diff --git a/tutorials/colors/colormap-manipulation.py b/tutorials/colors/colormap-manipulation.py index bbd8cb8d2b2f..8b2cbc784bc0 100644 --- a/tutorials/colors/colormap-manipulation.py +++ b/tutorials/colors/colormap-manipulation.py @@ -4,7 +4,7 @@ ******************************** Matplotlib has a number of built-in colormaps accessible via -`.matplotlib.cm.get_cmap`. There are also external libraries like +`.matplotlib.colormaps`. There are also external libraries like palettable_ that have many extra colormaps. .. _palettable: https://jiffyclub.github.io/palettable/ @@ -24,19 +24,19 @@ ============================================ First, getting a named colormap, most of which are listed in -:doc:`/tutorials/colors/colormaps`, may be done using -`.matplotlib.cm.get_cmap`, which returns a colormap object. -The second argument gives the size of the list of colors used to define the -colormap, and below we use a modest value of 8 so there are not a lot of -values to look at. +:doc:`/tutorials/colors/colormaps`, may be done using `.matplotlib.colormaps`, +which returns a colormap object. The length of the list of colors used +internally to define the colormap can be adjusted via `.Colormap.resampled`. +Below we use a modest value of 8 so there are not a lot of values to look at. + """ import numpy as np import matplotlib.pyplot as plt -from matplotlib import cm +import matplotlib as mpl from matplotlib.colors import ListedColormap, LinearSegmentedColormap -viridis = cm.get_cmap('viridis', 8) +viridis = mpl.colormaps['viridis'].resampled(8) ############################################################################## # The object ``viridis`` is a callable, that when passed a float between @@ -72,7 +72,7 @@ # However, one may still call the colormap with an integer array, or with a # float array between 0 and 1. -copper = cm.get_cmap('copper', 8) +copper = mpl.colormaps['copper'].resampled(8) print('copper(range(8))', copper(range(8))) print('copper(np.linspace(0, 1, 8))', copper(np.linspace(0, 1, 8))) @@ -123,7 +123,7 @@ def plot_examples(colormaps): # For example, suppose we want to make the first 25 entries of a 256-length # "viridis" colormap pink for some reason: -viridis = cm.get_cmap('viridis', 256) +viridis = mpl.colormaps['viridis'].resampled(256) newcolors = viridis(np.linspace(0, 1, 256)) pink = np.array([248/256, 24/256, 148/256, 1]) newcolors[:25, :] = pink @@ -138,15 +138,15 @@ def plot_examples(colormaps): # values that were in the original colormap. This method does not interpolate # in color-space to add new colors. -viridis_big = cm.get_cmap('viridis') +viridis_big = mpl.colormaps['viridis'] newcmp = ListedColormap(viridis_big(np.linspace(0.25, 0.75, 128))) plot_examples([viridis, newcmp]) ############################################################################## # and we can easily concatenate two colormaps: -top = cm.get_cmap('Oranges_r', 128) -bottom = cm.get_cmap('Blues', 128) +top = mpl.colormaps['Oranges_r'].resampled(128) +bottom = mpl.colormaps['Blues'].resampled(128) newcolors = np.vstack((top(np.linspace(0, 1, 128)), bottom(np.linspace(0, 1, 128)))) @@ -255,6 +255,48 @@ def plot_linearmap(cdict): plot_examples([cmap1, cmap2]) +############################################################################# +# .. _reversing-colormap: +# +# Reversing a colormap +# ==================== +# +# `.Colormap.reversed` creates a new colormap that is a reversed version of +# the original colormap. + +colors = ["#ffffcc", "#a1dab4", "#41b6c4", "#2c7fb8", "#253494"] +my_cmap = ListedColormap(colors, name="my_cmap") + +my_cmap_r = my_cmap.reversed() + +plot_examples([my_cmap, my_cmap_r]) +# %% +# If no name is passed in, ``.reversed`` also names the copy by +# :ref:`appending '_r' ` to the original colormap's +# name. + +############################################################################## +# .. _registering-colormap: +# +# Registering a colormap +# ====================== +# +# Colormaps can be added to the `matplotlib.colormaps` list of named colormaps. +# This allows the colormaps to be accessed by name in plotting functions: + +# my_cmap, my_cmap_r from reversing a colormap +mpl.colormaps.register(cmap=my_cmap) +mpl.colormaps.register(cmap=my_cmap_r) + +data = [[1, 2, 3, 4, 5]] + +fig, (ax1, ax2) = plt.subplots(nrows=2) + +ax1.imshow(data, cmap='my_cmap') +ax2.imshow(data, cmap='my_cmap_r') + +plt.show() + ############################################################################# # # .. admonition:: References @@ -268,4 +310,4 @@ def plot_linearmap(cdict): # - `matplotlib.colors.LinearSegmentedColormap` # - `matplotlib.colors.ListedColormap` # - `matplotlib.cm` -# - `matplotlib.cm.get_cmap` +# - `matplotlib.colormaps` diff --git a/tutorials/colors/colormapnorms.py b/tutorials/colors/colormapnorms.py index ae3ac8825602..e7ca0e61bfc7 100644 --- a/tutorials/colors/colormapnorms.py +++ b/tutorials/colors/colormapnorms.py @@ -20,14 +20,12 @@ Artists that map data to color pass the arguments *vmin* and *vmax* to construct a :func:`matplotlib.colors.Normalize` instance, then call it: -.. ipython:: +.. code-block:: pycon - In [1]: import matplotlib as mpl - - In [2]: norm = mpl.colors.Normalize(vmin=-1, vmax=1) - - In [3]: norm(0) - Out[3]: 0.5 + >>> import matplotlib as mpl + >>> norm = mpl.colors.Normalize(vmin=-1, vmax=1) + >>> norm(0) + 0.5 However, there are sometimes cases where it is useful to map data to colormaps in a non-linear fashion. @@ -53,7 +51,7 @@ X, Y = np.mgrid[-3:3:complex(0, N), -2:2:complex(0, N)] # A low hump with a spike coming out of the top right. Needs to have -# z/colour axis on a log scale so we see both hump and spike. linear +# z/colour axis on a log scale, so we see both hump and spike. A linear # scale only shows the spike. Z1 = np.exp(-X**2 - Y**2) Z2 = np.exp(-(X * 10)**2 - (Y * 10)**2) @@ -192,15 +190,12 @@ # lower out-of-bounds values to the range over which the colors are # distributed. For instance: # -# .. ipython:: -# -# In [2]: import matplotlib.colors as colors -# -# In [3]: bounds = np.array([-0.25, -0.125, 0, 0.5, 1]) -# -# In [4]: norm = colors.BoundaryNorm(boundaries=bounds, ncolors=4) +# .. code-block:: pycon # -# In [5]: print(norm([-0.2, -0.15, -0.02, 0.3, 0.8, 0.99])) +# >>> import matplotlib.colors as colors +# >>> bounds = np.array([-0.25, -0.125, 0, 0.5, 1]) +# >>> norm = colors.BoundaryNorm(boundaries=bounds, ncolors=4) +# >>> print(norm([-0.2, -0.15, -0.02, 0.3, 0.8, 0.99])) # [0 0 1 2 3 3] # # Note: Unlike the other norms, this norm returns values from 0 to *ncolors*-1. @@ -273,7 +268,7 @@ pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=divnorm, cmap=terrain_map, shading='auto') -# Simple geographic plot, set aspect ratio beecause distance between lines of +# Simple geographic plot, set aspect ratio because distance between lines of # longitude depends on latitude. ax.set_aspect(1 / np.cos(np.deg2rad(49))) ax.set_title('TwoSlopeNorm(x)') diff --git a/tutorials/colors/colormaps.py b/tutorials/colors/colormaps.py index 97cf8b8f28b2..68862d834f68 100644 --- a/tutorials/colors/colormaps.py +++ b/tutorials/colors/colormaps.py @@ -4,7 +4,7 @@ ******************************** Matplotlib has a number of built-in colormaps accessible via -`.matplotlib.cm.get_cmap`. There are also external libraries that +`.matplotlib.colormaps`. There are also external libraries that have many extra colormaps, which can be viewed in the `Third-party colormaps`_ section of the Matplotlib documentation. Here we briefly discuss how to choose between the many options. For @@ -80,7 +80,6 @@ import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt -from matplotlib import cm from colorspacious import cspace_converter @@ -105,7 +104,7 @@ def plot_color_gradients(category, cmap_list): axs[0].set_title(f'{category} colormaps', fontsize=14) for ax, name in zip(axs, cmap_list): - ax.imshow(gradient, aspect='auto', cmap=plt.get_cmap(name)) + ax.imshow(gradient, aspect='auto', cmap=mpl.colormaps[name]) ax.text(-0.01, 0.5, name, va='center', ha='right', fontsize=10, transform=ax.transAxes) @@ -277,7 +276,7 @@ def plot_color_gradients(category, cmap_list): # Get RGB values for colormap and convert the colormap in # CAM02-UCS colorspace. lab[0, :, 0] is the lightness. - rgb = cm.get_cmap(cmap)(x)[np.newaxis, :, :3] + rgb = mpl.colormaps[cmap](x)[np.newaxis, :, :3] lab = cspace_converter("sRGB1", "CAM02-UCS")(rgb) # Plot colormap L values. Do separately for each category @@ -286,7 +285,7 @@ def plot_color_gradients(category, cmap_list): # https://stackoverflow.com/q/8202605/ if cmap_category == 'Sequential': - # These colormaps all start at high lightness but we want them + # These colormaps all start at high lightness, but we want them # reversed to look nice in the plot, so reverse the order. y_ = lab[0, ::-1, 0] c_ = x[::-1] @@ -380,14 +379,14 @@ def plot_color_gradients(cmap_category, cmap_list): for ax, name in zip(axs, cmap_list): # Get RGB values for colormap. - rgb = cm.get_cmap(plt.get_cmap(name))(x)[np.newaxis, :, :3] + rgb = mpl.colormaps[name](x)[np.newaxis, :, :3] # Get colormap in CAM02-UCS colorspace. We want the lightness. lab = cspace_converter("sRGB1", "CAM02-UCS")(rgb) L = lab[0, :, 0] L = np.float32(np.vstack((L, L, L))) - ax[0].imshow(gradient, aspect='auto', cmap=plt.get_cmap(name)) + ax[0].imshow(gradient, aspect='auto', cmap=mpl.colormaps[name]) ax[1].imshow(L, aspect='auto', cmap='binary_r', vmin=0., vmax=100.) pos = list(ax[0].get_position().bounds) x_text = pos[0] - 0.01 diff --git a/tutorials/colors/colors.py b/tutorials/colors/colors.py index f5e36cfc9052..224886c20483 100644 --- a/tutorials/colors/colors.py +++ b/tutorials/colors/colors.py @@ -1,8 +1,11 @@ """ ***************** -Specifying Colors +Specifying colors ***************** +Color formats +============= + Matplotlib recognizes the following formats to specify a color. +--------------------------------------+--------------------------------------+ @@ -74,45 +77,57 @@ "Red", "Green", and "Blue" are the intensities of those colors. In combination, they represent the colorspace. -Matplotlib draws Artists based on the ``zorder`` parameter. If there are no -specified values, Matplotlib defaults to the order of the Artists added to the -Axes. - -The alpha for an Artist controls opacity. It indicates how the RGB color of the -new Artist combines with RGB colors already on the Axes. +Transparency +============ -The two Artists combine with alpha compositing. Matplotlib uses the equation -below to compute the result of blending a new Artist. +The *alpha* value of a color specifies its transparency, where 0 is fully +transparent and 1 is fully opaque. When a color is semi-transparent, the +background color will show through. -:: +The *alpha* value determines the resulting color by blending the +foreground color with the background color according to the formula - RGB_{new} = RGB_{below} * (1 - \\alpha) + RGB_{artist} * \\alpha +.. math:: -Alpha of 1 indicates the new Artist completely covers the previous color. -Alpha of 0 for top color is not visible; however, it contributes to blending -for intermediate values as the cumulative result of all previous Artists. The -following table contains examples. + RGB_{result} = RGB_{background} * (1 - \\alpha) + RGB_{foreground} * \\alpha -+---------------+-------------------------------------------------------------+ -| Alpha value | Visual | -+===============+=============================================================+ -| ``0.3`` | .. image:: ../../_static/color_zorder_A.png | -+---------------+-------------------------------------------------------------+ -| ``1`` | .. image:: ../../_static/color_zorder_B.png | -+---------------+-------------------------------------------------------------+ - -.. note:: +The following plot illustrates the effect of transparency. +""" - Re-ordering Artists is not commutative in Matplotlib. +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle +import numpy as np +fig, ax = plt.subplots(figsize=(6.5, 1.65), layout='constrained') +ax.add_patch(Rectangle((-0.2, -0.35), 11.2, 0.7, color='C1', alpha=0.8)) +for i, alpha in enumerate(np.linspace(0, 1, 11)): + ax.add_patch(Rectangle((i, 0.05), 0.8, 0.6, alpha=alpha, zorder=0)) + ax.text(i+0.4, 0.85, f"{alpha:.1f}", ha='center') + ax.add_patch(Rectangle((i, -0.05), 0.8, -0.6, alpha=alpha, zorder=2)) +ax.set_xlim(-0.2, 13) +ax.set_ylim(-1, 1) +ax.set_title('alpha values') +ax.text(11.3, 0.6, 'zorder=1', va='center', color='C0') +ax.text(11.3, 0, 'zorder=2\nalpha=0.8', va='center', color='C1') +ax.text(11.3, -0.6, 'zorder=3', va='center', color='C0') +ax.axis('off') -"CN" color selection --------------------- -Matplotlib converts "CN" colors to RGBA when drawing Artists. The -:doc:`/tutorials/intermediate/color_cycle` section contains additional -information about controlling colors and style properties. -""" +############################################################################### +# +# The orange rectangle is semi-transparent with *alpha* = 0.8. The top row of +# blue squares is drawn below and the bottom row of blue squares is drawn on +# top of the orange rectangle. +# +# See also :doc:`/gallery/misc/zorder_demo` to learn more on the drawing order. +# +# +# "CN" color selection +# ==================== +# +# Matplotlib converts "CN" colors to RGBA when drawing Artists. The +# :doc:`/tutorials/intermediate/color_cycle` section contains additional +# information about controlling colors and style properties. import numpy as np @@ -134,7 +149,7 @@ def demo(sty): demo('default') -demo('seaborn') +demo('seaborn-v0_8') ############################################################################### # The first color ``'C0'`` is the title. Each plot uses the second and third @@ -144,7 +159,7 @@ def demo(sty): # .. _xkcd-colors: # # Comparison between X11/CSS4 and xkcd colors -# ------------------------------------------- +# =========================================== # # The xkcd colors come from a `user survey conducted by the webcomic xkcd # `__. diff --git a/tutorials/intermediate/arranging_axes.py b/tutorials/intermediate/arranging_axes.py index 90636b6960aa..3fbfb7b6e239 100644 --- a/tutorials/intermediate/arranging_axes.py +++ b/tutorials/intermediate/arranging_axes.py @@ -1,7 +1,7 @@ """ -===================================== +=================================== Arranging multiple Axes in a Figure -===================================== +=================================== Often more than one Axes is wanted on a figure at a time, usually organized into a regular grid. Matplotlib has a variety of tools for @@ -32,12 +32,13 @@ `~matplotlib.pyplot.subplot_mosaic` A simple way to create figures and a grid of Axes, with the added - flexibility that Axes can also span rows or columns. The Axes - are returned in a labelled dictionary instead of an array. See also - `.Figure.subplot_mosaic` and :doc:`/tutorials/provisional/mosaic`. + flexibility that Axes can also span rows or columns. The Axes are returned + in a labelled dictionary instead of an array. See also + `.Figure.subplot_mosaic` and + :doc:`/gallery/subplots_axes_and_figures/mosaic`. Sometimes it is natural to have more than one distinct group of Axes grids, -in which case Matplotlib has the concept of `~.figure.SubFigure`: +in which case Matplotlib has the concept of `.SubFigure`: `~matplotlib.figure.SubFigure` A virtual figure within a figure. @@ -100,7 +101,7 @@ import numpy as np fig, axs = plt.subplots(ncols=2, nrows=2, figsize=(5.5, 3.5), - constrained_layout=True) + layout="constrained") # add an artist, in this case a nice label in the middle... for row in range(2): for col in range(2): @@ -111,7 +112,7 @@ fig.suptitle('plt.subplots()') ############################################################################## -# We will annotate a lot of Axes, so lets encapsulate the annotation, rather +# We will annotate a lot of Axes, so let's encapsulate the annotation, rather # than having that large piece of annotation code every time we need it: @@ -129,11 +130,41 @@ def annotate_axes(ax, text, fontsize=18): fig, axd = plt.subplot_mosaic([['upper left', 'upper right'], ['lower left', 'lower right']], - figsize=(5.5, 3.5), constrained_layout=True) + figsize=(5.5, 3.5), layout="constrained") for k in axd: annotate_axes(axd[k], f'axd["{k}"]', fontsize=14) fig.suptitle('plt.subplot_mosaic()') +############################################################################# +# +# Grids of fixed-aspect ratio Axes +# -------------------------------- +# +# Fixed-aspect ratio axes are common for images or maps. However, they +# present a challenge to layout because two sets of constraints are being +# imposed on the size of the Axes - that they fit in the figure and that they +# have a set aspect ratio. This leads to large gaps between Axes by default: +# + +fig, axs = plt.subplots(2, 2, layout="constrained", figsize=(5.5, 3.5)) +for ax in axs.flat: + ax.set_aspect(1) +fig.suptitle('Fixed aspect Axes') + +############################################################################ +# One way to address this is to change the aspect of the figure to be close +# to the aspect ratio of the Axes, however that requires trial and error. +# Matplotlib also supplies ``layout="compressed"``, which will work with +# simple grids to reduce the gaps between Axes. (The ``mpl_toolkits`` also +# provides `~.mpl_toolkits.axes_grid1.axes_grid.ImageGrid` to accomplish +# a similar effect, but with a non-standard Axes class). + +fig, axs = plt.subplots(2, 2, layout="compressed", figsize=(5.5, 3.5)) +for ax in axs.flat: + ax.set_aspect(1) +fig.suptitle('Fixed aspect Axes: compressed') + + ############################################################################ # Axes spanning rows or columns in a grid # --------------------------------------- @@ -145,7 +176,7 @@ def annotate_axes(ax, text, fontsize=18): fig, axd = plt.subplot_mosaic([['upper left', 'right'], ['lower left', 'right']], - figsize=(5.5, 3.5), constrained_layout=True) + figsize=(5.5, 3.5), layout="constrained") for k in axd: annotate_axes(axd[k], f'axd["{k}"]', fontsize=14) fig.suptitle('plt.subplot_mosaic()') @@ -168,7 +199,7 @@ def annotate_axes(ax, text, fontsize=18): fig, axd = plt.subplot_mosaic([['upper left', 'right'], ['lower left', 'right']], gridspec_kw=gs_kw, figsize=(5.5, 3.5), - constrained_layout=True) + layout="constrained") for k in axd: annotate_axes(axd[k], f'axd["{k}"]', fontsize=14) fig.suptitle('plt.subplot_mosaic()') @@ -184,7 +215,7 @@ def annotate_axes(ax, text, fontsize=18): # necessarily aligned. See below for a more verbose way to achieve the same # effect with `~.gridspec.GridSpecFromSubplotSpec`. -fig = plt.figure(constrained_layout=True) +fig = plt.figure(layout="constrained") subfigs = fig.subfigures(1, 2, wspace=0.07, width_ratios=[1.5, 1.]) axs0 = subfigs[0].subplots(2, 2) subfigs[0].set_facecolor('0.9') @@ -207,7 +238,7 @@ def annotate_axes(ax, text, fontsize=18): outer = [['upper left', inner], ['lower left', 'lower right']] -fig, axd = plt.subplot_mosaic(outer, constrained_layout=True) +fig, axd = plt.subplot_mosaic(outer, layout="constrained") for k in axd: annotate_axes(axd[k], f'axd["{k}"]') @@ -227,10 +258,10 @@ def annotate_axes(ax, text, fontsize=18): # Basic 2x2 grid # -------------- # -# We can accopmplish a 2x2 grid in the same manner as +# We can accomplish a 2x2 grid in the same manner as # ``plt.subplots(2, 2)``: -fig = plt.figure(figsize=(5.5, 3.5), constrained_layout=True) +fig = plt.figure(figsize=(5.5, 3.5), layout="constrained") spec = fig.add_gridspec(ncols=2, nrows=2) ax0 = fig.add_subplot(spec[0, 0]) @@ -256,7 +287,7 @@ def annotate_axes(ax, text, fontsize=18): # and the new Axes will span the slice. This would be the same # as ``fig, axd = plt.subplot_mosaic([['ax0', 'ax0'], ['ax1', 'ax2']], ...)``: -fig = plt.figure(figsize=(5.5, 3.5), constrained_layout=True) +fig = plt.figure(figsize=(5.5, 3.5), layout="constrained") spec = fig.add_gridspec(2, 2) ax0 = fig.add_subplot(spec[0, :]) @@ -284,7 +315,7 @@ def annotate_axes(ax, text, fontsize=18): # These spacing parameters can also be passed to `~.pyplot.subplots` and # `~.pyplot.subplot_mosaic` as the *gridspec_kw* argument. -fig = plt.figure(constrained_layout=False, facecolor='0.9') +fig = plt.figure(layout=None, facecolor='0.9') gs = fig.add_gridspec(nrows=3, ncols=3, left=0.05, right=0.75, hspace=0.1, wspace=0.05) ax0 = fig.add_subplot(gs[:-1, :]) @@ -306,7 +337,7 @@ def annotate_axes(ax, text, fontsize=18): # Note this is also available from the more verbose # `.gridspec.GridSpecFromSubplotSpec`. -fig = plt.figure(constrained_layout=True) +fig = plt.figure(layout="constrained") gs0 = fig.add_gridspec(1, 2) gs00 = gs0[0].subgridspec(2, 2) @@ -363,7 +394,8 @@ def squiggle_xy(a, b, c, d, i=np.arange(0.0, 2*np.pi, 0.05)): # More reading # ============ # -# - More details about :doc:`subplot mosaic `. +# - More details about :doc:`subplot mosaic +# `. # - More details about :doc:`constrained layout # `, used to align # spacing in most of these examples. diff --git a/tutorials/intermediate/artists.py b/tutorials/intermediate/artists.py index 16c4566071f2..1a186ccb4222 100644 --- a/tutorials/intermediate/artists.py +++ b/tutorials/intermediate/artists.py @@ -29,8 +29,8 @@ the containers are places to put them (:class:`~matplotlib.axis.Axis`, :class:`~matplotlib.axes.Axes` and :class:`~matplotlib.figure.Figure`). The standard use is to create a :class:`~matplotlib.figure.Figure` instance, use -the ``Figure`` to create one or more :class:`~matplotlib.axes.Axes` or -:class:`~matplotlib.axes.Subplot` instances, and use the ``Axes`` instance +the ``Figure`` to create one or more :class:`~matplotlib.axes.Axes` +instances, and use the ``Axes`` instance helper methods to create the primitives. In the example below, we create a ``Figure`` instance using :func:`matplotlib.pyplot.figure`, which is a convenience method for instantiating ``Figure`` instances and connecting them @@ -59,10 +59,7 @@ class in the Matplotlib API, and the one you will be working with most :class:`~matplotlib.image.AxesImage`, respectively). These helper methods will take your data (e.g., ``numpy`` arrays and strings) and create primitive ``Artist`` instances as needed (e.g., ``Line2D``), add them to -the relevant containers, and draw them when requested. Most of you -are probably familiar with the :class:`~matplotlib.axes.Subplot`, -which is just a special case of an ``Axes`` that lives on a regular -rows by columns grid of ``Subplot`` instances. If you want to create +the relevant containers, and draw them when requested. If you want to create an ``Axes`` at an arbitrary location, simply use the :meth:`~matplotlib.figure.Figure.add_axes` method which takes a list of ``[left, bottom, width, height]`` values in 0-1 relative figure @@ -79,8 +76,8 @@ class in the Matplotlib API, and the one you will be working with most line, = ax.plot(t, s, color='blue', lw=2) In this example, ``ax`` is the ``Axes`` instance created by the -``fig.add_subplot`` call above (remember ``Subplot`` is just a subclass of -``Axes``) and when you call ``ax.plot``, it creates a ``Line2D`` instance and +``fig.add_subplot`` call above and when you call ``ax.plot``, it creates a +``Line2D`` instance and adds it to the ``Axes``. In the interactive `IPython `_ session below, you can see that the ``Axes.lines`` list is length one and contains the same line that was returned by the ``line, = ax.plot...`` call: @@ -115,6 +112,7 @@ class in the Matplotlib API, and the one you will be working with most Try creating the figure below. """ +# sphinx_gallery_capture_repr = ('__repr__',) import numpy as np import matplotlib.pyplot as plt @@ -122,8 +120,8 @@ class in the Matplotlib API, and the one you will be working with most fig = plt.figure() fig.subplots_adjust(top=0.8) ax1 = fig.add_subplot(211) -ax1.set_ylabel('volts') -ax1.set_title('a sine wave') +ax1.set_ylabel('Voltage [V]') +ax1.set_title('A sine wave') t = np.arange(0.0, 1.0, 0.01) s = np.sin(2*np.pi*t) @@ -135,7 +133,7 @@ class in the Matplotlib API, and the one you will be working with most ax2 = fig.add_axes([0.15, 0.1, 0.7, 0.3]) n, bins, patches = ax2.hist(np.random.randn(1000), 50, facecolor='yellow', edgecolor='yellow') -ax2.set_xlabel('time (s)') +ax2.set_xlabel('Time [s]') plt.show() @@ -298,10 +296,10 @@ class in the Matplotlib API, and the one you will be working with most # In [158]: ax2 = fig.add_axes([0.1, 0.1, 0.7, 0.3]) # # In [159]: ax1 -# Out[159]: +# Out[159]: # # In [160]: print(fig.axes) -# [, ] +# [, ] # # Because the figure maintains the concept of the "current Axes" (see # :meth:`Figure.gca ` and @@ -348,12 +346,12 @@ class in the Matplotlib API, and the one you will be working with most # ================ ============================================================ # Figure attribute Description # ================ ============================================================ -# axes A list of `~.axes.Axes` instances (includes Subplot) +# axes A list of `~.axes.Axes` instances # patch The `.Rectangle` background # images A list of `.FigureImage` patches - # useful for raw pixel display # legends A list of Figure `.Legend` instances -# (different from ``Axes.legends``) +# (different from ``Axes.get_legend()``) # lines A list of Figure `.Line2D` instances # (rarely used, see ``Axes.lines``) # patches A list of Figure `.Patch`\s @@ -543,7 +541,7 @@ class in the Matplotlib API, and the one you will be working with most # `~.axes.Axes.fill` - shared area `.Polygon` ax.patches # `~.axes.Axes.hist` - histograms `.Rectangle` ax.patches # `~.axes.Axes.imshow` - image data `.AxesImage` ax.images -# `~.axes.Axes.legend` - Axes legends `.Legend` ax.legends +# `~.axes.Axes.legend` - Axes legend `.Legend` ax.get_legend() # `~.axes.Axes.plot` - xy plots `.Line2D` ax.lines # `~.axes.Axes.scatter` - scatter charts `.PolyCollection` ax.collections # `~.axes.Axes.text` - text `.Text` ax.texts @@ -558,31 +556,31 @@ class in the Matplotlib API, and the one you will be working with most # :attr:`~matplotlib.axes.Axes.yaxis`. The ``XAxis`` and ``YAxis`` # containers will be detailed below, but note that the ``Axes`` contains # many helper methods which forward calls on to the -# :class:`~matplotlib.axis.Axis` instances so you often do not need to +# :class:`~matplotlib.axis.Axis` instances, so you often do not need to # work with them directly unless you want to. For example, you can set # the font color of the ``XAxis`` ticklabels using the ``Axes`` helper # method:: # -# for label in ax.get_xticklabels(): -# label.set_color('orange') +# ax.tick_params(axis='x', labelcolor='orange') # -# Below is a summary of the Artists that the Axes contains +# Below is a summary of the Artists that the `~.axes.Axes` contains # # ============== ========================================= # Axes attribute Description # ============== ========================================= -# artists A list of `.Artist` instances +# artists An `.ArtistList` of `.Artist` instances # patch `.Rectangle` instance for Axes background -# collections A list of `.Collection` instances -# images A list of `.AxesImage` -# legends A list of `.Legend` instances -# lines A list of `.Line2D` instances -# patches A list of `.Patch` instances -# texts A list of `.Text` instances +# collections An `.ArtistList` of `.Collection` instances +# images An `.ArtistList` of `.AxesImage` +# lines An `.ArtistList` of `.Line2D` instances +# patches An `.ArtistList` of `.Patch` instances +# texts An `.ArtistList` of `.Text` instances # xaxis A `matplotlib.axis.XAxis` instance # yaxis A `matplotlib.axis.YAxis` instance # ============== ========================================= # +# The legend can be accessed by `~.axes.Axes.get_legend`, +# # .. _axis-container: # # Axis containers @@ -718,6 +716,6 @@ class in the Matplotlib API, and the one you will be working with most # dollar signs and colors them green on the right side of the yaxis. # # -# .. include:: ../../gallery/pyplots/dollar_ticks.rst -# :start-after: y axis labels. +# .. include:: ../../gallery/ticks/dollar_ticks.rst +# :start-after: .. redirect-from:: /gallery/pyplots/dollar_ticks # :end-before: .. admonition:: References diff --git a/tutorials/intermediate/autoscale.py b/tutorials/intermediate/autoscale.py index b585eb851113..3b563510aa1f 100644 --- a/tutorials/intermediate/autoscale.py +++ b/tutorials/intermediate/autoscale.py @@ -26,7 +26,7 @@ # ------- # The default margin around the data limits is 5%: -ax.margins() +print(ax.margins()) ############################################################################### # The margins can be made larger using `~matplotlib.axes.Axes.margins`: @@ -164,9 +164,9 @@ fig, ax = plt.subplots() collection = mpl.collections.StarPolygonCollection( - 5, 0, [250, ], # five point star, zero angle, size 250px + 5, rotation=0, sizes=(250,), # five point star, zero angle, size 250px offsets=np.column_stack([x, y]), # Set the positions - transOffset=ax.transData, # Propagate transformations of the Axes + offset_transform=ax.transData, # Propagate transformations of the Axes ) ax.add_collection(collection) ax.autoscale_view() diff --git a/tutorials/intermediate/constrainedlayout_guide.py b/tutorials/intermediate/constrainedlayout_guide.py index 7b296f99305d..8852f4067ff4 100644 --- a/tutorials/intermediate/constrainedlayout_guide.py +++ b/tutorials/intermediate/constrainedlayout_guide.py @@ -14,13 +14,13 @@ but uses a constraint solver to determine the size of axes that allows them to fit. -*constrained_layout* needs to be activated before any axes are added to -a figure. Two ways of doing so are +*constrained_layout* typically needs to be activated before any axes are +added to a figure. Two ways of doing so are * using the respective argument to :func:`~.pyplot.subplots` or :func:`~.pyplot.figure`, e.g.:: - plt.subplots(constrained_layout=True) + plt.subplots(layout="constrained") * activate it via :ref:`rcParams`, like:: @@ -29,15 +29,6 @@ Those are described in detail throughout the following sections. -.. warning:: - - Currently Constrained Layout is **experimental**. The - behaviour and API are subject to change, or the whole functionality - may be removed without a deprecation period. If you *require* your - plots to be absolutely reproducible, get the Axes positions after - running Constrained Layout and use ``ax.set_position()`` in your code - with ``constrained_layout=False``. - Simple Example ============== @@ -72,32 +63,32 @@ def example_plot(ax, fontsize=12, hide_labels=False): ax.set_ylabel('y-label', fontsize=fontsize) ax.set_title('Title', fontsize=fontsize) -fig, ax = plt.subplots(constrained_layout=False) +fig, ax = plt.subplots(layout=None) example_plot(ax, fontsize=24) ############################################################################### # To prevent this, the location of axes needs to be adjusted. For # subplots, this can be done manually by adjusting the subplot parameters # using `.Figure.subplots_adjust`. However, specifying your figure with the -# # ``constrained_layout=True`` keyword argument will do the adjusting +# # ``layout="constrained"`` keyword argument will do the adjusting # # automatically. -fig, ax = plt.subplots(constrained_layout=True) +fig, ax = plt.subplots(layout="constrained") example_plot(ax, fontsize=24) ############################################################################### # When you have multiple subplots, often you see labels of different # axes overlapping each other. -fig, axs = plt.subplots(2, 2, constrained_layout=False) +fig, axs = plt.subplots(2, 2, layout=None) for ax in axs.flat: example_plot(ax) ############################################################################### -# Specifying ``constrained_layout=True`` in the call to ``plt.subplots`` +# Specifying ``layout="constrained"`` in the call to ``plt.subplots`` # causes the layout to be properly constrained. -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: example_plot(ax) @@ -122,7 +113,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): norm = mcolors.Normalize(vmin=0., vmax=100.) # see note above: this makes all pcolormesh calls consistent: pc_kwargs = {'rasterized': True, 'cmap': 'viridis', 'norm': norm} -fig, ax = plt.subplots(figsize=(4, 4), constrained_layout=True) +fig, ax = plt.subplots(figsize=(4, 4), layout="constrained") im = ax.pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=ax, shrink=0.6) @@ -131,7 +122,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): # ``ax`` argument of ``colorbar``, constrained_layout will take space from # the specified axes. -fig, axs = plt.subplots(2, 2, figsize=(4, 4), constrained_layout=True) +fig, axs = plt.subplots(2, 2, figsize=(4, 4), layout="constrained") for ax in axs.flat: im = ax.pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=axs, shrink=0.6) @@ -141,7 +132,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): # will steal space appropriately, and leave a gap, but all subplots will # still be the same size. -fig, axs = plt.subplots(3, 3, figsize=(4, 4), constrained_layout=True) +fig, axs = plt.subplots(3, 3, figsize=(4, 4), layout="constrained") for ax in axs.flat: im = ax.pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=axs[1:, ][:, 1], shrink=0.8) @@ -151,9 +142,9 @@ def example_plot(ax, fontsize=12, hide_labels=False): # Suptitle # ========= # -# ``constrained_layout`` can also make room for `~.figure.Figure.suptitle`. +# ``constrained_layout`` can also make room for `~.Figure.suptitle`. -fig, axs = plt.subplots(2, 2, figsize=(4, 4), constrained_layout=True) +fig, axs = plt.subplots(2, 2, figsize=(4, 4), layout="constrained") for ax in axs.flat: im = ax.pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=axs, shrink=0.6) @@ -168,14 +159,14 @@ def example_plot(ax, fontsize=12, hide_labels=False): # However, constrained-layout does *not* handle legends being created via # :meth:`.Figure.legend` (yet). -fig, ax = plt.subplots(constrained_layout=True) +fig, ax = plt.subplots(layout="constrained") ax.plot(np.arange(10), label='This is a plot') ax.legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) ############################################# # However, this will steal space from a subplot layout: -fig, axs = plt.subplots(1, 2, figsize=(4, 2), constrained_layout=True) +fig, axs = plt.subplots(1, 2, figsize=(4, 2), layout="constrained") axs[0].plot(np.arange(10)) axs[1].plot(np.arange(10), label='This is a plot') axs[1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5)) @@ -191,7 +182,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): # trigger a draw if we want constrained_layout to adjust the size # of the axes before printing. -fig, axs = plt.subplots(1, 2, figsize=(4, 2), constrained_layout=True) +fig, axs = plt.subplots(1, 2, figsize=(4, 2), layout="constrained") axs[0].plot(np.arange(10)) axs[1].plot(np.arange(10), label='This is a plot') @@ -203,9 +194,14 @@ def example_plot(ax, fontsize=12, hide_labels=False): # we want the legend included in the bbox_inches='tight' calcs. leg.set_in_layout(True) # we don't want the layout to change at this point. -fig.set_constrained_layout(False) -fig.savefig('../../doc/_static/constrained_layout_1b.png', - bbox_inches='tight', dpi=100) +fig.set_layout_engine(None) +try: + fig.savefig('../../doc/_static/constrained_layout_1b.png', + bbox_inches='tight', dpi=100) +except FileNotFoundError: + # this allows the script to keep going if run interactively and + # the directory above doesn't exist + pass ############################################# # The saved file looks like: @@ -215,14 +211,20 @@ def example_plot(ax, fontsize=12, hide_labels=False): # # A better way to get around this awkwardness is to simply # use the legend method provided by `.Figure.legend`: -fig, axs = plt.subplots(1, 2, figsize=(4, 2), constrained_layout=True) +fig, axs = plt.subplots(1, 2, figsize=(4, 2), layout="constrained") axs[0].plot(np.arange(10)) lines = axs[1].plot(np.arange(10), label='This is a plot') labels = [l.get_label() for l in lines] leg = fig.legend(lines, labels, loc='center left', bbox_to_anchor=(0.8, 0.5), bbox_transform=axs[1].transAxes) -fig.savefig('../../doc/_static/constrained_layout_2b.png', - bbox_inches='tight', dpi=100) +try: + fig.savefig('../../doc/_static/constrained_layout_2b.png', + bbox_inches='tight', dpi=100) +except FileNotFoundError: + # this allows the script to keep going if run interactively and + # the directory above doesn't exist + pass + ############################################# # The saved file looks like: @@ -237,13 +239,14 @@ def example_plot(ax, fontsize=12, hide_labels=False): # # Padding between axes is controlled in the horizontal by *w_pad* and # *wspace*, and vertical by *h_pad* and *hspace*. These can be edited -# via `~.figure.Figure.set_constrained_layout_pads`. *w/h_pad* are +# via `~.layout_engine.ConstrainedLayoutEngine.set`. *w/h_pad* are # the minimum space around the axes in units of inches: -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: example_plot(ax, hide_labels=True) -fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0, wspace=0) +fig.get_layout_engine().set(w_pad=4 / 72, h_pad=4 / 72, hspace=0, + wspace=0) ########################################## # Spacing between subplots is further set by *wspace* and *hspace*. These @@ -252,36 +255,35 @@ def example_plot(ax, fontsize=12, hide_labels=False): # used instead. Note in the below how the space at the edges doesn't change # from the above, but the space between subplots does. -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: example_plot(ax, hide_labels=True) -fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.2, - wspace=0.2) +fig.get_layout_engine().set(w_pad=4 / 72, h_pad=4 / 72, hspace=0.2, + wspace=0.2) ########################################## # If there are more than two columns, the *wspace* is shared between them, -# so here the wspace is divided in 2, with a *wspace* of 0.1 between each +# so here the wspace is divided in two, with a *wspace* of 0.1 between each # column: -fig, axs = plt.subplots(2, 3, constrained_layout=True) +fig, axs = plt.subplots(2, 3, layout="constrained") for ax in axs.flat: example_plot(ax, hide_labels=True) -fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.2, - wspace=0.2) +fig.get_layout_engine().set(w_pad=4 / 72, h_pad=4 / 72, hspace=0.2, + wspace=0.2) ########################################## # GridSpecs also have optional *hspace* and *wspace* keyword arguments, # that will be used instead of the pads set by ``constrained_layout``: -fig, axs = plt.subplots(2, 2, constrained_layout=True, +fig, axs = plt.subplots(2, 2, layout="constrained", gridspec_kw={'wspace': 0.3, 'hspace': 0.2}) for ax in axs.flat: example_plot(ax, hide_labels=True) # this has no effect because the space set in the gridspec trumps the # space set in constrained_layout. -fig.set_constrained_layout_pads(w_pad=4 / 72, h_pad=4 / 72, hspace=0.0, - wspace=0.0) -plt.show() +fig.get_layout_engine().set(w_pad=4 / 72, h_pad=4 / 72, hspace=0.0, + wspace=0.0) ########################################## # Spacing with colorbars @@ -291,7 +293,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): # is a fraction of the width of the parent(s). The spacing to the # next subplot is then given by *w/hspace*. -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 2, layout="constrained") pads = [0, 0.05, 0.1, 0.2] for pad, ax in zip(pads, axs.flat): pc = ax.pcolormesh(arr, **pc_kwargs) @@ -299,8 +301,8 @@ def example_plot(ax, fontsize=12, hide_labels=False): ax.set_xticklabels([]) ax.set_yticklabels([]) ax.set_title(f'pad: {pad}') -fig.set_constrained_layout_pads(w_pad=2 / 72, h_pad=2 / 72, hspace=0.2, - wspace=0.2) +fig.get_layout_engine().set(w_pad=2 / 72, h_pad=2 / 72, hspace=0.2, + wspace=0.2) ########################################## # rcParams @@ -327,13 +329,15 @@ def example_plot(ax, fontsize=12, hide_labels=False): # ================= # # constrained_layout is meant to be used -# with :func:`~matplotlib.figure.Figure.subplots` or -# :func:`~matplotlib.gridspec.GridSpec` and +# with :func:`~matplotlib.figure.Figure.subplots`, +# :func:`~matplotlib.figure.Figure.subplot_mosaic`, or +# :func:`~matplotlib.gridspec.GridSpec` with # :func:`~matplotlib.figure.Figure.add_subplot`. # -# Note that in what follows ``constrained_layout=True`` +# Note that in what follows ``layout="constrained"`` -fig = plt.figure() +plt.rcParams['figure.constrained_layout.use'] = False +fig = plt.figure(layout="constrained") gs1 = gridspec.GridSpec(2, 1, figure=fig) ax1 = fig.add_subplot(gs1[0]) @@ -347,7 +351,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): # convenience functions `~.Figure.add_gridspec` and # `~.SubplotSpec.subgridspec`. -fig = plt.figure() +fig = plt.figure(layout="constrained") gs0 = fig.add_gridspec(1, 2) @@ -374,7 +378,7 @@ def example_plot(ax, fontsize=12, hide_labels=False): # then they need to be in the same gridspec. We need to make this figure # larger as well in order for the axes not to collapse to zero height: -fig = plt.figure(figsize=(4, 6)) +fig = plt.figure(figsize=(4, 6), layout="constrained") gs0 = fig.add_gridspec(6, 2) @@ -392,52 +396,94 @@ def example_plot(ax, fontsize=12, hide_labels=False): example_plot(ax, hide_labels=True) fig.suptitle('Overlapping Gridspecs') - ############################################################################ # This example uses two gridspecs to have the colorbar only pertain to # one set of pcolors. Note how the left column is wider than the # two right-hand columns because of this. Of course, if you wanted the -# subplots to be the same size you only needed one gridspec. +# subplots to be the same size you only needed one gridspec. Note that +# the same effect can be achieved using `~.Figure.subfigures`. +fig = plt.figure(layout="constrained") +gs0 = fig.add_gridspec(1, 2, figure=fig, width_ratios=[1, 2]) +gs_left = gs0[0].subgridspec(2, 1) +gs_right = gs0[1].subgridspec(2, 2) -def docomplicated(suptitle=None): - fig = plt.figure() - gs0 = fig.add_gridspec(1, 2, figure=fig, width_ratios=[1., 2.]) - gsl = gs0[0].subgridspec(2, 1) - gsr = gs0[1].subgridspec(2, 2) +for gs in gs_left: + ax = fig.add_subplot(gs) + example_plot(ax) +axs = [] +for gs in gs_right: + ax = fig.add_subplot(gs) + pcm = ax.pcolormesh(arr, **pc_kwargs) + ax.set_xlabel('x-label') + ax.set_ylabel('y-label') + ax.set_title('title') + axs += [ax] +fig.suptitle('Nested plots using subgridspec') +fig.colorbar(pcm, ax=axs) - for gs in gsl: - ax = fig.add_subplot(gs) - example_plot(ax) - axs = [] - for gs in gsr: - ax = fig.add_subplot(gs) - pcm = ax.pcolormesh(arr, **pc_kwargs) - ax.set_xlabel('x-label') - ax.set_ylabel('y-label') - ax.set_title('title') +############################################################################### +# Rather than using subgridspecs, Matplotlib now provides `~.Figure.subfigures` +# which also work with ``constrained_layout``: - axs += [ax] - fig.colorbar(pcm, ax=axs) - if suptitle is not None: - fig.suptitle(suptitle) +fig = plt.figure(layout="constrained") +sfigs = fig.subfigures(1, 2, width_ratios=[1, 2]) +axs_left = sfigs[0].subplots(2, 1) +for ax in axs_left.flat: + example_plot(ax) -docomplicated() +axs_right = sfigs[1].subplots(2, 2) +for ax in axs_right.flat: + pcm = ax.pcolormesh(arr, **pc_kwargs) + ax.set_xlabel('x-label') + ax.set_ylabel('y-label') + ax.set_title('title') +fig.colorbar(pcm, ax=axs_right) +fig.suptitle('Nested plots using subfigures') ############################################################################### # Manually setting axes positions # ================================ # -# There can be good reasons to manually set an axes position. A manual call +# There can be good reasons to manually set an Axes position. A manual call # to `~.axes.Axes.set_position` will set the axes so constrained_layout has # no effect on it anymore. (Note that ``constrained_layout`` still leaves the # space for the axes that is moved). -fig, axs = plt.subplots(1, 2) +fig, axs = plt.subplots(1, 2, layout="constrained") example_plot(axs[0], fontsize=12) axs[1].set_position([0.2, 0.2, 0.4, 0.4]) +############################################################################### +# .. _compressed_layout: +# +# Grids of fixed aspect-ratio Axes: "compressed" layout +# ===================================================== +# +# ``constrained_layout`` operates on the grid of "original" positions for +# axes. However, when Axes have fixed aspect ratios, one side is usually made +# shorter, and leaves large gaps in the shortened direction. In the following, +# the Axes are square, but the figure quite wide so there is a horizontal gap: + +fig, axs = plt.subplots(2, 2, figsize=(5, 3), + sharex=True, sharey=True, layout="constrained") +for ax in axs.flat: + ax.imshow(arr) +fig.suptitle("fixed-aspect plots, layout='constrained'") + +############################################################################### +# One obvious way of fixing this is to make the figure size more square, +# however, closing the gaps exactly requires trial and error. For simple grids +# of Axes we can use ``layout="compressed"`` to do the job for us: + +fig, axs = plt.subplots(2, 2, figsize=(5, 3), + sharex=True, sharey=True, layout='compressed') +for ax in axs.flat: + ax.imshow(arr) +fig.suptitle("fixed-aspect plots, layout='compressed'") + + ############################################################################### # Manually turning off ``constrained_layout`` # =========================================== @@ -445,7 +491,7 @@ def docomplicated(suptitle=None): # ``constrained_layout`` usually adjusts the axes positions on each draw # of the figure. If you want to get the spacing provided by # ``constrained_layout`` but not have it update, then do the initial -# draw and then call ``fig.set_constrained_layout(False)``. +# draw and then call ``fig.set_layout_engine(None)``. # This is potentially useful for animations where the tick labels may # change length. # @@ -466,7 +512,7 @@ def docomplicated(suptitle=None): # `.GridSpec` instance if the geometry is not the same, and # ``constrained_layout``. So the following works fine: -fig = plt.figure() +fig = plt.figure(layout="constrained") ax1 = plt.subplot(2, 2, 1) ax2 = plt.subplot(2, 2, 3) @@ -481,7 +527,7 @@ def docomplicated(suptitle=None): ############################################################################### # but the following leads to a poor layout: -fig = plt.figure() +fig = plt.figure(layout="constrained") ax1 = plt.subplot(2, 2, 1) ax2 = plt.subplot(2, 2, 3) @@ -497,7 +543,7 @@ def docomplicated(suptitle=None): # `~matplotlib.pyplot.subplot2grid` works with the same limitation # that nrows and ncols cannot change for the layout to look good. -fig = plt.figure() +fig = plt.figure(layout="constrained") ax1 = plt.subplot2grid((3, 3), (0, 0)) ax2 = plt.subplot2grid((3, 3), (0, 1), colspan=2) @@ -509,7 +555,6 @@ def docomplicated(suptitle=None): example_plot(ax3) example_plot(ax4) fig.suptitle('subplot2grid') -plt.show() ############################################################################### # Other Caveats @@ -554,7 +599,7 @@ def docomplicated(suptitle=None): # ====================== # # The algorithm for the constraint is relatively straightforward, but -# has some complexity due to the complex ways we can layout a figure. +# has some complexity due to the complex ways we can lay out a figure. # # Layout in Matplotlib is carried out with gridspecs # via the `.GridSpec` class. A gridspec is a logical division of the figure @@ -566,7 +611,7 @@ def docomplicated(suptitle=None): # for each column, and ``bottom`` and ``top`` variables for each row, and # further it has a margin for each of left, right, bottom and top. In each # row, the bottom/top margins are widened until all the decorators -# in that row are accommodated. Similarly for columns and the left/right +# in that row are accommodated. Similarly, for columns and the left/right # margins. # # @@ -588,7 +633,7 @@ def docomplicated(suptitle=None): from matplotlib._layoutgrid import plot_children -fig, ax = plt.subplots(constrained_layout=True) +fig, ax = plt.subplots(layout="constrained") example_plot(ax, fontsize=24) plot_children(fig) @@ -602,10 +647,10 @@ def docomplicated(suptitle=None): # margin. The left and right margins are not shared, and hence are # allowed to be different. -fig, ax = plt.subplots(1, 2, constrained_layout=True) +fig, ax = plt.subplots(1, 2, layout="constrained") example_plot(ax[0], fontsize=32) example_plot(ax[1], fontsize=8) -plot_children(fig, printit=False) +plot_children(fig) ####################################################################### # Two Axes and colorbar @@ -614,7 +659,7 @@ def docomplicated(suptitle=None): # A colorbar is simply another item that expands the margin of the parent # layoutgrid cell: -fig, ax = plt.subplots(1, 2, constrained_layout=True) +fig, ax = plt.subplots(1, 2, layout="constrained") im = ax[0].pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=ax[0], shrink=0.6) im = ax[1].pcolormesh(arr, **pc_kwargs) @@ -627,11 +672,11 @@ def docomplicated(suptitle=None): # If a colorbar belongs to more than one cell of the grid, then # it makes a larger margin for each: -fig, axs = plt.subplots(2, 2, constrained_layout=True) +fig, axs = plt.subplots(2, 2, layout="constrained") for ax in axs.flat: im = ax.pcolormesh(arr, **pc_kwargs) fig.colorbar(im, ax=axs, shrink=0.6) -plot_children(fig, printit=False) +plot_children(fig) ####################################################################### # Uneven sized Axes @@ -645,10 +690,10 @@ def docomplicated(suptitle=None): # ``bottom`` margins are not affected by the left-hand column. This # is a conscious decision of the algorithm, and leads to the case where # the two right-hand axes have the same height, but it is not 1/2 the height -# of the left-hand axes. This is consietent with how ``gridspec`` works +# of the left-hand axes. This is consistent with how ``gridspec`` works # without constrained layout. -fig = plt.figure(constrained_layout=True) +fig = plt.figure(layout="constrained") gs = gridspec.GridSpec(2, 2, figure=fig) ax = fig.add_subplot(gs[:, 0]) im = ax.pcolormesh(arr, **pc_kwargs) @@ -656,7 +701,7 @@ def docomplicated(suptitle=None): im = ax.pcolormesh(arr, **pc_kwargs) ax = fig.add_subplot(gs[1, 1]) im = ax.pcolormesh(arr, **pc_kwargs) -plot_children(fig, printit=False) +plot_children(fig) ####################################################################### # One case that requires finessing is if margins do not have any artists @@ -665,7 +710,7 @@ def docomplicated(suptitle=None): # so we take the maximum width of the margin widths that do have artists. # This makes all the axes have the same size: -fig = plt.figure(constrained_layout=True) +fig = plt.figure(layout="constrained") gs = fig.add_gridspec(2, 4) ax00 = fig.add_subplot(gs[0, 0:2]) ax01 = fig.add_subplot(gs[0, 2:]) diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py index 746a44261483..0fbe4b2d3526 100644 --- a/tutorials/intermediate/legend_guide.py +++ b/tutorials/intermediate/legend_guide.py @@ -47,8 +47,9 @@ not all artists can be added to a legend, at which point a "proxy" will have to be created (see :ref:`proxy_legend_handles` for further details). -Those artists with an empty string as label or with a label starting with -"_" will be ignored. +.. note:: + Artists with an empty string as label or with a label starting with an + underscore, "_", will be ignored. For full control of what is being added to the legend, it is common to pass the appropriate handles directly to :func:`legend`:: @@ -126,7 +127,7 @@ # Place a legend above this subplot, expanding itself to # fully use the given bounding box. ax_dict['top'].legend(bbox_to_anchor=(0., 1.02, 1., .102), loc='lower left', - ncol=2, mode="expand", borderaxespad=0.) + ncols=2, mode="expand", borderaxespad=0.) ax_dict['bottom'].plot([1, 2, 3], label="test1") ax_dict['bottom'].plot([3, 2, 1], label="test2") @@ -134,7 +135,54 @@ ax_dict['bottom'].legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0.) -plt.show() +############################################################################## +# Figure legends +# -------------- +# +# Sometimes it makes more sense to place a legend relative to the (sub)figure +# rather than individual Axes. By using ``constrained_layout`` and +# specifying "outside" at the beginning of the *loc* keyword argument, +# the legend is drawn outside the Axes on the (sub)figure. + +fig, axs = plt.subplot_mosaic([['left', 'right']], layout='constrained') + +axs['left'].plot([1, 2, 3], label="test1") +axs['left'].plot([3, 2, 1], label="test2") + +axs['right'].plot([1, 2, 3], 'C2', label="test3") +axs['right'].plot([3, 2, 1], 'C3', label="test4") +# Place a legend to the right of this smaller subplot. +fig.legend(loc='outside upper right') + +############################################################################## +# This accepts a slightly different grammar than the normal *loc* keyword, +# where "outside right upper" is different from "outside upper right". +# +ucl = ['upper', 'center', 'lower'] +lcr = ['left', 'center', 'right'] +fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7') + +ax.plot([1, 2], [1, 2], label='TEST') +# Place a legend to the right of this smaller subplot. +for loc in [ + 'outside upper left', + 'outside upper center', + 'outside upper right', + 'outside lower left', + 'outside lower center', + 'outside lower right']: + fig.legend(loc=loc, title=loc) + +fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7') +ax.plot([1, 2], [1, 2], label='test') + +for loc in [ + 'outside left upper', + 'outside right upper', + 'outside left lower', + 'outside right lower']: + fig.legend(loc=loc, title=loc) + ############################################################################### # Multiple legends on the same Axes diff --git a/tutorials/intermediate/tight_layout_guide.py b/tutorials/intermediate/tight_layout_guide.py index 976fd511a516..27b7d0777126 100644 --- a/tutorials/intermediate/tight_layout_guide.py +++ b/tutorials/intermediate/tight_layout_guide.py @@ -161,7 +161,7 @@ def example_plot(ax, fontsize=12): # are rare cases where it is not. # # * ``pad=0`` can clip some texts by a few pixels. This may be a bug or -# a limitation of the current algorithm and it is not clear why it +# a limitation of the current algorithm, and it is not clear why it # happens. Meanwhile, use of pad larger than 0.3 is recommended. # # Use with GridSpec @@ -213,7 +213,7 @@ def example_plot(ax, fontsize=12): # ======================= # # Pre Matplotlib 2.2, legends and annotations were excluded from the bounding -# box calculations that decide the layout. Subsequently these artists were +# box calculations that decide the layout. Subsequently, these artists were # added to the calculation, but sometimes it is undesirable to include them. # For instance in this case it might be good to have the axes shrink a bit # to make room for the legend: @@ -276,7 +276,7 @@ def example_plot(ax, fontsize=12): ############################################################################### # Another option is to use the AxesGrid1 toolkit to -# explicitly create an axes for the colorbar. +# explicitly create an Axes for the colorbar. from mpl_toolkits.axes_grid1 import make_axes_locatable diff --git a/tutorials/introductory/animation_tutorial.py b/tutorials/introductory/animation_tutorial.py new file mode 100644 index 000000000000..a736580a36e4 --- /dev/null +++ b/tutorials/introductory/animation_tutorial.py @@ -0,0 +1,242 @@ +""" +=========================== +Animations using Matplotlib +=========================== + +Based on its plotting functionality, Matplotlib also provides an interface to +generate animations using the `~matplotlib.animation` module. An +animation is a sequence of frames where each frame corresponds to a plot on a +`~matplotlib.figure.Figure`. This tutorial covers a general guideline on +how to create such animations and the different options available. +""" + +import matplotlib.pyplot as plt +import matplotlib.animation as animation +import numpy as np + +############################################################################### +# Animation Classes +# ================= +# +# The animation process in Matplotlib can be thought of in 2 different ways: +# +# - `~matplotlib.animation.FuncAnimation`: Generate data for first +# frame and then modify this data for each frame to create an animated plot. +# +# - `~matplotlib.animation.ArtistAnimation`: Generate a list (iterable) +# of artists that will draw in each frame in the animation. +# +# `~matplotlib.animation.FuncAnimation` is more efficient in terms of +# speed and memory as it draws an artist once and then modifies it. On the +# other hand `~matplotlib.animation.ArtistAnimation` is flexible as it +# allows any iterable of artists to be animated in a sequence. +# +# ``FuncAnimation`` +# ----------------- +# +# The `~matplotlib.animation.FuncAnimation` class allows us to create an +# animation by passing a function that iteratively modifies the data of a plot. +# This is achieved by using the *setter* methods on various +# `~matplotlib.artist.Artist` (examples: `~matplotlib.lines.Line2D`, +# `~matplotlib.collections.PathCollection`, etc.). A usual +# `~matplotlib.animation.FuncAnimation` object takes a +# `~matplotlib.figure.Figure` that we want to animate and a function +# *func* that modifies the data plotted on the figure. It uses the *frames* +# parameter to determine the length of the animation. The *interval* parameter +# is used to determine time in milliseconds between drawing of two frames. +# Animating using `.FuncAnimation` would usually follow the following +# structure: +# +# - Plot the initial figure, including all the required artists. Save all the +# artists in variables so that they can be updated later on during the +# animation. +# - Create an animation function that updates the data in each artist to +# generate the new frame at each function call. +# - Create a `.FuncAnimation` object with the `.Figure` and the animation +# function, along with the keyword arguments that determine the animation +# properties. +# - Use `.animation.Animation.save` or `.pyplot.show` to save or show the +# animation. +# +# The update function uses the ``set_*`` function for different artists to +# modify the data. The following table shows a few plotting methods, the artist +# types they return and some methods that can be used to update them. +# +# ======================================== ============================= =========================== +# Plotting method Artist Set method +# ======================================== ============================= =========================== +# `.Axes.plot` `.lines.Line2D` `~.lines.Line2D.set_data` +# `.Axes.scatter` `.collections.PathCollection` `~.collections.\ +# PathCollection.set_offsets` +# `.Axes.imshow` `.image.AxesImage` ``AxesImage.set_data`` +# `.Axes.annotate` `.text.Annotation` `~.text.Annotation.\ +# update_positions` +# `.Axes.barh` `.patches.Rectangle` `~.Rectangle.set_angle`, +# `~.Rectangle.set_bounds`, +# `~.Rectangle.set_height`, +# `~.Rectangle.set_width`, +# `~.Rectangle.set_x`, +# `~.Rectangle.set_y`, +# `~.Rectangle.set_xy` +# `.Axes.fill` `.patches.Polygon` `~.Polygon.set_xy` +# `.Axes.add_patch`\(`.patches.Ellipse`\) `.patches.Ellipse` `~.Ellipse.set_angle`, +# `~.Ellipse.set_center`, +# `~.Ellipse.set_height`, +# `~.Ellipse.set_width` +# ======================================== ============================= =========================== +# +# Covering the set methods for all types of artists is beyond the scope of this +# tutorial but can be found in their respective documentations. An example of +# such update methods in use for `.Axes.scatter` and `.Axes.plot` is as follows. + +fig, ax = plt.subplots() +t = np.linspace(0, 3, 40) +g = -9.81 +v0 = 12 +z = g * t**2 / 2 + v0 * t + +v02 = 5 +z2 = g * t**2 / 2 + v02 * t + +scat = ax.scatter(t[0], z[0], c="b", s=5, label=f'v0 = {v0} m/s') +line2 = ax.plot(t[0], z2[0], label=f'v0 = {v02} m/s')[0] +ax.set(xlim=[0, 3], ylim=[-4, 10], xlabel='Time [s]', ylabel='Z [m]') +ax.legend() + + +def update(frame): + # for each frame, update the data stored on each artist. + x = t[:frame] + y = z[:frame] + # update the scatter plot: + data = np.stack([x, y]).T + scat.set_offsets(data) + # update the line plot: + line2.set_xdata(t[:frame]) + line2.set_ydata(z2[:frame]) + return (scat, line2) + + +ani = animation.FuncAnimation(fig=fig, func=update, frames=40, interval=30) +plt.show() + + +############################################################################### +# ``ArtistAnimation`` +# ------------------- +# +# `~matplotlib.animation.ArtistAnimation` can be used +# to generate animations if there is data stored on various different artists. +# This list of artists is then converted frame by frame into an animation. For +# example, when we use `.Axes.barh` to plot a bar-chart, it creates a number of +# artists for each of the bar and error bars. To update the plot, one would +# need to update each of the bars from the container individually and redraw +# them. Instead, `.animation.ArtistAnimation` can be used to plot each frame +# individually and then stitched together to form an animation. A barchart race +# is a simple example for this. + + +fig, ax = plt.subplots() +rng = np.random.default_rng(19680801) +data = np.array([20, 20, 20, 20]) +x = np.array([1, 2, 3, 4]) + +artists = [] +colors = ['tab:blue', 'tab:red', 'tab:green', 'tab:purple'] +for i in range(20): + data += rng.integers(low=0, high=10, size=data.shape) + container = ax.barh(x, data, color=colors) + artists.append(container) + + +ani = animation.ArtistAnimation(fig=fig, artists=artists, interval=400) +plt.show() + +############################################################################### +# Animation Writers +# ================= +# +# Animation objects can be saved to disk using various multimedia writers +# (ex: Pillow, *ffpmeg*, *imagemagick*). Not all video formats are supported +# by all writers. There are 4 major types of writers: +# +# - `~matplotlib.animation.PillowWriter` - Uses the Pillow library to +# create the animation. +# +# - `~matplotlib.animation.HTMLWriter` - Used to create JavaScript-based +# animations. +# +# - Pipe-based writers - `~matplotlib.animation.FFMpegWriter` and +# `~matplotlib.animation.ImageMagickWriter` are pipe based writers. +# These writers pipe each frame to the utility (*ffmpeg* / *imagemagick*) +# which then stitches all of them together to create the animation. +# +# - File-based writers - `~matplotlib.animation.FFMpegFileWriter` and +# `~matplotlib.animation.ImageMagickFileWriter` are examples of +# file-based writers. These writers are slower than their pipe-based +# alternatives but are more useful for debugging as they save each frame in +# a file before stitching them together into an animation. +# +# Saving Animations +# ----------------- +# +# .. list-table:: +# :header-rows: 1 +# +# * - Writer +# - Supported Formats +# * - `~matplotlib.animation.PillowWriter` +# - .gif, .apng, .webp +# * - `~matplotlib.animation.HTMLWriter` +# - .htm, .html, .png +# * - | `~matplotlib.animation.FFMpegWriter` +# | `~matplotlib.animation.FFMpegFileWriter` +# - All formats supported by |ffmpeg|_: ``ffmpeg -formats`` +# * - | `~matplotlib.animation.ImageMagickWriter` +# | `~matplotlib.animation.ImageMagickFileWriter` +# - All formats supported by |imagemagick|_: ``magick -list format`` +# +# .. _ffmpeg: https://www.ffmpeg.org/general.html#Supported-File-Formats_002c-Codecs-or-Features +# .. |ffmpeg| replace:: *ffmpeg* +# +# .. _imagemagick: https://imagemagick.org/script/formats.php#supported +# .. |imagemagick| replace:: *imagemagick* +# +# To save animations using any of the writers, we can use the +# `.animation.Animation.save` method. It takes the *filename* that we want to +# save the animation as and the *writer*, which is either a string or a writer +# object. It also takes an *fps* argument. This argument is different than the +# *interval* argument that `~.animation.FuncAnimation` or +# `~.animation.ArtistAnimation` uses. *fps* determines the frame rate that the +# **saved** animation uses, whereas *interval* determines the frame rate that +# the **displayed** animation uses. +# +# Below are a few examples that show how to save an animation with different +# writers. +# +# +# Pillow writers:: +# +# ani.save(filename="/tmp/pillow_example.gif", writer="pillow") +# ani.save(filename="/tmp/pillow_example.apng", writer="pillow") +# +# HTML writers:: +# +# ani.save(filename="/tmp/html_example.html", writer="html") +# ani.save(filename="/tmp/html_example.htm", writer="html") +# ani.save(filename="/tmp/html_example.png", writer="html") +# +# FFMpegWriter:: +# +# ani.save(filename="/tmp/ffmpeg_example.mkv", writer="ffmpeg") +# ani.save(filename="/tmp/ffmpeg_example.mp4", writer="ffmpeg") +# ani.save(filename="/tmp/ffmpeg_example.mjpeg", writer="ffmpeg") +# +# Imagemagick writers:: +# +# ani.save(filename="/tmp/imagemagick_example.gif", writer="imagemagick") +# ani.save(filename="/tmp/imagemagick_example.webp", writer="imagemagick") +# ani.save(filename="apng:/tmp/imagemagick_example.apng", +# writer="imagemagick", extra_args=["-quality", "100"]) +# +# (the ``extra_args`` for *apng* are needed to reduce filesize by ~10x) diff --git a/tutorials/introductory/customizing.py b/tutorials/introductory/customizing.py index 9e74016c823e..10fc21d2187b 100644 --- a/tutorials/introductory/customizing.py +++ b/tutorials/introductory/customizing.py @@ -9,9 +9,9 @@ There are three ways to customize Matplotlib: - 1. :ref:`Setting rcParams at runtime`. - 2. :ref:`Using style sheets`. - 3. :ref:`Changing your matplotlibrc file`. +1. :ref:`Setting rcParams at runtime`. +2. :ref:`Using style sheets`. +3. :ref:`Changing your matplotlibrc file`. Setting rcParams at runtime takes precedence over style sheets, style sheets take precedence over :file:`matplotlibrc` files. @@ -137,6 +137,17 @@ def plotting_function(): # >>> import matplotlib.pyplot as plt # >>> plt.style.use('./images/presentation.mplstyle') # +# +# Distributing styles +# ------------------- +# +# You can include style sheets into standard importable Python packages (which +# can be e.g. distributed on PyPI). If your package is importable as +# ``import mypackage``, with a ``mypackage/__init__.py`` module, and you add +# a ``mypackage/presentation.mplstyle`` style sheet, then it can be used as +# ``plt.style.use("mypackage.presentation")``. Subpackages (e.g. +# ``dotted.package.name``) are also supported. +# # Alternatively, you can make your style known to Matplotlib by placing # your ``.mplstyle`` file into ``mpl_configdir/stylelib``. You # can then load your custom style sheet with a call to @@ -217,8 +228,8 @@ def plotting_function(): # # 4. :file:`{INSTALL}/matplotlib/mpl-data/matplotlibrc`, where # :file:`{INSTALL}` is something like -# :file:`/usr/lib/python3.7/site-packages` on Linux, and maybe -# :file:`C:\\Python37\\Lib\\site-packages` on Windows. Every time you +# :file:`/usr/lib/python3.9/site-packages` on Linux, and maybe +# :file:`C:\\Python39\\Lib\\site-packages` on Windows. Every time you # install matplotlib, this file will be overwritten, so if you want # your customizations to be saved, please move this file to your # user-specific matplotlib directory. diff --git a/tutorials/introductory/images.py b/tutorials/introductory/images.py index 46a6de70ee69..7673037c4a9c 100644 --- a/tutorials/introductory/images.py +++ b/tutorials/introductory/images.py @@ -37,19 +37,20 @@ cells below those that create the plot will change the plot - it is a live object in memory. -This tutorial will use Matplotlib's imperative-style plotting -interface, pyplot. This interface maintains global state, and is very -useful for quickly and easily experimenting with various plot -settings. The alternative is the object-oriented interface, which is also -very powerful, and generally more suitable for large application -development. If you'd like to learn about the object-oriented -interface, a great place to start is our :doc:`Usage guide -`. For now, let's get on -with the imperative-style approach: +This tutorial will use Matplotlib's implicit plotting interface, pyplot. This +interface maintains global state, and is very useful for quickly and easily +experimenting with various plot settings. The alternative is the explicit, +which is more suitable for large application development. For an explanation +of the tradeoffs between the implicit and explicit interfaces see +:ref:`api_interfaces` and the :doc:`Quick start guide +` to start using the explicit interface. +For now, let's get on with the implicit approach: + """ import matplotlib.pyplot as plt -import matplotlib.image as mpimg +import numpy as np +from PIL import Image ############################################################################### # .. _importing_data: @@ -69,29 +70,20 @@ # on where you get your data, the other kinds of image that you'll most # likely encounter are RGBA images, which allow for transparency, or # single-channel grayscale (luminosity) images. Download `stinkbug.png -# `_ +# `_ # to your computer for the rest of this tutorial. # -# And here we go... +# We use Pillow to open an image (with `PIL.Image.open`), and immediately +# convert the `PIL.Image.Image` object into an 8-bit (``dtype=uint8``) numpy +# array. -img = mpimg.imread('../../doc/_static/stinkbug.png') -print(img) +img = np.asarray(Image.open('../../doc/_static/stinkbug.png')) +print(repr(img)) ############################################################################### -# Note the dtype there - float32. Matplotlib has rescaled the 8 bit -# data from each channel to floating point data between 0.0 and 1.0. As -# a side note, the only datatype that Pillow can work with is uint8. -# Matplotlib plotting can handle float32 and uint8, but image -# reading/writing for any format other than PNG is limited to uint8 -# data. Why 8 bits? Most displays can only render 8 bits per channel -# worth of color gradation. Why can they only render 8 bits/channel? -# Because that's about all the human eye can see. More here (from a -# photography standpoint): `Luminous Landscape bit depth tutorial -# `_. -# # Each inner list represents a pixel. Here, with an RGB image, there # are 3 values. Since it's a black and white image, R, G, and B are all -# similar. An RGBA (where A is alpha, or transparency), has 4 values +# similar. An RGBA (where A is alpha, or transparency) has 4 values # per inner list, and a simple luminance image just has one value (and # is thus only a 2-D array, not a 3-D array). For RGB and RGBA images, # Matplotlib supports float32 and uint8 data types. For grayscale, @@ -127,13 +119,11 @@ # Pseudocolor is only relevant to single-channel, grayscale, luminosity # images. We currently have an RGB image. Since R, G, and B are all # similar (see for yourself above or in your data), we can just pick one -# channel of our data: +# channel of our data using array slicing (you can read more in the +# `Numpy tutorial `_): lum_img = img[:, :, 0] - -# This is array slicing. You can read more in the `Numpy tutorial -# `_. - plt.imshow(lum_img) ############################################################################### @@ -188,7 +178,7 @@ # interesting regions is the histogram. To create a histogram of our # image data, we use the :func:`~matplotlib.pyplot.hist` function. -plt.hist(lum_img.ravel(), bins=256, range=(0.0, 1.0), fc='k', ec='k') +plt.hist(lum_img.ravel(), bins=range(256), fc='k', ec='k') ############################################################################### # Most often, the "interesting" part of the image is around the peak, @@ -196,29 +186,23 @@ # below the peak. In our histogram, it looks like there's not much # useful information in the high end (not many white things in the # image). Let's adjust the upper limit, so that we effectively "zoom in -# on" part of the histogram. We do this by passing the clim argument to -# imshow. You could also do this by calling the -# :meth:`~matplotlib.cm.ScalarMappable.set_clim` method of the image plot -# object, but make sure that you do so in the same cell as your plot -# command when working with the Jupyter Notebook - it will not change -# plots from earlier cells. +# on" part of the histogram. We do this by setting *clim*, the colormap +# limits. # -# You can specify the clim in the call to ``plot``. +# This can be done by passing a *clim* keyword argument in the call to +# ``imshow``. -imgplot = plt.imshow(lum_img, clim=(0.0, 0.7)) +plt.imshow(lum_img, clim=(0, 175)) ############################################################################### -# You can also specify the clim using the returned object -fig = plt.figure() -ax = fig.add_subplot(1, 2, 1) -imgplot = plt.imshow(lum_img) -ax.set_title('Before') -plt.colorbar(ticks=[0.1, 0.3, 0.5, 0.7], orientation='horizontal') -ax = fig.add_subplot(1, 2, 2) +# This can also be done by calling the +# :meth:`~matplotlib.cm.ScalarMappable.set_clim` method of the returned image +# plot object, but make sure that you do so in the same cell as your plot +# command when working with the Jupyter Notebook - it will not change +# plots from earlier cells. + imgplot = plt.imshow(lum_img) -imgplot.set_clim(0.0, 0.7) -ax.set_title('After') -plt.colorbar(ticks=[0.1, 0.3, 0.5, 0.7], orientation='horizontal') +imgplot.set_clim(0, 175) ############################################################################### # .. _Interpolation: @@ -242,19 +226,17 @@ # We'll use the Pillow library that we used to load the image also to resize # the image. -from PIL import Image - img = Image.open('../../doc/_static/stinkbug.png') -img.thumbnail((64, 64), Image.ANTIALIAS) # resizes image in-place +img.thumbnail((64, 64)) # resizes image in-place imgplot = plt.imshow(img) ############################################################################### -# Here we have the default interpolation, bilinear, since we did not +# Here we use the default interpolation ("nearest"), since we did not # give :func:`~matplotlib.pyplot.imshow` any interpolation argument. # -# Let's try some others. Here's "nearest", which does no interpolation. +# Let's try some others. Here's "bilinear": -imgplot = plt.imshow(img, interpolation="nearest") +imgplot = plt.imshow(img, interpolation="bilinear") ############################################################################### # and bicubic: diff --git a/tutorials/introductory/lifecycle.py b/tutorials/introductory/lifecycle.py index 4f30a4d08586..3d3a1036ff2b 100644 --- a/tutorials/introductory/lifecycle.py +++ b/tutorials/introductory/lifecycle.py @@ -17,38 +17,41 @@ `_ by Chris Moffitt. It was transformed into this tutorial by Chris Holdgraf. -A note on the Object-Oriented API vs. Pyplot -============================================ +A note on the explicit vs. implicit interfaces +============================================== -Matplotlib has two interfaces. The first is an object-oriented (OO) -interface. In this case, we utilize an instance of :class:`axes.Axes` -in order to render visualizations on an instance of :class:`figure.Figure`. +Matplotlib has two interfaces. For an explanation of the trade-offs between the +explicit and implicit interfaces see :ref:`api_interfaces`. -The second is based on MATLAB and uses a state-based interface. This is -encapsulated in the :mod:`.pyplot` module. See the :doc:`pyplot tutorials -` for a more in-depth look at the pyplot -interface. +In the explicit object-oriented (OO) interface we directly utilize instances of +:class:`axes.Axes` to build up the visualization in an instance of +:class:`figure.Figure`. In the implicit interface, inspired by and modeled on +MATLAB, we use a global state-based interface which is encapsulated in the +:mod:`.pyplot` module to plot to the "current Axes". See the :doc:`pyplot +tutorials ` for a more in-depth look at the +pyplot interface. Most of the terms are straightforward but the main thing to remember is that: -* The Figure is the final image that may contain 1 or more Axes. -* The Axes represent an individual plot (don't confuse this with the word - "axis", which refers to the x/y axis of a plot). +* The `.Figure` is the final image, and may contain one or more `~.axes.Axes`. +* The `~.axes.Axes` represents an individual plot (not to be confused with + `~.axis.Axis`, which refers to the x-, y-, or z-axis of a plot). We call methods that do the plotting directly from the Axes, which gives us much more flexibility and power in customizing our plot. .. note:: - In general, try to use the object-oriented interface over the pyplot - interface. + In general, use the explicit interface over the implicit pyplot interface + for plotting. Our data ======== We'll use the data from the post from which this tutorial was derived. It contains sales information for a number of companies. + """ # sphinx_gallery_thumbnail_number = 10 diff --git a/tutorials/introductory/pyplot.py b/tutorials/introductory/pyplot.py index 994bbb50d630..163c74b5b31c 100644 --- a/tutorials/introductory/pyplot.py +++ b/tutorials/introductory/pyplot.py @@ -3,19 +3,21 @@ Pyplot tutorial =============== -An introduction to the pyplot interface. +An introduction to the pyplot interface. Please also see +:doc:`/tutorials/introductory/quick_start` for an overview of how Matplotlib +works and :ref:`api_interfaces` for an explanation of the trade-offs between the +supported user APIs. + """ ############################################################################### -# Intro to pyplot -# =============== +# Introduction to pyplot +# ====================== # -# :mod:`matplotlib.pyplot` is a collection of functions -# that make matplotlib work like MATLAB. -# Each ``pyplot`` function makes -# some change to a figure: e.g., creates a figure, creates a plotting area -# in a figure, plots some lines in a plotting area, decorates the plot -# with labels, etc. +# :mod:`matplotlib.pyplot` is a collection of functions that make matplotlib +# work like MATLAB. Each ``pyplot`` function makes some change to a figure: +# e.g., creates a figure, creates a plotting area in a figure, plots some lines +# in a plotting area, decorates the plot with labels, etc. # # In :mod:`matplotlib.pyplot` various states are preserved # across function calls, so that it keeps track of things like @@ -27,10 +29,11 @@ # # .. note:: # -# the pyplot API is generally less-flexible than the object-oriented API. -# Most of the function calls you see here can also be called as methods -# from an ``Axes`` object. We recommend browsing the tutorials and -# examples to see how this works. +# The implicit pyplot API is generally less verbose but also not as flexible as the +# explicit API. Most of the function calls you see here can also be called +# as methods from an ``Axes`` object. We recommend browsing the tutorials +# and examples to see how this works. See :ref:`api_interfaces` for an +# explanation of the trade-off of the supported user APIs. # # Generating visualizations with pyplot is very quick: @@ -45,7 +48,7 @@ # `~.pyplot.plot`, matplotlib assumes it is a # sequence of y values, and automatically generates the x values for # you. Since python ranges start with 0, the default x vector has the -# same length as y but starts with 0. Hence the x data are +# same length as y but starts with 0; therefore, the x data are # ``[0, 1, 2, 3]``. # # `~.pyplot.plot` is a versatile function, and will take an arbitrary number of @@ -101,7 +104,7 @@ # access particular variables with strings. For example, with # `numpy.recarray` or `pandas.DataFrame`. # -# Matplotlib allows you provide such an object with +# Matplotlib allows you to provide such an object with # the ``data`` keyword argument. If provided, then you may generate plots with # the strings corresponding to these variables. @@ -259,7 +262,7 @@ def f(t): ############################################################################### # The `~.pyplot.figure` call here is optional because a figure will be created -# if none exists, just as an axes will be created (equivalent to an explicit +# if none exists, just as an Axes will be created (equivalent to an explicit # ``subplot()`` call) if none exists. # The `~.pyplot.subplot` call specifies ``numrows, # numcols, plot_number`` where ``plot_number`` ranges from 1 to @@ -268,7 +271,7 @@ def f(t): # to ``subplot(2, 1, 1)``. # # You can create an arbitrary number of subplots -# and axes. If you want to place an axes manually, i.e., not on a +# and axes. If you want to place an Axes manually, i.e., not on a # rectangular grid, use `~.pyplot.axes`, # which allows you to specify the location as ``axes([left, bottom, # width, height])`` where all values are in fractional (0 to 1) @@ -292,15 +295,17 @@ def f(t): # plt.figure(2) # a second figure # plt.plot([4, 5, 6]) # creates a subplot() by default # -# plt.figure(1) # figure 1 current; subplot(212) still current -# plt.subplot(211) # make subplot(211) in figure1 current +# plt.figure(1) # first figure current; +# # subplot(212) still current +# plt.subplot(211) # make subplot(211) in the first figure +# # current # plt.title('Easy as 1, 2, 3') # subplot 211 title # # You can clear the current figure with `~.pyplot.clf` # and the current axes with `~.pyplot.cla`. If you find # it annoying that states (specifically the current image, figure and axes) # are being maintained for you behind the scenes, don't despair: this is just a thin -# stateful wrapper around an object oriented API, which you can use +# stateful wrapper around an object-oriented API, which you can use # instead (see :doc:`/tutorials/intermediate/artists`) # # If you are making lots of figures, you need to be aware of one @@ -326,7 +331,7 @@ def f(t): x = mu + sigma * np.random.randn(10000) # the histogram of the data -n, bins, patches = plt.hist(x, 50, density=1, facecolor='g', alpha=0.75) +n, bins, patches = plt.hist(x, 50, density=True, facecolor='g', alpha=0.75) plt.xlabel('Smarts') @@ -350,7 +355,7 @@ def f(t): # Using mathematical expressions in text # -------------------------------------- # -# matplotlib accepts TeX equation expressions in any text expression. +# Matplotlib accepts TeX equation expressions in any text expression. # For example to write the expression :math:`\sigma_i=15` in the title, # you can write a TeX expression surrounded by dollar signs:: # @@ -360,9 +365,9 @@ def f(t): # that the string is a *raw* string and not to treat backslashes as # python escapes. matplotlib has a built-in TeX expression parser and # layout engine, and ships its own math fonts -- for details see -# :doc:`/tutorials/text/mathtext`. Thus you can use mathematical text across platforms -# without requiring a TeX installation. For those who have LaTeX and -# dvipng installed, you can also use LaTeX to format your text and +# :doc:`/tutorials/text/mathtext`. Thus, you can use mathematical text across +# platforms without requiring a TeX installation. For those who have LaTeX +# and dvipng installed, you can also use LaTeX to format your text and # incorporate the output directly into your display figures or saved # postscript -- see :doc:`/tutorials/text/usetex`. # @@ -410,7 +415,7 @@ def f(t): # # plt.xscale('log') # -# An example of four plots with the same data and different scales for the y axis +# An example of four plots with the same data and different scales for the y-axis # is shown below. # Fixing random state for reproducibility diff --git a/tutorials/introductory/usage.py b/tutorials/introductory/quick_start.py similarity index 94% rename from tutorials/introductory/usage.py rename to tutorials/introductory/quick_start.py index 2de9a2798683..2caacc5aee22 100644 --- a/tutorials/introductory/usage.py +++ b/tutorials/introductory/quick_start.py @@ -1,10 +1,13 @@ """ -*********** -Basic Usage -*********** +***************** +Quick start guide +***************** This tutorial covers some basic usage patterns and best practices to help you get started with Matplotlib. + +.. redirect-from:: /tutorials/introductory/usage + """ # sphinx_gallery_thumbnail_number = 3 @@ -17,15 +20,15 @@ # A simple example # ================ # -# Matplotlib graphs your data on `~.figure.Figure`\s (e.g., windows, Jupyter +# Matplotlib graphs your data on `.Figure`\s (e.g., windows, Jupyter # widgets, etc.), each of which can contain one or more `~.axes.Axes`, an # area where points can be specified in terms of x-y coordinates (or theta-r -# in a polar plot, x-y-z in a 3D plot, etc). The simplest way of +# in a polar plot, x-y-z in a 3D plot, etc.). The simplest way of # creating a Figure with an Axes is using `.pyplot.subplots`. We can then use # `.Axes.plot` to draw some data on the Axes: fig, ax = plt.subplots() # Create a figure containing a single axes. -ax.plot([1, 2, 3, 4], [1, 4, 2, 3]); # Plot some data on the axes. +ax.plot([1, 2, 3, 4], [1, 4, 2, 3]) # Plot some data on the axes. ############################################################################### # .. _figure_parts: @@ -110,9 +113,9 @@ # b_asarray = np.asarray(b) # # Most methods will also parse an addressable object like a *dict*, a -# `numpy.recarray`, or a `pandas.DataFrame`. Matplotlib allows you provide -# the ``data`` keyword argument and generate plots passing the strings -# corresponding to the *x* and *y* variables. +# `numpy.recarray`, or a `pandas.DataFrame`. Matplotlib allows you to +# provide the ``data`` keyword argument and generate plots passing the +# strings corresponding to the *x* and *y* variables. np.random.seed(19680801) # seed the random number generator. data = {'a': np.arange(50), 'c': np.random.randint(0, 50, 50), @@ -123,7 +126,7 @@ fig, ax = plt.subplots(figsize=(5, 2.7), layout='constrained') ax.scatter('a', 'b', c='c', s='d', data=data) ax.set_xlabel('entry a') -ax.set_ylabel('entry b'); +ax.set_ylabel('entry b') ############################################################################## # .. _coding_styles: @@ -131,16 +134,19 @@ # Coding styles # ============= # -# The object-oriented and the pyplot interfaces -# --------------------------------------------- +# The explicit and the implicit interfaces +# ---------------------------------------- # # As noted above, there are essentially two ways to use Matplotlib: # # - Explicitly create Figures and Axes, and call methods on them (the # "object-oriented (OO) style"). -# - Rely on pyplot to automatically create and manage the Figures and Axes, and +# - Rely on pyplot to implicitly create and manage the Figures and Axes, and # use pyplot functions for plotting. # +# See :ref:`api_interfaces` for an explanation of the tradeoffs between the +# implicit and explicit interfaces. +# # So one can use the OO-style x = np.linspace(0, 2, 100) # Sample data. @@ -153,7 +159,7 @@ ax.set_xlabel('x label') # Add an x-label to the axes. ax.set_ylabel('y label') # Add a y-label to the axes. ax.set_title("Simple Plot") # Add a title to the axes. -ax.legend(); # Add a legend. +ax.legend() # Add a legend. ############################################################################### # or the pyplot-style: @@ -167,7 +173,7 @@ plt.xlabel('x label') plt.ylabel('y label') plt.title("Simple Plot") -plt.legend(); +plt.legend() ############################################################################### # (In addition, there is a third approach, for the case when embedding @@ -207,7 +213,7 @@ def my_plotter(ax, data1, data2, param_dict): data1, data2, data3, data4 = np.random.randn(4, 100) # make 4 random data sets fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(5, 2.7)) my_plotter(ax1, data1, data2, {'marker': 'x'}) -my_plotter(ax2, data3, data4, {'marker': 'o'}); +my_plotter(ax2, data3, data4, {'marker': 'o'}) ############################################################################### # Note that if you want to install these as a python package, or any other @@ -229,7 +235,7 @@ def my_plotter(ax, data1, data2, param_dict): x = np.arange(len(data1)) ax.plot(x, np.cumsum(data1), color='blue', linewidth=3, linestyle='--') l, = ax.plot(x, np.cumsum(data2), color='orange', linewidth=2) -l.set_linestyle(':'); +l.set_linestyle(':') ############################################################################### # Colors @@ -242,7 +248,7 @@ def my_plotter(ax, data1, data2, param_dict): # from the interior: fig, ax = plt.subplots(figsize=(5, 2.7)) -ax.scatter(data1, data2, s=50, facecolor='C0', edgecolor='k'); +ax.scatter(data1, data2, s=50, facecolor='C0', edgecolor='k') ############################################################################### # Linewidths, linestyles, and markersizes @@ -266,7 +272,7 @@ def my_plotter(ax, data1, data2, param_dict): ax.plot(data2, 'd', label='data2') ax.plot(data3, 'v', label='data3') ax.plot(data4, 's', label='data4') -ax.legend(); +ax.legend() ############################################################################### # @@ -285,14 +291,14 @@ def my_plotter(ax, data1, data2, param_dict): x = mu + sigma * np.random.randn(10000) fig, ax = plt.subplots(figsize=(5, 2.7), layout='constrained') # the histogram of the data -n, bins, patches = ax.hist(x, 50, density=1, facecolor='C0', alpha=0.75) +n, bins, patches = ax.hist(x, 50, density=True, facecolor='C0', alpha=0.75) ax.set_xlabel('Length [cm]') ax.set_ylabel('Probability') ax.set_title('Aardvark lengths\n (not really)') ax.text(75, .025, r'$\mu=115,\ \sigma=15$') ax.axis([55, 175, 0, 0.03]) -ax.grid(True); +ax.grid(True) ############################################################################### # All of the `~.Axes.text` functions return a `matplotlib.text.Text` @@ -336,7 +342,7 @@ def my_plotter(ax, data1, data2, param_dict): ax.annotate('local max', xy=(2, 1), xytext=(3, 1.5), arrowprops=dict(facecolor='black', shrink=0.05)) -ax.set_ylim(-2, 2); +ax.set_ylim(-2, 2) ############################################################################### # In this basic example, both *xy* and *xytext* are in data coordinates. @@ -354,7 +360,7 @@ def my_plotter(ax, data1, data2, param_dict): ax.plot(np.arange(len(data1)), data1, label='data1') ax.plot(np.arange(len(data2)), data2, label='data2') ax.plot(np.arange(len(data3)), data3, 'd', label='data3') -ax.legend(); +ax.legend() ############################################################################## # Legends in Matplotlib are quite flexible in layout, placement, and what @@ -385,7 +391,7 @@ def my_plotter(ax, data1, data2, param_dict): axs[0].plot(xdata, data) axs[1].set_yscale('log') -axs[1].plot(xdata, data); +axs[1].plot(xdata, data) ############################################################################## # The scale sets the mapping from data values to spacing along the Axis. This @@ -407,7 +413,7 @@ def my_plotter(ax, data1, data2, param_dict): axs[1].plot(xdata, data1) axs[1].set_xticks(np.arange(0, 100, 30), ['zero', '30', 'sixty', '90']) axs[1].set_yticks([-1.5, 0, 1.5]) # note that we don't need to specify labels -axs[1].set_title('Manual ticks'); +axs[1].set_title('Manual ticks') ############################################################################## # Different scales can have different locators and formatters; for instance @@ -429,7 +435,7 @@ def my_plotter(ax, data1, data2, param_dict): data = np.cumsum(np.random.randn(len(dates))) ax.plot(dates, data) cdf = mpl.dates.ConciseDateFormatter(ax.xaxis.get_major_locator()) -ax.xaxis.set_major_formatter(cdf); +ax.xaxis.set_major_formatter(cdf) ############################################################################## # For more information see the date examples @@ -441,7 +447,7 @@ def my_plotter(ax, data1, data2, param_dict): fig, ax = plt.subplots(figsize=(5, 2.7), layout='constrained') categories = ['turnips', 'rutabaga', 'cucumber', 'pumpkins'] -ax.bar(categories, np.random.rand(len(categories))); +ax.bar(categories, np.random.rand(len(categories))) ############################################################################## # One caveat about categorical plotting is that some methods of parsing @@ -555,17 +561,17 @@ def my_plotter(ax, data1, data2, param_dict): ['lowleft', 'right']], layout='constrained') axd['upleft'].set_title('upleft') axd['lowleft'].set_title('lowleft') -axd['right'].set_title('right'); +axd['right'].set_title('right') ############################################################################### # Matplotlib has quite sophisticated tools for arranging Axes: See # :doc:`/tutorials/intermediate/arranging_axes` and -# :doc:`/tutorials/provisional/mosaic`. +# :doc:`/gallery/subplots_axes_and_figures/mosaic`. # # # More reading # ============ # # For more plot types see :doc:`Plot types ` and the -# :doc:`API reference `, in particlar the +# :doc:`API reference `, in particular the # :doc:`Axes API `. diff --git a/tutorials/text/README.txt b/tutorials/text/README.txt index 7ad6e9b14b18..4eaaa4de9c23 100644 --- a/tutorials/text/README.txt +++ b/tutorials/text/README.txt @@ -6,5 +6,5 @@ Text matplotlib has extensive text support, including support for mathematical expressions, truetype support for raster and vector outputs, newline separated text with arbitrary -rotations, and unicode support. These tutorials cover +rotations, and Unicode support. These tutorials cover the basics of working with text in Matplotlib. diff --git a/tutorials/text/annotations.py b/tutorials/text/annotations.py index 232da9cd2d9e..bee2e244238d 100644 --- a/tutorials/text/annotations.py +++ b/tutorials/text/annotations.py @@ -2,33 +2,42 @@ Annotations =========== -Annotating text with Matplotlib. +Annotations are graphical elements, often pieces of text, that explain, add +context to, or otherwise highlight some portion of the visualized data. +`~.Axes.annotate` supports a number of coordinate systems for flexibly +positioning data and annotations relative to each other and a variety of +options of for styling the text. Axes.annotate also provides an optional arrow +from the text to the data and this arrow can be styled in various ways. +`~.Axes.text` can also be used for simple text annotation, but does not +provide as much flexibility in positioning and styling as `~.Axes.annotate`. .. contents:: Table of Contents :depth: 3 """ - -from matplotlib import pyplot as plt - ############################################################################### # .. _annotations-tutorial: # # Basic annotation # ---------------- # -# The uses of the basic :func:`~matplotlib.pyplot.text` will place text -# at an arbitrary position on the Axes. A common use case of text is to -# annotate some feature of the plot, and the -# :func:`~matplotlib.axes.Axes.annotate` method provides helper functionality -# to make annotations easy. In an annotation, there are two points to -# consider: the location being annotated represented by the argument -# *xy* and the location of the text *xytext*. Both of these -# arguments are ``(x, y)`` tuples. -# -# .. figure:: ../../gallery/pyplots/images/sphx_glr_annotation_basic_001.png -# :target: ../../gallery/pyplots/annotation_basic.html -# :align: center -# +# In an annotation, there are two points to consider: the location of the data +# being annotated *xy* and the location of the annotation text *xytext*. Both +# of these arguments are ``(x, y)`` tuples: + +import numpy as np +import matplotlib.pyplot as plt + +fig, ax = plt.subplots(figsize=(3, 3)) + +t = np.arange(0.0, 5.0, 0.01) +s = np.cos(2*np.pi*t) +line, = ax.plot(t, s, lw=2) + +ax.annotate('local max', xy=(2, 1), xytext=(3, 1.5), + arrowprops=dict(facecolor='black', shrink=0.05)) +ax.set_ylim(-2, 2) + +############################################################################### # In this example, both the *xy* (arrow tip) and *xytext* locations # (text location) are in data coordinates. There are a variety of other # coordinate systems one can choose -- you can specify the coordinate @@ -47,22 +56,50 @@ # 'data' use the axes data coordinate system # ================== ======================================================== # -# For example to place the text coordinates in fractional axes -# coordinates, one could do:: +# The following strings are also valid arguments for *textcoords* # -# ax.annotate('local max', xy=(3, 1), xycoords='data', -# xytext=(0.8, 0.95), textcoords='axes fraction', -# arrowprops=dict(facecolor='black', shrink=0.05), -# horizontalalignment='right', verticalalignment='top', -# ) +# ================== ======================================================== +# argument coordinate system +# ================== ======================================================== +# 'offset points' offset (in points) from the xy value +# 'offset pixels' offset (in pixels) from the xy value +# ================== ======================================================== # # For physical coordinate systems (points or pixels) the origin is the -# bottom-left of the figure or axes. +# bottom-left of the figure or axes. Points are +# `typographic points `_ +# meaning that they are a physical unit measuring 1/72 of an inch. Points and +# pixels are discussed in further detail in :ref:`transforms-fig-scale-dpi`. # -# Optionally, you can enable drawing of an arrow from the text to the annotated -# point by giving a dictionary of arrow properties in the optional keyword -# argument *arrowprops*. +# .. _annotation-data: +# +# Annotating data +# ~~~~~~~~~~~~~~~ +# +# This example places the text coordinates in fractional axes coordinates: + +fig, ax = plt.subplots(figsize=(3, 3)) + +t = np.arange(0.0, 5.0, 0.01) +s = np.cos(2*np.pi*t) +line, = ax.plot(t, s, lw=2) + +ax.annotate('local max', xy=(2, 1), xycoords='data', + xytext=(0.01, .99), textcoords='axes fraction', + va='top', ha='left', + arrowprops=dict(facecolor='black', shrink=0.05)) +ax.set_ylim(-2, 2) + +################################################################### +# +# .. _annotation-with-arrow: # +# Annotating with arrows +# ~~~~~~~~~~~~~~~~~~~~~~ +# +# You can enable drawing of an arrow from the text to the annotated point +# by giving a dictionary of arrow properties in the optional keyword +# argument *arrowprops*. # # ==================== ===================================================== # *arrowprops* key description @@ -77,59 +114,77 @@ # e.g., ``facecolor`` # ==================== ===================================================== # -# -# In the example below, the *xy* point is in native coordinates -# (*xycoords* defaults to 'data'). For a polar axes, this is in -# (theta, radius) space. The text in this example is placed in the +# In the example below, the *xy* point is in the data coordinate system +# since *xycoords* defaults to 'data'. For a polar axes, this is in +# (theta, radius) space. The text in this example is placed in the # fractional figure coordinate system. :class:`matplotlib.text.Text` # keyword arguments like *horizontalalignment*, *verticalalignment* and # *fontsize* are passed from `~matplotlib.axes.Axes.annotate` to the # ``Text`` instance. + +fig = plt.figure() +ax = fig.add_subplot(projection='polar') +r = np.arange(0, 1, 0.001) +theta = 2 * 2*np.pi * r +line, = ax.plot(theta, r, color='#ee8d18', lw=3) + +ind = 800 +thisr, thistheta = r[ind], theta[ind] +ax.plot([thistheta], [thisr], 'o') +ax.annotate('a polar annotation', + xy=(thistheta, thisr), # theta, radius + xytext=(0.05, 0.05), # fraction, fraction + textcoords='figure fraction', + arrowprops=dict(facecolor='black', shrink=0.05), + horizontalalignment='left', + verticalalignment='bottom') + +############################################################################### +# For more on plotting with arrows, see :ref:`annotation_with_custom_arrow` # -# .. figure:: ../../gallery/pyplots/images/sphx_glr_annotation_polar_001.png -# :target: ../../gallery/pyplots/annotation_polar.html -# :align: center -# -# For more on all the wild and wonderful things you can do with -# annotations, including fancy arrows, see :ref:`plotting-guide-annotation` -# and :doc:`/gallery/text_labels_and_annotations/annotation_demo`. -# -# -# Do not proceed unless you have already read :ref:`annotations-tutorial`, -# :func:`~matplotlib.pyplot.text` and :func:`~matplotlib.pyplot.annotate`! +# .. _annotations-offset-text: # +# Placing text annotations relative to data +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Annotations can be positioned at a relative offset to the *xy* input to +# annotation by setting the *textcoords* keyword argument to ``'offset points'`` +# or ``'offset pixels'``. + +fig, ax = plt.subplots(figsize=(3, 3)) +x = [1, 3, 5, 7, 9] +y = [2, 4, 6, 8, 10] +annotations = ["A", "B", "C", "D", "E"] +ax.scatter(x, y, s=20) + +for xi, yi, text in zip(x, y, annotations): + ax.annotate(text, + xy=(xi, yi), xycoords='data', + xytext=(1.5, 1.5), textcoords='offset points') + +############################################################################### +# The annotations are offset 1.5 points (1.5*1/72 inches) from the *xy* values. # # .. _plotting-guide-annotation: # -# Advanced Annotations -# -------------------- +# Advanced annotation +# ------------------- # -# Annotating with Text with Box -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# Let's start with a simple example. +# We recommend reading :ref:`annotations-tutorial`, :func:`~matplotlib.pyplot.text` +# and :func:`~matplotlib.pyplot.annotate` before reading this section. # -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_text_arrow_001.png -# :target: ../../gallery/userdemo/annotate_text_arrow.html -# :align: center +# Annotating with boxed text +# ~~~~~~~~~~~~~~~~~~~~~~~~~~ # # `~.Axes.text` takes a *bbox* keyword argument, which draws a box around the -# text:: -# -# t = ax.text( -# 0, 0, "Direction", ha="center", va="center", rotation=45, size=15, -# bbox=dict(boxstyle="rarrow,pad=0.3", fc="cyan", ec="b", lw=2)) -# -# The patch object associated with the text can be accessed by:: -# -# bb = t.get_bbox_patch() -# -# The return value is a `.FancyBboxPatch`; patch properties -# (facecolor, edgewidth, etc.) can be accessed and modified as usual. -# `.FancyBboxPatch.set_boxstyle` sets the box shape:: -# -# bb.set_boxstyle("rarrow", pad=0.6) -# +# text: + +fig, ax = plt.subplots(figsize=(5, 5)) +t = ax.text(0.5, 0.5, "Direction", + ha="center", va="center", rotation=45, size=15, + bbox=dict(boxstyle="rarrow,pad=0.3", + fc="lightblue", ec="steelblue", lw=2)) + +############################################################################### # The arguments are the name of the box style with its attributes as # keyword arguments. Currently, following box styles are implemented. # @@ -138,6 +193,7 @@ # ========== ============== ========================== # Circle ``circle`` pad=0.3 # DArrow ``darrow`` pad=0.3 +# Ellipse ``ellipse`` pad=0.3 # LArrow ``larrow`` pad=0.3 # RArrow ``rarrow`` pad=0.3 # Round ``round`` pad=0.3,rounding_size=None @@ -151,43 +207,82 @@ # :target: ../../gallery/shapes_and_collections/fancybox_demo.html # :align: center # -# Note that the attribute arguments can be specified within the style -# name with separating comma (this form can be used as "boxstyle" value -# of bbox argument when initializing the text instance) :: +# The patch object (box) associated with the text can be accessed using:: # -# bb.set_boxstyle("rarrow,pad=0.6") +# bb = t.get_bbox_patch() # -# Annotating with Arrow -# ~~~~~~~~~~~~~~~~~~~~~ +# The return value is a `.FancyBboxPatch`; patch properties +# (facecolor, edgewidth, etc.) can be accessed and modified as usual. +# `.FancyBboxPatch.set_boxstyle` sets the box shape:: # -# `~.Axes.annotate` draws an arrow connecting two points in an axes:: +# bb.set_boxstyle("rarrow", pad=0.6) # -# ax.annotate("Annotation", -# xy=(x1, y1), xycoords='data', -# xytext=(x2, y2), textcoords='offset points', -# ) +# The attribute arguments can also be specified within the style +# name with separating comma:: # -# This annotates a point at *xy* in the given coordinate (*xycoords*) -# with the text at *xytext* given in *textcoords*. Often, the -# annotated point is specified in the *data* coordinate and the annotating -# text in *offset points*. -# See `~.Axes.annotate` for available coordinate systems. +# bb.set_boxstyle("rarrow, pad=0.6") # -# An arrow connecting *xy* to *xytext* can be optionally drawn by -# specifying the *arrowprops* argument. To draw only an arrow, use -# empty string as the first argument. :: # -# ax.annotate("", -# xy=(0.2, 0.2), xycoords='data', -# xytext=(0.8, 0.8), textcoords='data', -# arrowprops=dict(arrowstyle="->", -# connectionstyle="arc3"), -# ) +# Defining custom box styles +# ~~~~~~~~~~~~~~~~~~~~~~~~~~ # -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple01_001.png -# :target: ../../gallery/userdemo/annotate_simple01.html -# :align: center +# You can use a custom box style. The value for the ``boxstyle`` can be a +# callable object in the following forms: + +from matplotlib.path import Path + + +def custom_box_style(x0, y0, width, height, mutation_size): + """ + Given the location and size of the box, return the path of the box around + it. Rotation is automatically taken care of. + + Parameters + ---------- + x0, y0, width, height : float + Box location and size. + mutation_size : float + Mutation reference scale, typically the text font size. + """ + # padding + mypad = 0.3 + pad = mutation_size * mypad + # width and height with padding added. + width = width + 2 * pad + height = height + 2 * pad + # boundary of the padded box + x0, y0 = x0 - pad, y0 - pad + x1, y1 = x0 + width, y0 + height + # return the new path + return Path([(x0, y0), (x1, y0), (x1, y1), (x0, y1), + (x0-pad, (y0+y1)/2), (x0, y0), (x0, y0)], + closed=True) + +fig, ax = plt.subplots(figsize=(3, 3)) +ax.text(0.5, 0.5, "Test", size=30, va="center", ha="center", rotation=30, + bbox=dict(boxstyle=custom_box_style, alpha=0.2)) + +############################################################################### +# See also :doc:`/gallery/userdemo/custom_boxstyle01`. Similarly, you can define a +# custom `.ConnectionStyle` and a custom `.ArrowStyle`. View the source code at +# `.patches` to learn how each class is defined. +# +# .. _annotation_with_custom_arrow: # +# Customizing annotation arrows +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# An arrow connecting *xy* to *xytext* can be optionally drawn by +# specifying the *arrowprops* argument. To draw only an arrow, use +# empty string as the first argument: + +fig, ax = plt.subplots(figsize=(3, 3)) +ax.annotate("", + xy=(0.2, 0.2), xycoords='data', + xytext=(0.8, 0.8), textcoords='data', + arrowprops=dict(arrowstyle="->", connectionstyle="arc3")) + +############################################################################### # The arrow is drawn as follows: # # 1. A path connecting the two points is created, as specified by the @@ -222,7 +317,7 @@ # # The behavior of each connection style is (limitedly) demonstrated in the # example below. (Warning: The behavior of the ``bar`` style is currently not -# well defined, it may be changed in the future). +# well-defined and may be changed in the future). # # .. figure:: ../../gallery/userdemo/images/sphx_glr_connectionstyle_demo_001.png # :target: ../../gallery/userdemo/connectionstyle_demo.html @@ -257,31 +352,64 @@ # For these arrow styles, you must use the "angle3" or "arc3" connection # style. # -# If the annotation string is given, the patchA is set to the bbox patch +# If the annotation string is given, the patch is set to the bbox patch # of the text by default. -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple02_001.png -# :target: ../../gallery/userdemo/annotate_simple02.html -# :align: center -# + +fig, ax = plt.subplots(figsize=(3, 3)) + +ax.annotate("Test", + xy=(0.2, 0.2), xycoords='data', + xytext=(0.8, 0.8), textcoords='data', + size=20, va="center", ha="center", + arrowprops=dict(arrowstyle="simple", + connectionstyle="arc3,rad=-0.2")) + +############################################################################## # As with `~.Axes.text`, a box around the text can be drawn using the *bbox* # argument. -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple03_001.png -# :target: ../../gallery/userdemo/annotate_simple03.html -# :align: center -# + +fig, ax = plt.subplots(figsize=(3, 3)) + +ann = ax.annotate("Test", + xy=(0.2, 0.2), xycoords='data', + xytext=(0.8, 0.8), textcoords='data', + size=20, va="center", ha="center", + bbox=dict(boxstyle="round4", fc="w"), + arrowprops=dict(arrowstyle="-|>", + connectionstyle="arc3,rad=-0.2", + fc="w")) + +############################################################################## # By default, the starting point is set to the center of the text # extent. This can be adjusted with ``relpos`` key value. The values # are normalized to the extent of the text. For example, (0, 0) means # lower-left corner and (1, 1) means top-right. -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple04_001.png -# :target: ../../gallery/userdemo/annotate_simple04.html -# :align: center -# -# Placing Artist at the anchored location of the Axes -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +fig, ax = plt.subplots(figsize=(3, 3)) + +ann = ax.annotate("Test", + xy=(0.2, 0.2), xycoords='data', + xytext=(0.8, 0.8), textcoords='data', + size=20, va="center", ha="center", + bbox=dict(boxstyle="round4", fc="w"), + arrowprops=dict(arrowstyle="-|>", + connectionstyle="arc3,rad=0.2", + relpos=(0., 0.), + fc="w")) + +ann = ax.annotate("Test", + xy=(0.2, 0.2), xycoords='data', + xytext=(0.8, 0.8), textcoords='data', + size=20, va="center", ha="center", + bbox=dict(boxstyle="round4", fc="w"), + arrowprops=dict(arrowstyle="-|>", + connectionstyle="arc3,rad=-0.2", + relpos=(1., 0.), + fc="w")) + +############################################################################## +# Placing Artist at anchored Axes locations +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # There are classes of artists that can be placed at an anchored # location in the Axes. A common example is the legend. This type @@ -291,9 +419,9 @@ from matplotlib.offsetbox import AnchoredText -fig, ax = plt.subplots() -at = AnchoredText( - "Figure 1a", prop=dict(size=15), frameon=True, loc='upper left') +fig, ax = plt.subplots(figsize=(3, 3)) +at = AnchoredText("Figure 1a", + prop=dict(size=15), frameon=True, loc='upper left') at.patch.set_boxstyle("round,pad=0.,rounding_size=0.2") ax.add_artist(at) @@ -304,10 +432,10 @@ # artists) is known in pixel size during the time of creation. For # example, If you want to draw a circle with fixed size of 20 pixel x 20 # pixel (radius = 10 pixel), you can utilize -# ``AnchoredDrawingArea``. The instance is created with a size of the -# drawing area (in pixels), and arbitrary artists can added to the -# drawing area. Note that the extents of the artists that are added to -# the drawing area are not related to the placement of the drawing +# `~mpl_toolkits.axes_grid1.anchored_artists.AnchoredDrawingArea`. The instance +# is created with a size of the drawing area (in pixels), and arbitrary artists +# can be added to the drawing area. Note that the extents of the artists that are +# added to the drawing area are not related to the placement of the drawing # area itself. Only the initial size matters. # # The artists that are added to the drawing area should not have a @@ -318,7 +446,7 @@ from matplotlib.patches import Circle from mpl_toolkits.axes_grid1.anchored_artists import AnchoredDrawingArea -fig, ax = plt.subplots() +fig, ax = plt.subplots(figsize=(3, 3)) ada = AnchoredDrawingArea(40, 20, 0, 0, loc='upper right', pad=0., frameon=False) p1 = Circle((10, 10), 10) @@ -330,9 +458,11 @@ ############################################################################### # Sometimes, you want your artists to scale with the data coordinate (or # coordinates other than canvas pixels). You can use -# ``AnchoredAuxTransformBox`` class. This is similar to -# ``AnchoredDrawingArea`` except that the extent of the artist is -# determined during the drawing time respecting the specified transform. +# `~mpl_toolkits.axes_grid1.anchored_artists.AnchoredAuxTransformBox` class. +# This is similar to +# `~mpl_toolkits.axes_grid1.anchored_artists.AnchoredDrawingArea` except that +# the extent of the artist is determined during the drawing time respecting the +# specified transform. # # The ellipse in the example below will have width and height # corresponding to 0.1 and 0.4 in data coordinates and will be @@ -341,167 +471,202 @@ from matplotlib.patches import Ellipse from mpl_toolkits.axes_grid1.anchored_artists import AnchoredAuxTransformBox -fig, ax = plt.subplots() +fig, ax = plt.subplots(figsize=(3, 3)) box = AnchoredAuxTransformBox(ax.transData, loc='upper left') el = Ellipse((0, 0), width=0.1, height=0.4, angle=30) # in data coordinates! box.drawing_area.add_artist(el) ax.add_artist(box) ############################################################################### -# As in the legend, the bbox_to_anchor argument can be set. Using the -# HPacker and VPacker, you can have an arrangement(?) of artist as in the -# legend (as a matter of fact, this is how the legend is created). -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_anchored_box04_001.png -# :target: ../../gallery/userdemo/anchored_box04.html -# :align: center -# -# Note that unlike the legend, the ``bbox_transform`` is set -# to IdentityTransform by default. -# -# Using Complex Coordinates with Annotations -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# The Annotation in matplotlib supports several types of coordinates as -# described in :ref:`annotations-tutorial`. For an advanced user who wants -# more control, it supports a few other options. -# -# 1. A `.Transform` instance. For example, :: -# -# ax.annotate("Test", xy=(0.5, 0.5), xycoords=ax.transAxes) -# -# is identical to :: +# Another method of anchoring an artist relative to a parent axes or anchor +# point is via the *bbox_to_anchor* argument of `.AnchoredOffsetbox`. This +# artist can then be automatically positioned relative to another artist using +# `.HPacker` and `.VPacker`: + +from matplotlib.offsetbox import (AnchoredOffsetbox, DrawingArea, HPacker, + TextArea) + +fig, ax = plt.subplots(figsize=(3, 3)) + +box1 = TextArea(" Test: ", textprops=dict(color="k")) +box2 = DrawingArea(60, 20, 0, 0) + +el1 = Ellipse((10, 10), width=16, height=5, angle=30, fc="r") +el2 = Ellipse((30, 10), width=16, height=5, angle=170, fc="g") +el3 = Ellipse((50, 10), width=16, height=5, angle=230, fc="b") +box2.add_artist(el1) +box2.add_artist(el2) +box2.add_artist(el3) + +box = HPacker(children=[box1, box2], + align="center", + pad=0, sep=5) + +anchored_box = AnchoredOffsetbox(loc='lower left', + child=box, pad=0., + frameon=True, + bbox_to_anchor=(0., 1.02), + bbox_transform=ax.transAxes, + borderpad=0.,) + +ax.add_artist(anchored_box) +fig.subplots_adjust(top=0.8) + +############################################################################### +# Note that, unlike in `.Legend`, the ``bbox_transform`` is set to +# `.IdentityTransform` by default # -# ax.annotate("Test", xy=(0.5, 0.5), xycoords="axes fraction") +# .. _annotating_coordinate_systems: # -# This allows annotating a point in another axes:: +# Coordinate systems for annotations +# ---------------------------------- # -# fig, (ax1, ax2) = plt.subplots(1, 2) -# ax2.annotate("Test", xy=(0.5, 0.5), xycoords=ax1.transData, -# xytext=(0.5, 0.5), textcoords=ax2.transData, -# arrowprops=dict(arrowstyle="->")) +# Matplotlib Annotations support several types of coordinate systems. The +# examples in :ref:`annotations-tutorial` used the ``data`` coordinate system; +# Some others more advanced options are: # +# 1. A `.Transform` instance. For more information on transforms, see the +# :doc:`../advanced/transforms_tutorial` For example, the +# ``Axes.transAxes`` transform positions the annotation relative to the Axes +# coordinates and using it is therefore identical to setting the +# coordinate system to "axes fraction": + +fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2, figsize=(6, 3)) +ax1.annotate("Test", xy=(0.5, 0.5), xycoords=ax1.transAxes) +ax2.annotate("Test", xy=(0.5, 0.5), xycoords="axes fraction") + +############################################################################### +# Another commonly used `.Transform` instance is ``Axes.transData``. This +# transform is the coordinate system of the data plotted in the axes. In this +# example, it is used to draw an arrow from a point in *ax1* to text in *ax2*, +# where the point and text are positioned relative to the coordinates of *ax1* +# and *ax2* respectively: + +fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2, figsize=(6, 3)) + +ax1.annotate("Test1", xy=(0.5, 0.5), xycoords="axes fraction") +ax2.annotate("Test2", + xy=(0.5, 0.5), xycoords=ax1.transData, + xytext=(0.5, 0.5), textcoords=ax2.transData, + arrowprops=dict(arrowstyle="->")) + +############################################################################# # 2. An `.Artist` instance. The *xy* value (or *xytext*) is interpreted as a -# fractional coordinate of the bbox (return value of *get_window_extent*) of -# the artist:: -# -# an1 = ax.annotate("Test 1", xy=(0.5, 0.5), xycoords="data", -# va="center", ha="center", -# bbox=dict(boxstyle="round", fc="w")) -# an2 = ax.annotate("Test 2", xy=(1, 0.5), xycoords=an1, # (1, 0.5) of the an1's bbox -# xytext=(30, 0), textcoords="offset points", -# va="center", ha="left", -# bbox=dict(boxstyle="round", fc="w"), -# arrowprops=dict(arrowstyle="->")) -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple_coord01_001.png -# :target: ../../gallery/userdemo/annotate_simple_coord01.html -# :align: center -# -# Note that you must ensure that the extent of the coordinate artist (*an1* in -# above example) is determined before *an2* gets drawn. Usually, this means -# that *an2* needs to be drawn after *an1*. +# fractional coordinate of the bounding box (bbox) of the artist: + +fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(3, 3)) +an1 = ax.annotate("Test 1", + xy=(0.5, 0.5), xycoords="data", + va="center", ha="center", + bbox=dict(boxstyle="round", fc="w")) + +an2 = ax.annotate("Test 2", + xy=(1, 0.5), xycoords=an1, # (1, 0.5) of an1's bbox + xytext=(30, 0), textcoords="offset points", + va="center", ha="left", + bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->")) + +############################################################################### +# Note that you must ensure that the extent of the coordinate artist (*an1* in +# this example) is determined before *an2* gets drawn. Usually, this means +# that *an2* needs to be drawn after *an1*. The base class for all bounding +# boxes is `.BboxBase` # # 3. A callable object that takes the renderer instance as single argument, and -# returns either a `.Transform` or a `.BboxBase`. The return value is then -# handled as in (1), for transforms, or in (2), for bboxes. For example, :: -# -# an2 = ax.annotate("Test 2", xy=(1, 0.5), xycoords=an1, -# xytext=(30, 0), textcoords="offset points") -# -# is identical to:: -# -# an2 = ax.annotate("Test 2", xy=(1, 0.5), xycoords=an1.get_window_extent, -# xytext=(30, 0), textcoords="offset points") -# -# 4. A pair of coordinate specifications -- the first for the x-coordinate, and -# the second is for the y-coordinate; e.g. :: -# -# annotate("Test", xy=(0.5, 1), xycoords=("data", "axes fraction")) -# -# Here, 0.5 is in data coordinates, and 1 is in normalized axes coordinates. -# Each of the coordinate specifications can also be an artist or a transform. -# For example, -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple_coord02_001.png -# :target: ../../gallery/userdemo/annotate_simple_coord02.html -# :align: center -# +# returns either a `.Transform` or a `.BboxBase`. For example, the return +# value of `.Artist.get_window_extent` is a bbox, so this method is identical +# to (2) passing in the artist: + +fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(3, 3)) +an1 = ax.annotate("Test 1", + xy=(0.5, 0.5), xycoords="data", + va="center", ha="center", + bbox=dict(boxstyle="round", fc="w")) + +an2 = ax.annotate("Test 2", + xy=(1, 0.5), xycoords=an1.get_window_extent, + xytext=(30, 0), textcoords="offset points", + va="center", ha="left", + bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->")) + +############################################################################### +# `.Artist.get_window_extent` is the bounding box of the Axes object and is +# therefore identical to setting the coordinate system to axes fraction: + +fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2, figsize=(6, 3)) + +an1 = ax1.annotate("Test1", xy=(0.5, 0.5), xycoords="axes fraction") +an2 = ax2.annotate("Test 2", xy=(0.5, 0.5), xycoords=ax2.get_window_extent) + +############################################################################### +# 4. A blended pair of coordinate specifications -- the first for the +# x-coordinate, and the second is for the y-coordinate. For example, x=0.5 is +# in data coordinates, and y=1 is in normalized axes coordinates: + +fig, ax = plt.subplots(figsize=(3, 3)) +ax.annotate("Test", xy=(0.5, 1), xycoords=("data", "axes fraction")) +ax.axvline(x=.5, color='lightgray') +ax.set(xlim=(0, 2), ylim=(1, 2)) + +############################################################################### # 5. Sometimes, you want your annotation with some "offset points", not from the -# annotated point but from some other point. `.text.OffsetFrom` is a helper -# for such cases. -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_annotate_simple_coord03_001.png -# :target: ../../gallery/userdemo/annotate_simple_coord03.html -# :align: center -# -# You may take a look at this example -# :doc:`/gallery/text_labels_and_annotations/annotation_demo`. -# +# annotated point but from some other point or artist. `.text.OffsetFrom` is +# a helper for such cases. + +from matplotlib.text import OffsetFrom + +fig, ax = plt.subplots(figsize=(3, 3)) +an1 = ax.annotate("Test 1", xy=(0.5, 0.5), xycoords="data", + va="center", ha="center", + bbox=dict(boxstyle="round", fc="w")) + +offset_from = OffsetFrom(an1, (0.5, 0)) +an2 = ax.annotate("Test 2", xy=(0.1, 0.1), xycoords="data", + xytext=(0, -10), textcoords=offset_from, + # xytext is offset points from "xy=(0.5, 0), xycoords=an1" + va="top", ha="center", + bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->")) + +############################################################################### # Using ConnectionPatch # ~~~~~~~~~~~~~~~~~~~~~ # -# ConnectionPatch is like an annotation without text. While `~.Axes.annotate` -# is sufficient in most situations, ConnectionPatch is useful when you want to -# connect points in different axes. :: -# -# from matplotlib.patches import ConnectionPatch -# xy = (0.2, 0.2) -# con = ConnectionPatch(xyA=xy, coordsA=ax1.transData, -# xyB=xy, coordsB=ax2.transData) -# fig.add_artist(con) -# -# The above code connects point *xy* in the data coordinates of ``ax1`` to -# point *xy* in the data coordinates of ``ax2``. Here is a simple example. -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_connect_simple01_001.png -# :target: ../../gallery/userdemo/connect_simple01.html -# :align: center -# -# Here, we added the ConnectionPatch to the *figure* (with `~.Figure.add_artist`) -# rather than to either axes: this ensures that it is drawn on top of both axes, -# and is also necessary if using :doc:`constrained_layout +# `.ConnectionPatch` is like an annotation without text. While `~.Axes.annotate` +# is sufficient in most situations, `.ConnectionPatch` is useful when you want +# to connect points in different axes. For example, here we connect the point +# *xy* in the data coordinates of ``ax1`` to point *xy* in the data coordinates +# of ``ax2``: + +from matplotlib.patches import ConnectionPatch + +fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2, figsize=(6, 3)) +xy = (0.3, 0.2) +con = ConnectionPatch(xyA=xy, coordsA=ax1.transData, + xyB=xy, coordsB=ax2.transData) + +fig.add_artist(con) + +############################################################################### +# Here, we added the `.ConnectionPatch` to the *figure* +# (with `~.Figure.add_artist`) rather than to either axes. This ensures that +# the ConnectionPatch artist is drawn on top of both axes, and is also necessary +# when using :doc:`constrained_layout # ` for positioning the axes. # -# Advanced Topics -# --------------- -# # Zoom effect between Axes # ~~~~~~~~~~~~~~~~~~~~~~~~ # -# ``mpl_toolkits.axes_grid1.inset_locator`` defines some patch classes useful for -# interconnecting two axes. Understanding the code requires some knowledge of -# Matplotlib's transform system. +# `mpl_toolkits.axes_grid1.inset_locator` defines some patch classes useful for +# interconnecting two axes. # # .. figure:: ../../gallery/subplots_axes_and_figures/images/sphx_glr_axes_zoom_effect_001.png # :target: ../../gallery/subplots_axes_and_figures/axes_zoom_effect.html # :align: center # -# Define Custom BoxStyle -# ~~~~~~~~~~~~~~~~~~~~~~ -# -# You can use a custom box style. The value for the ``boxstyle`` can be a -# callable object in the following forms.:: -# -# def __call__(self, x0, y0, width, height, mutation_size, -# aspect_ratio=1.): -# ''' -# Given the location and size of the box, return the path of -# the box around it. -# -# - *x0*, *y0*, *width*, *height* : location and size of the box -# - *mutation_size* : a reference scale for the mutation. -# - *aspect_ratio* : aspect-ratio for the mutation. -# ''' -# path = ... -# return path -# -# Here is a complete example. -# -# .. figure:: ../../gallery/userdemo/images/sphx_glr_custom_boxstyle01_001.png -# :target: ../../gallery/userdemo/custom_boxstyle01.html -# :align: center -# -# Similarly, you can define a custom ConnectionStyle and a custom ArrowStyle. -# See the source code of ``lib/matplotlib/patches.py`` and check -# how each style class is defined. +# The code for this figure is at +# :doc:`/gallery/subplots_axes_and_figures/axes_zoom_effect` and +# familiarity with :doc:`../advanced/transforms_tutorial` +# is recommended. diff --git a/tutorials/text/mathtext.py b/tutorials/text/mathtext.py index 9c3264bb483a..2be0d67ca3b0 100644 --- a/tutorials/text/mathtext.py +++ b/tutorials/text/mathtext.py @@ -9,16 +9,16 @@ its own TeX expression parser, layout engine, and fonts. The layout engine is a fairly direct adaptation of the layout algorithms in Donald Knuth's TeX, so the quality is quite good (Matplotlib also provides a ``usetex`` -option for those who do want to call out to TeX to generate their text (see +option for those who do want to call out to TeX to generate their text; see :doc:`/tutorials/text/usetex`). Any text element can use math text. You should use raw strings (precede the quotes with an ``'r'``), and surround the math text with dollar signs ($), as in TeX. Regular text and mathtext can be interleaved within the same string. Mathtext can use DejaVu Sans (default), DejaVu Serif, the Computer Modern fonts -(from (La)TeX), `STIX `_ fonts (with are designed +(from (La)TeX), `STIX `_ fonts (which are designed to blend well with Times), or a Unicode font that you provide. The mathtext -font can be selected with the customization variable ``mathtext.fontset`` (see +font can be selected via :rc:`mathtext.fontset` (see :doc:`/tutorials/introductory/customizing`) Here is a simple example:: @@ -111,7 +111,7 @@ r'$(\frac{5 - \frac{1}{x}}{4})$' -.. math :: +.. math:: (\frac{5 - \frac{1}{x}}{4}) @@ -120,7 +120,7 @@ r'$\left(\frac{5 - \frac{1}{x}}{4}\right)$' -.. math :: +.. math:: \left(\frac{5 - \frac{1}{x}}{4}\right) @@ -130,7 +130,7 @@ r'$\sqrt{2}$' -.. math :: +.. math:: \sqrt{2} @@ -140,7 +140,7 @@ r'$\sqrt[3]{x}$' -.. math :: +.. math:: \sqrt[3]{x} @@ -354,7 +354,7 @@ ------- Here is an example illustrating many of these features in context. -.. figure:: ../../gallery/pyplots/images/sphx_glr_pyplot_mathtext_001.png - :target: ../../gallery/pyplots/pyplot_mathtext.html +.. figure:: ../../gallery/text_labels_and_annotations/images/sphx_glr_mathtext_demo_001.png + :target: ../../gallery/text_labels_and_annotations/mathtext_demo.html :align: center """ diff --git a/tutorials/text/pgf.py b/tutorials/text/pgf.py index e447c1910f6d..5cbf4cfa8b98 100644 --- a/tutorials/text/pgf.py +++ b/tutorials/text/pgf.py @@ -1,7 +1,7 @@ r""" -********************************************************* -Typesetting with XeLaTeX/LuaLaTeX via the ``pgf`` backend -********************************************************* +************************************************************ +Text rendering with XeLaTeX/LuaLaTeX via the ``pgf`` backend +************************************************************ Using the ``pgf`` backend, Matplotlib can export figures as pgf drawing commands that can be processed with pdflatex, xelatex or lualatex. XeLaTeX and diff --git a/tutorials/text/text_intro.py b/tutorials/text/text_intro.py index 1f2a7a1a28b6..42539302a730 100644 --- a/tutorials/text/text_intro.py +++ b/tutorials/text/text_intro.py @@ -8,7 +8,7 @@ Matplotlib has extensive text support, including support for mathematical expressions, truetype support for raster and vector outputs, newline separated text with arbitrary -rotations, and unicode support. +rotations, and Unicode support. Because it embeds fonts directly in output documents, e.g., for postscript or PDF, what you see on the screen is what you get in the hardcopy. @@ -31,11 +31,11 @@ Basic text commands =================== -The following commands are used to create text in the pyplot -interface and the object-oriented API: +The following commands are used to create text in the implicit and explicit +interfaces (see :ref:`api_interfaces` for an explanation of the tradeoffs): =================== =================== ====================================== -`.pyplot` API OO API description +implicit API explicit API description =================== =================== ====================================== `~.pyplot.text` `~.Axes.text` Add text at an arbitrary location of the `~matplotlib.axes.Axes`. @@ -63,6 +63,7 @@ configured with a variety of font and other properties. The example below shows all of these commands in action, and more detail is provided in the sections that follow. + """ import matplotlib @@ -87,7 +88,7 @@ ax.text(2, 6, r'an equation: $E=mc^2$', fontsize=15) -ax.text(3, 2, 'unicode: Institut für Festkörperphysik') +ax.text(3, 2, 'Unicode: Institut für Festkörperphysik') ax.text(0.95, 0.01, 'colored text in axes coords', verticalalignment='bottom', horizontalalignment='right', @@ -117,7 +118,7 @@ fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(bottom=0.15, left=0.2) ax.plot(x1, y1) -ax.set_xlabel('time [s]') +ax.set_xlabel('Time [s]') ax.set_ylabel('Damped oscillation [V]') plt.show() @@ -130,7 +131,7 @@ fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(bottom=0.15, left=0.2) ax.plot(x1, y1*10000) -ax.set_xlabel('time [s]') +ax.set_xlabel('Time [s]') ax.set_ylabel('Damped oscillation [V]') plt.show() @@ -143,7 +144,7 @@ fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(bottom=0.15, left=0.2) ax.plot(x1, y1*10000) -ax.set_xlabel('time [s]') +ax.set_xlabel('Time [s]') ax.set_ylabel('Damped oscillation [V]', labelpad=18) plt.show() @@ -158,7 +159,7 @@ fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(bottom=0.15, left=0.2) ax.plot(x1, y1) -ax.set_xlabel('time [s]', position=(0., 1e6), horizontalalignment='left') +ax.set_xlabel('Time [s]', position=(0., 1e6), horizontalalignment='left') ax.set_ylabel('Damped oscillation [V]') plt.show() @@ -178,7 +179,7 @@ fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(bottom=0.15, left=0.2) ax.plot(x1, y1) -ax.set_xlabel('time [s]', fontsize='large', fontweight='bold') +ax.set_xlabel('Time [s]', fontsize='large', fontweight='bold') ax.set_ylabel('Damped oscillation [V]', fontproperties=font) plt.show() @@ -190,7 +191,7 @@ fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(bottom=0.2, left=0.2) ax.plot(x1, np.cumsum(y1**2)) -ax.set_xlabel('time [s] \n This was a long experiment') +ax.set_xlabel('Time [s] \n This was a long experiment') ax.set_ylabel(r'$\int\ Y^2\ dt\ \ [V^2 s]$') plt.show() @@ -211,8 +212,8 @@ plt.show() ############################################################################## -# Vertical spacing for titles is controlled via :rc:`axes.titlepad`, which -# defaults to 5 points. Setting to a different value moves the title. +# Vertical spacing for titles is controlled via :rc:`axes.titlepad`. +# Setting to a different value moves the title. fig, ax = plt.subplots(figsize=(5, 3)) fig.subplots_adjust(top=0.8) @@ -249,7 +250,7 @@ # Simple ticks # ~~~~~~~~~~~~ # -# It often is convenient to simply define the +# It is often convenient to simply define the # tick values, and sometimes the tick labels, overriding the default # locators and formatters. This is discouraged because it breaks interactive # navigation of the plot. It also can reset the axis limits: note that @@ -282,7 +283,7 @@ # Tick Locators and Formatters # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # -# Instead of making a list of all the tickalbels, we could have +# Instead of making a list of all the ticklabels, we could have # used `matplotlib.ticker.StrMethodFormatter` (new-style ``str.format()`` # format string) or `matplotlib.ticker.FormatStrFormatter` (old-style '%' # format string) and passed it to the ``ax.xaxis``. A @@ -322,7 +323,7 @@ # ``nbins=auto`` uses an algorithm to determine how many ticks will # be acceptable based on how long the axis is. The fontsize of the # ticklabel is taken into account, but the length of the tick string -# is not (because its not yet known.) In the bottom row, the +# is not (because it's not yet known.) In the bottom row, the # ticklabels are quite large, so we set ``nbins=4`` to make the # labels fit in the right-hand plot. diff --git a/tutorials/text/text_props.py b/tutorials/text/text_props.py index fba757979c8f..a2acbd0bd27f 100644 --- a/tutorials/text/text_props.py +++ b/tutorials/text/text_props.py @@ -179,6 +179,11 @@ # generic-family aliases like (``{'cursive', 'fantasy', 'monospace', # 'sans', 'sans serif', 'sans-serif', 'serif'}``). # +# .. note:: +# To access the full list of available fonts: :: +# +# matplotlib.font_manager.get_font_names() +# # The mapping between the generic family aliases and actual font families # (mentioned at :doc:`default rcParams `) # is controlled by the following rcParams: @@ -209,6 +214,9 @@ # # This is effectively translated to: # matplotlib.rcParams['font.family'] = ['Family1', 'SerifFamily1', 'SerifFamily2', 'Family2'] # +# +# .. _font-nonlatin: +# # Text with non-latin glyphs # ========================== # diff --git a/tutorials/text/usetex.py b/tutorials/text/usetex.py index 608208414915..2b55864b18f6 100644 --- a/tutorials/text/usetex.py +++ b/tutorials/text/usetex.py @@ -19,59 +19,54 @@ itself (only LaTeX is supported). The executables for these external dependencies must all be located on your :envvar:`PATH`. -There are a couple of options to mention, which can be changed using -:doc:`rc settings `. Here is an example -matplotlibrc file:: - - font.family : serif - font.serif : Times, Palatino, New Century Schoolbook, Bookman, Computer Modern Roman - font.sans-serif : Helvetica, Avant Garde, Computer Modern Sans Serif - font.cursive : Zapf Chancery - font.monospace : Courier, Computer Modern Typewriter - - text.usetex : true - -The first valid font in each family is the one that will be loaded. If the -fonts are not specified, the Computer Modern fonts are used by default. All of -the other fonts are Adobe fonts. Times and Palatino each have their own -accompanying math fonts, while the other Adobe serif fonts make use of the -Computer Modern math fonts. See the PSNFSS_ documentation for more details. - -To use LaTeX and select Helvetica as the default font, without editing -matplotlibrc use:: - - import matplotlib.pyplot as plt - plt.rcParams.update({ - "text.usetex": True, - "font.family": "sans-serif", - "font.sans-serif": ["Helvetica"]}) - # for Palatino and other serif fonts use: - plt.rcParams.update({ - "text.usetex": True, - "font.family": "serif", - "font.serif": ["Palatino"], - }) - # It's also possible to use the reduced notation by directly setting font.family: - plt.rcParams.update({ - "text.usetex": True, - "font.family": "Helvetica" - }) - -Currently, the supported fonts are: - -================= ============================================================ -family fonts -================= ============================================================ -``'serif'`` ``'New Century Schoolbook'``, ``'Bookman'``, ``'Times'``, - ``'Palatino'``, ``'Charter'``, ``'Computer Modern Roman'`` - -``'sans-serif'`` ``'Helvetica'``, ``'Avant Garde'``, ``'Computer Modern - Serif'`` - -``'cursive'`` ``'Zapf Chancery'`` - -``'monospace'`` ``'Courier'``, ``'Computer Modern Typewriter'`` -================= ============================================================ +Only a small number of font families (defined by the PSNFSS_ scheme) are +supported. They are listed here, with the corresponding LaTeX font selection +commands and LaTeX packages, which are automatically used. + +=========================== ================================================= +generic family fonts +=========================== ================================================= +serif (``\rmfamily``) Computer Modern Roman, Palatino (``mathpazo``), + Times (``mathptmx``), Bookman (``bookman``), + New Century Schoolbook (``newcent``), + Charter (``charter``) + +sans-serif (``\sffamily``) Computer Modern Serif, Helvetica (``helvet``), + Avant Garde (``avant``) + +cursive (``\rmfamily``) Zapf Chancery (``chancery``) + +monospace (``\ttfamily``) Computer Modern Typewriter, Courier (``courier``) +=========================== ================================================= + +The default font family (which does not require loading any LaTeX package) is +Computer Modern. All other families are Adobe fonts. Times and Palatino each +have their own accompanying math fonts, while the other Adobe serif fonts make +use of the Computer Modern math fonts. + +To enable LaTeX and select a font, use e.g.:: + + plt.rcParams.update({ + "text.usetex": True, + "font.family": "Helvetica" + }) + +or equivalently, set your :doc:`matplotlibrc +` to:: + + text.usetex : true + font.family : Helvetica + +It is also possible to instead set ``font.family`` to one of the generic family +names and then configure the corresponding generic family; e.g.:: + + plt.rcParams.update({ + "text.usetex": True, + "font.family": "sans-serif", + "font.sans-serif": "Helvetica", + }) + +(this was the required approach until Matplotlib 3.5). Here is the standard example, :doc:`/gallery/text_labels_and_annotations/tex_demo`: @@ -86,13 +81,23 @@ Non-ASCII characters (e.g. the degree sign in the y-label above) are supported to the extent that they are supported by inputenc_. +.. note:: + For consistency with the non-usetex case, Matplotlib special-cases newlines, + so that single-newlines yield linebreaks (rather than being interpreted as + whitespace in standard LaTeX). + + Matplotlib uses the underscore_ package so that underscores (``_``) are + printed "as-is" in text mode (rather than causing an error as in standard + LaTeX). Underscores still introduce subscripts in math mode. + .. note:: Certain characters require special escaping in TeX, such as:: - # $ % & ~ _ ^ \ { } \( \) \[ \] + # $ % & ~ ^ \ { } \( \) \[ \] Therefore, these characters will behave differently depending on - :rc:`text.usetex`. + :rc:`text.usetex`. As noted above, underscores (``_``) do not require + escaping outside of math mode. PostScript options ================== @@ -164,5 +169,6 @@ .. _Poppler: https://poppler.freedesktop.org/ .. _PSNFSS: http://www.ctan.org/tex-archive/macros/latex/required/psnfss/psnfss2e.pdf .. _PSfrag: https://ctan.org/pkg/psfrag +.. _underscore: https://ctan.org/pkg/underscore .. _Xpdf: http://www.xpdfreader.com/ """ diff --git a/tutorials/toolkits/axes_grid.py b/tutorials/toolkits/axes_grid.py index c5417977b179..0206b7ed816b 100644 --- a/tutorials/toolkits/axes_grid.py +++ b/tutorials/toolkits/axes_grid.py @@ -1,9 +1,9 @@ r""" .. _axes_grid1_users-guide-index: -========================================== -Overview of :mod:`mpl_toolkits.axes_grid1` -========================================== +====================== +The axes_grid1 toolkit +====================== :mod:`.axes_grid1` provides the following features: @@ -39,7 +39,7 @@ * The position of each axes is determined at the drawing time (see AxesDivider_), so that the size of the entire grid fits in the given rectangle (like the aspect of axes). Note that in this example, - the paddings between axes are fixed even if you changes the figure + the paddings between axes are fixed even if you change the figure size. * Axes in the same column share their x-axis, and axes in the same row share @@ -68,7 +68,7 @@ Users typically do not need to directly instantiate dividers by calling `~.axes_grid1.axes_divider.AxesDivider`; instead, `~.axes_grid1.axes_divider.make_axes_locatable` can be used to create a divider -for an axes:: +for an Axes:: ax = subplot(1, 1, 1) divider = make_axes_locatable(ax) @@ -76,8 +76,8 @@ `.AxesDivider.append_axes` can then be used to create a new axes on a given side ("left", "right", "top", "bottom") of the original axes. -colorbar whose height (or width) in sync with the master axes -------------------------------------------------------------- +colorbar whose height (or width) is in sync with the main axes +-------------------------------------------------------------- .. figure:: ../../gallery/axes_grid1/images/sphx_glr_simple_colorbar_001.png :target: ../../gallery/axes_grid1/simple_colorbar.html @@ -119,36 +119,36 @@ ParasiteAxes ------------ -The ParasiteAxes is an axes whose location is identical to its host +The ParasiteAxes is an Axes whose location is identical to its host axes. The location is adjusted in the drawing time, thus it works even if the host change its location (e.g., images). In most cases, you first create a host axes, which provides a few -method that can be used to create parasite axes. They are *twinx*, -*twiny* (which are similar to twinx and twiny in the matplotlib) and -*twin*. *twin* takes an arbitrary transformation that maps between the -data coordinates of the host axes and the parasite axes. *draw* +methods that can be used to create parasite axes. They are ``twinx``, +``twiny`` (which are similar to ``twinx`` and ``twiny`` in the matplotlib) and +``twin``. ``twin`` takes an arbitrary transformation that maps between the +data coordinates of the host axes and the parasite axes. The ``draw`` method of the parasite axes are never called. Instead, host axes -collects artists in parasite axes and draw them as if they belong to +collects artists in parasite axes and draws them as if they belong to the host axes, i.e., artists in parasite axes are merged to those of the host axes and then drawn according to their zorder. The host and parasite axes modifies some of the axes behavior. For example, color cycle for plot lines are shared between host and parasites. Also, the legend command in host, creates a legend that includes lines in the -parasite axes. To create a host axes, you may use *host_subplot* or -*host_axes* command. +parasite axes. To create a host axes, you may use ``host_subplot`` or +``host_axes`` command. -Example 1. twinx +Example 1: twinx ~~~~~~~~~~~~~~~~ .. figure:: ../../gallery/axes_grid1/images/sphx_glr_parasite_simple_001.png :target: ../../gallery/axes_grid1/parasite_simple.html :align: center -Example 2. twin +Example 2: twin ~~~~~~~~~~~~~~~ -*twin* without a transform argument assumes that the parasite axes has the +``twin`` without a transform argument assumes that the parasite axes has the same data transform as the host. This can be useful when you want the top(or right)-axis to have different tick-locations, tick-labels, or tick-formatter for bottom(or left)-axis. :: @@ -177,7 +177,7 @@ is anchored to the (axes) bbox, similarly to legends. These artists derive from `.offsetbox.OffsetBox`, and the artist need to be drawn in canvas coordinates. There is limited support for arbitrary transforms. For example, -the ellipse in the example below will have width and height in data coordinate. +the ellipse in the example below will have width and height in data coordinates. .. figure:: ../../gallery/axes_grid1/images/sphx_glr_simple_anchored_artists_001.png :target: ../../gallery/axes_grid1/simple_anchored_artists.html @@ -268,13 +268,13 @@ .. code-block:: none - +--------+--------+--------+--------+ - | (2, 0) | (2, 1) | (2, 2) | (2, 3) | - +--------+--------+--------+--------+ - | (1, 0) | (1, 1) | (1, 2) | (1, 3) | - +--------+--------+--------+--------+ - | (0, 0) | (0, 1) | (0, 2) | (0, 3) | - +--------+--------+--------+--------+ + ┌────────┬────────┬────────┬────────┐ + │ (2, 0) │ (2, 1) │ (2, 2) │ (2, 3) │ + ├────────┼────────┼────────┼────────┤ + │ (1, 0) │ (1, 1) │ (1, 2) │ (1, 3) │ + ├────────┼────────┼────────┼────────┤ + │ (0, 0) │ (0, 1) │ (0, 2) │ (0, 3) │ + └────────┴────────┴────────┴────────┘ such that the bottom row has a fixed height of 2 (inches) and the top two rows have a height ratio of 2 (middle) to 3 (top). (For example, if the grid has diff --git a/tutorials/toolkits/axisartist.py b/tutorials/toolkits/axisartist.py index 7040a2f6a4d5..d05087e9f8b2 100644 --- a/tutorials/toolkits/axisartist.py +++ b/tutorials/toolkits/axisartist.py @@ -1,9 +1,7 @@ r""" -============================== -Overview of axisartist toolkit -============================== - -The axisartist toolkit tutorial. +====================== +The axisartist toolkit +====================== .. warning:: *axisartist* uses a custom Axes class @@ -45,7 +43,7 @@ callable method and it behaves as an original Axes.axis method in Matplotlib). -To create an axes, :: +To create an Axes, :: import mpl_toolkits.axisartist as AA fig = plt.figure() @@ -84,7 +82,7 @@ ---------------------------- Most commands in the axes_grid1 toolkit can take an axes_class keyword -argument, and the commands create an axes of the given class. For example, +argument, and the commands create an Axes of the given class. For example, to create a host subplot with axisartist.Axes, :: import mpl_toolkits.axisartist as AA @@ -267,14 +265,14 @@ 1. Changing tick locations and label. - Same as the original Matplotlib's axes:: + Same as the original Matplotlib's axes:: - ax.set_xticks([1, 2, 3]) + ax.set_xticks([1, 2, 3]) 2. Changing axis properties like color, etc. - Change the properties of appropriate artists. For example, to change - the color of the ticklabels:: + Change the properties of appropriate artists. For example, to change + the color of the ticklabels:: ax.axis["left"].major_ticklabels.set_color("r") @@ -340,18 +338,18 @@ default setting of above properties for each, "bottom", "left", "top", and "right" axis. - ========== =========== ========= ========== ========= ========== - ? ? left bottom right top - ---------- ----------- --------- ---------- --------- ---------- - axislabel direction '-' '+' '+' '-' - axislabel rotation 180 0 0 180 - axislabel va center top center bottom - axislabel ha right center right center - ticklabel direction '-' '+' '+' '-' - ticklabels rotation 90 0 -90 180 - ticklabel ha right center right center - ticklabel va center baseline center baseline - ========== =========== ========= ========== ========= ========== +========== =========== ========= ========== ========= ========== + ? ? left bottom right top +---------- ----------- --------- ---------- --------- ---------- +axislabel direction '-' '+' '+' '-' +axislabel rotation 180 0 0 180 +axislabel va center top center bottom +axislabel ha right center right center +ticklabel direction '-' '+' '+' '-' +ticklabels rotation 90 0 -90 180 +ticklabel ha right center right center +ticklabel va center baseline center baseline +========== =========== ========= ========== ========= ========== And, 'set_axis_direction("top")' means to adjust the text rotation etc, for settings suitable for "top" axis. The concept of axis @@ -490,10 +488,10 @@ def inv_tr(x, y): # has a cycle of 360 degree. # The second coordinate (latitude, but radius in polar) has a minimum of 0 extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, - lon_cycle = 360, - lat_cycle = None, - lon_minmax = None, - lat_minmax = (0, np.inf), + lon_cycle=360, + lat_cycle=None, + lon_minmax=None, + lat_minmax=(0, np.inf), ) # Find a grid values appropriate for the coordinate (degree, @@ -519,10 +517,9 @@ def inv_tr(x, y): ax1 = SubplotHost(fig, 1, 2, 2, grid_helper=grid_helper) # A parasite axes with given transform - ax2 = ParasiteAxesAuxTrans(ax1, tr, "equal") + ax2 = ax1.get_aux_axes(tr, "equal") # note that ax2.transData == tr + ax1.transData # Anything you draw in ax2 will match the ticks and grids of ax1. - ax1.parasites.append(ax2) .. figure:: ../../gallery/axisartist/images/sphx_glr_demo_curvelinear_grid_001.png :target: ../../gallery/axisartist/demo_curvelinear_grid.html diff --git a/tutorials/toolkits/mplot3d.py b/tutorials/toolkits/mplot3d.py index 86479dbabb50..f3ff920e036f 100644 --- a/tutorials/toolkits/mplot3d.py +++ b/tutorials/toolkits/mplot3d.py @@ -1,11 +1,12 @@ """ =================== -The mplot3d Toolkit +The mplot3d toolkit =================== Generating 3D plots using the mplot3d toolkit. -.. currentmodule:: mpl_toolkits.mplot3d +This tutorial showcases various 3D plots. Click on the figures to see each full +gallery example with the code that generates the figures. .. contents:: :backlinks: none @@ -23,10 +24,6 @@ :target: ../../gallery/mplot3d/subplot3d.html :align: center -.. versionchanged:: 1.0.0 - Prior to Matplotlib 1.0.0, only a single `.Axes3D` could be created per - figure; it needed to be directly instantiated as ``ax = Axes3D(fig)``. - .. versionchanged:: 3.2.0 Prior to Matplotlib 3.2.0, it was necessary to explicitly import the :mod:`mpl_toolkits.mplot3d` module to make the '3d' projection to @@ -39,79 +36,72 @@ Line plots ========== +See `.Axes3D.plot` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_lines3d_001.png :target: ../../gallery/mplot3d/lines3d.html :align: center -.. automethod:: Axes3D.plot - .. _scatter3d: Scatter plots ============= +See `.Axes3D.scatter` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_scatter3d_001.png :target: ../../gallery/mplot3d/scatter3d.html :align: center -.. automethod:: Axes3D.scatter - .. _wireframe: Wireframe plots =============== +See `.Axes3D.plot_wireframe` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_wire3d_001.png :target: ../../gallery/mplot3d/wire3d.html :align: center -.. automethod:: Axes3D.plot_wireframe - .. _surface: Surface plots ============= +See `.Axes3D.plot_surface` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_surface3d_001.png :target: ../../gallery/mplot3d/surface3d.html :align: center -.. automethod:: Axes3D.plot_surface - .. _trisurface: Tri-Surface plots ================= +See `.Axes3D.plot_trisurf` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_trisurf3d_001.png :target: ../../gallery/mplot3d/trisurf3d.html :align: center -.. automethod:: Axes3D.plot_trisurf - .. _contour3d: Contour plots ============= +See `.Axes3D.contour` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_contour3d_001.png :target: ../../gallery/mplot3d/contour3d.html :align: center -.. automethod:: Axes3D.contour - .. _contourf3d: Filled contour plots ==================== +See `.Axes3D.contourf` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_contourf3d_001.png :target: ../../gallery/mplot3d/contourf3d.html :align: center -.. automethod:: Axes3D.contourf - .. versionadded:: 1.1.0 The feature demoed in the second contourf3d example was enabled as a result of a bugfix for version 1.1.0. @@ -120,35 +110,32 @@ Polygon plots ============= +See `.Axes3D.add_collection3d` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_polys3d_001.png :target: ../../gallery/mplot3d/polys3d.html :align: center -.. automethod:: Axes3D.add_collection3d - .. _bar3d: Bar plots ========= +See `.Axes3D.bar` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_bars3d_001.png :target: ../../gallery/mplot3d/bars3d.html :align: center -.. automethod:: Axes3D.bar - .. _quiver3d: Quiver ====== +See `.Axes3D.quiver` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_quiver3d_001.png :target: ../../gallery/mplot3d/quiver3d.html :align: center -.. automethod:: Axes3D.quiver - .. _2dcollections3d: 2D plots in 3D @@ -161,10 +148,9 @@ Text ==== +See `.Axes3D.text` for API documentation. .. figure:: ../../gallery/mplot3d/images/sphx_glr_text3d_001.png :target: ../../gallery/mplot3d/text3d.html :align: center - -.. automethod:: Axes3D.text """