diff --git a/.gitignore b/.gitignore index 8e1d33efc..ba78a3adf 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,8 @@ ultraplot/_version.py # Nox build directories .nox/* + +# Generated docs and draw.io assets. The edited diagram remains a repository asset. +tools/cheatsheet/assets/ +docs/_static/plot_types/ +tools/cheatsheet/ultraplot_cheatsheet.png diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..6e3fcae75 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +# Cheatsheet sources and exports are repository assets, not pip distribution files. +prune tools/cheatsheet diff --git a/docs/_scripts/build_plot_types.py b/docs/_scripts/build_plot_types.py new file mode 100644 index 000000000..56b213976 --- /dev/null +++ b/docs/_scripts/build_plot_types.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +""" +Regenerate the visual plot-type index before a documentation build. + +Run from ``conf.py`` the same way ``fetch_releases.py`` is: the page and its +thumbnails are generated artefacts, so a clean checkout builds them rather than +carrying 60-odd PNGs in the repository. Rendering is skipped when the icons are +already present, so a local rebuild costs nothing. +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +GENERATOR = os.path.join(ROOT, "tools", "cheatsheet") + +sys.path.insert(0, GENERATOR) + + +def main(): + try: + import docs_index + except ImportError as error: # the tools folder is not shipped in sdists + print(f"plot-type index skipped: {error}") + return + try: + docs_index.main() + except Exception as error: # never fail the docs build over a thumbnail + print(f"plot-type index skipped: {type(error).__name__}: {error}") + + +if __name__ == "__main__": + main() diff --git a/docs/conf.py b/docs/conf.py index 6bc2931f8..7e4405568 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,6 +65,8 @@ def __getattr__(self, name): } if not FAST_PREVIEW: run([sys.executable, "_scripts/fetch_releases.py"], check=False) + # Visual plot-type index: thumbnails plus the page that arranges them. + run([sys.executable, "_scripts/build_plot_types.py"], check=False) # Docs theme selector. Default to Shibuya, but keep env override for A/B checks. DOCS_THEME = os.environ.get("UPLT_DOCS_THEME", "shibuya").strip().lower() diff --git a/docs/index.rst b/docs/index.rst index 557956553..709f9f388 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -129,6 +129,7 @@ For more details, check the full :doc:`User guide ` and :doc:`API Referen :hidden: basics + plot_types subplots cartesian networks diff --git a/docs/plot_types.rst b/docs/plot_types.rst new file mode 100644 index 000000000..a13f0cfe9 --- /dev/null +++ b/docs/plot_types.rst @@ -0,0 +1,516 @@ +.. _plot_types: + +========== +Plot types +========== + +Every thumbnail below is the output of the command it names, drawn by that +command. Click one to read its documentation. + +.. note:: + + This page is generated by ``tools/cheatsheet/docs_index.py`` from the same + registry the `cheatsheet `__ is built + from. Re-run it after adding a plotting command. + +.. raw:: html + + + + +Relational +========== + +How one variable relates to another. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/plot.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.plot` + + .. grid-item-card:: + :img-top: _static/plot_types/scatter.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.scatter` + + .. grid-item-card:: + :img-top: _static/plot_types/step.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.step` + + .. grid-item-card:: + :img-top: _static/plot_types/stem.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.stem` + + .. grid-item-card:: + :img-top: _static/plot_types/vlines.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.vlines` + + .. grid-item-card:: + :img-top: _static/plot_types/hlines.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.hlines` + + .. grid-item-card:: + :img-top: _static/plot_types/loglog.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.loglog` + + .. grid-item-card:: + :img-top: _static/plot_types/parametric.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.parametric` + + .. grid-item-card:: + :img-top: _static/plot_types/bar.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.bar` + + .. grid-item-card:: + :img-top: _static/plot_types/barh.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.barh` + + .. grid-item-card:: + :img-top: _static/plot_types/lollipop.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.lollipop` + + :doc:`example ` + + .. grid-item-card:: + :img-top: _static/plot_types/area.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.area` + + .. grid-item-card:: + :img-top: _static/plot_types/pie.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.pie` + + +Distributions +============= + +The shape and spread of a sample. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/hist.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.hist` + + .. grid-item-card:: + :img-top: _static/plot_types/hist2d.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.hist2d` + + .. grid-item-card:: + :img-top: _static/plot_types/hexbin.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.hexbin` + + .. grid-item-card:: + :img-top: _static/plot_types/box.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.box` + + .. grid-item-card:: + :img-top: _static/plot_types/violin.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.violin` + + .. grid-item-card:: + :img-top: _static/plot_types/beeswarm.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.beeswarm` + + .. grid-item-card:: + :img-top: _static/plot_types/ridgeline.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.ridgeline` + + .. grid-item-card:: + :img-top: _static/plot_types/errorbars.png + :text-align: center + + ``errorbars`` + + :meth:`~ultraplot.axes.PlotAxes.plot` + + +Fields +====== + +A value over a two-dimensional grid. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/pcolormesh.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.pcolormesh` + + .. grid-item-card:: + :img-top: _static/plot_types/pcolor.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.pcolor` + + .. grid-item-card:: + :img-top: _static/plot_types/contour.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.contour` + + .. grid-item-card:: + :img-top: _static/plot_types/contourf.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.contourf` + + .. grid-item-card:: + :img-top: _static/plot_types/imshow.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.imshow` + + .. grid-item-card:: + :img-top: _static/plot_types/matshow.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.matshow` + + .. grid-item-card:: + :img-top: _static/plot_types/spy.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.spy` + + .. grid-item-card:: + :img-top: _static/plot_types/heatmap.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.heatmap` + + .. grid-item-card:: + :img-top: _static/plot_types/tripcolor.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.tripcolor` + + .. grid-item-card:: + :img-top: _static/plot_types/tricontourf.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.tricontourf` + + +Vector fields +============= + +Direction and magnitude on a grid. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/quiver.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.quiver` + + .. grid-item-card:: + :img-top: _static/plot_types/barbs.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.barbs` + + .. grid-item-card:: + :img-top: _static/plot_types/streamplot.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.streamplot` + + .. grid-item-card:: + :img-top: _static/plot_types/curved_quiver.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.curved_quiver` + + :doc:`example ` + + +Networks and diagrams +===================== + +Relationships that are not a grid. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/graph.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.graph` + + :doc:`example ` + + .. grid-item-card:: + :img-top: _static/plot_types/sankey.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.sankey` + + :doc:`example ` + + .. grid-item-card:: + :img-top: _static/plot_types/ribbon.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.ribbon` + + :doc:`example ` + + .. grid-item-card:: + :img-top: _static/plot_types/chord_diagram.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.chord_diagram` + + :doc:`example ` + + .. grid-item-card:: + :img-top: _static/plot_types/radar_chart.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.radar_chart` + + .. grid-item-card:: + :img-top: _static/plot_types/phylogeny.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.phylogeny` + + :doc:`example ` + + .. grid-item-card:: + :img-top: _static/plot_types/taylor.png + :text-align: center + + ``taylor`` + + +Maps +==== + +A projection by name, with any plotting command drawn on top in lon/lat. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/proj-robin.png + :text-align: center + + ``proj='robin'`` + + .. grid-item-card:: + :img-top: _static/plot_types/proj-ortho.png + :text-align: center + + ``proj='ortho'`` + + .. grid-item-card:: + :img-top: _static/plot_types/coast-land-ocean.png + :text-align: center + + ``coast, land, ocean`` + + .. grid-item-card:: + :img-top: _static/plot_types/scatter-on-a-map.png + :text-align: center + + ``scatter on a map`` + + :meth:`~ultraplot.axes.PlotAxes.scatter` + + .. grid-item-card:: + :img-top: _static/plot_types/quiver-on-a-map.png + :text-align: center + + ``quiver on a map`` + + :meth:`~ultraplot.axes.PlotAxes.quiver` + + .. grid-item-card:: + :img-top: _static/plot_types/plot-on-a-map.png + :text-align: center + + ``plot on a map`` + + :meth:`~ultraplot.axes.PlotAxes.plot` + + +What one keyword does +===================== + +The same command, changed by a single argument. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/bar-stack-True.png + :text-align: center + + ``stack=True`` + + :meth:`~ultraplot.axes.PlotAxes.bar` + + .. grid-item-card:: + :img-top: _static/plot_types/bar-negpos-True.png + :text-align: center + + ``negpos=True`` + + :meth:`~ultraplot.axes.PlotAxes.bar` + + .. grid-item-card:: + :img-top: _static/plot_types/area-stack-True.png + :text-align: center + + ``stack=True`` + + :meth:`~ultraplot.axes.PlotAxes.area` + + .. grid-item-card:: + :img-top: _static/plot_types/area-negpos-True.png + :text-align: center + + ``negpos=True`` + + :meth:`~ultraplot.axes.PlotAxes.area` + + .. grid-item-card:: + :img-top: _static/plot_types/plot-bars-True.png + :text-align: center + + ``bars=True`` + + :meth:`~ultraplot.axes.PlotAxes.plot` + + .. grid-item-card:: + :img-top: _static/plot_types/contour-labels-True.png + :text-align: center + + ``labels=True`` + + :meth:`~ultraplot.axes.PlotAxes.contour` + + .. grid-item-card:: + :img-top: _static/plot_types/heatmap-labels-True.png + :text-align: center + + ``labels=True`` + + :meth:`~ultraplot.axes.PlotAxes.heatmap` + + .. grid-item-card:: + :img-top: _static/plot_types/pcolormesh-levels-6.png + :text-align: center + + ``levels=6`` + + :meth:`~ultraplot.axes.PlotAxes.pcolormesh` + + .. grid-item-card:: + :img-top: _static/plot_types/pcolormesh-discrete-False.png + :text-align: center + + ``discrete=False`` + + :meth:`~ultraplot.axes.PlotAxes.pcolormesh` + + .. grid-item-card:: + :img-top: _static/plot_types/pcolormesh-values.png + :text-align: center + + ``values=`` + + :meth:`~ultraplot.axes.PlotAxes.pcolormesh` + + +Swapped axes +============ + +Every command has a sibling that puts the categories on the other axis. + +.. grid:: 2 3 4 6 + :gutter: 2 + + .. grid-item-card:: + :img-top: _static/plot_types/histh.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.histh` + + .. grid-item-card:: + :img-top: _static/plot_types/boxh.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.boxh` + + .. grid-item-card:: + :img-top: _static/plot_types/violinh.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.violinh` + + .. grid-item-card:: + :img-top: _static/plot_types/lollipoph.png + :text-align: center + + :meth:`~ultraplot.axes.PlotAxes.lollipoph` + + :doc:`example ` diff --git a/pyproject.toml b/pyproject.toml index 7653d3bff..f1d538a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ ignore = [ ] [tool.setuptools] -packages = { find = { exclude = ["docs*", "baseline*", "logo*"] } } +packages = { find = { exclude = ["docs*", "baseline*", "logo*", "tools*"] } } include-package-data = true [tool.setuptools_scm] diff --git a/tools/cheatsheet/README.md b/tools/cheatsheet/README.md new file mode 100644 index 000000000..13a2643de --- /dev/null +++ b/tools/cheatsheet/README.md @@ -0,0 +1,69 @@ +# UltraPlot cheatsheet and docs icons + +This folder contains the editable A3 draw.io cheatsheet and the renderers shared +with the documentation’s visual plot-type index. + +- `ultraplot_cheatsheet.drawio`: the edited, self-contained diagram. +- `ultraplot_cheatsheet.svg` / `.png`: its existing previews. +- `drawio.py`: the reproducible layout generator, with serif text, Python + highlighting, embedded SVG plots and editable colormap swatches. +- `fix_svg_seams.py`: repairs colorbar seams in an edited diagram without + changing text, geometry or layout. +- `docs_index.py`: validates API links and generates `docs/plot_types.rst` plus + its PNG thumbnails in `docs/_static/plot_types/`. +- `parts/icons.py`: the plot-type registry and renderers used by the docs. +- `parts/features.py`: the feature icons used by the draw.io sheet. +- `parts/drawio_details.py`: sharing comparisons, legends, geography and + registered colormap samples. +- `parts/common.py`: shared style, sample data and paired SVG/PNG exports. +- `assets/`: generated assets; safe to regenerate. + +## Build + +```bash +python tools/cheatsheet/build.py # assets + docs index +python tools/cheatsheet/build.py --figures # assets only +python tools/cheatsheet/build.py --docs # docs; render missing icons +``` + +These commands preserve the edited draw.io file and its previews. To generate a +fresh layout explicitly, choose a separate output path: + +```bash +python tools/cheatsheet/build.py --drawio /tmp/ultraplot-regenerated.drawio +``` + +The diagram generator reads existing assets. Render them first on a clean +checkout. Regenerating at the edited diagram’s path replaces manual edits, so +use a separate filename when comparing changes. Update embedded images in the +edited diagram selectively when preserving manual edits. + +Individual renderers also run directly: + +```bash +python tools/cheatsheet/parts/icons.py +python tools/cheatsheet/parts/features.py +python tools/cheatsheet/parts/drawio_details.py +``` + +Rendering requires UltraPlot and the optional libraries used by the selected +plot types, including pandas, networkx and cartopy for maps. Layout generation +requires Pygments, DejaVu Serif and DejaVu Sans Mono; PNG previews require +CairoSVG. SVG plots preserve their aspect ratios and remain sharp when scaled; +plot contents are embedded images, while page text and boxes are editable. + +## Docs and packaging + +`docs/_scripts/build_plot_types.py` invokes `docs_index.py` during the docs build. +The Python icon registry is the source of truth; no document-engine manifests +are needed. Missing icons are checked by filename, including PNGs required by +the docs rather than just their SVG counterparts. + +The cheatsheet stays in the repository but is excluded from wheels and source +distributions. Docs builds should run from a repository checkout. + +To repair SVG seams without rebuilding a manually edited diagram: + +```bash +python tools/cheatsheet/fix_svg_seams.py path/to/sheet.drawio +``` diff --git a/tools/cheatsheet/build.py b/tools/cheatsheet/build.py new file mode 100644 index 000000000..34d4b80c9 --- /dev/null +++ b/tools/cheatsheet/build.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Build docs icons and draw.io assets without overwriting an edited diagram. + +Default: render all required assets and update the docs plot-type index. +--drawio PATH explicitly assembles a new diagram and SVG/PNG previews. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys + +HERE = Path(__file__).resolve().parent +PARTS = HERE / "parts" +MODULES = ("icons", "features", "drawio_details") + + +def render_figures(): + for name in MODULES: + subprocess.run([sys.executable, str(PARTS / f"{name}.py")], cwd=PARTS, check=True) + + +def write_docs_page(): + subprocess.run([sys.executable, str(HERE / "docs_index.py")], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--figures", action="store_true", help="render figure assets") + parser.add_argument("--docs", action="store_true", help="update the docs index and missing icons") + parser.add_argument("--drawio", type=Path, metavar="OUTPUT", help="explicitly generate a diagram and previews at this path") + args = parser.parse_args() + default = not (args.figures or args.docs or args.drawio) + if args.figures or default: + render_figures() + if args.docs or default: + write_docs_page() + if args.drawio: + subprocess.run([sys.executable, str(HERE / "drawio.py"), + "--output", str(args.drawio.resolve()), "--png"], check=True) + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/docs_index.py b/tools/cheatsheet/docs_index.py new file mode 100644 index 000000000..4c6402a18 --- /dev/null +++ b/tools/cheatsheet/docs_index.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +Generate the visual plot-type index for the documentation. + +Reuses the icon registry the cheatsheet is built from, so the docs page, the +cheatsheet show the same thumbnails and cannot drift apart. +Writes ``docs/plot_types.rst`` and copies the icons to ``docs/_static``. + + micromamba run -n ultraplot-dev python tools/cheatsheet/docs_index.py + +Every command links to its API entry, and the link targets are checked against +the live class before the page is written: a typo fails here rather than +becoming a broken reference in the built docs. +""" + +from __future__ import annotations + +import os +import shutil +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PARTS = os.path.join(HERE, "parts") +ROOT = os.path.dirname(os.path.dirname(HERE)) +DOCS = os.path.join(ROOT, "docs") +STATIC = os.path.join(DOCS, "_static", "plot_types") + +sys.path.insert(0, PARTS) + +from icons import ICONS, slug # noqa: E402 + +#: Gallery examples live here; an example that calls a command is a better +#: destination than the API page alone, so the card links to both. +EXAMPLES = os.path.join(DOCS, "examples", "plot_types") + +#: Commands whose icon does not name a method of its own. +METHOD_OVERRIDES = { + "errorbars": "plot", + "taylor": None, # a projection, not a command + "proj='robin'": None, + "proj='ortho'": None, + "coast, land, ocean": None, # format keywords + "scatter on a map": "scatter", + "quiver on a map": "quiver", + "plot on a map": "plot", +} + +#: Headings for the groups, in page order. +GROUPS = ( + ("relational", "Relational", "How one variable relates to another."), + ("distribution", "Distributions", "The shape and spread of a sample."), + ("field", "Fields", "A value over a two-dimensional grid."), + ("vector", "Vector fields", "Direction and magnitude on a grid."), + ("network", "Networks and diagrams", "Relationships that are not a grid."), + ( + "maps", + "Maps", + "A projection by name, with any plotting command drawn on top in lon/lat.", + ), + ( + "keyword", + "What one keyword does", + "The same command, changed by a single argument.", + ), + ( + "swapped", + "Swapped axes", + "Every command has a sibling that puts the categories on the other axis.", + ), +) + +HEADER = """.. _plot_types: + +========== +Plot types +========== + +Every thumbnail below is the output of the command it names, drawn by that +command. Click one to read its documentation. + +.. note:: + + This page is generated by ``tools/cheatsheet/docs_index.py`` from the same + registry the `cheatsheet `__ is built + from. Re-run it after adding a plotting command. + +.. raw:: html + + + +""" + + +def gallery_links(): + """ + Map a command to the gallery example that demonstrates it. + + Two signals, both deliberate on the example's part: a docstring that names + ``PlotAxes.``, or a file name that contains the command. Merely + calling a command is not enough — nearly every example calls ``plot`` — so + a missing link is preferred over a misleading one. + """ + import re + + links = {} + if not os.path.isdir(EXAMPLES): + return links + + commands = {name: method_for(name) for name in ICONS} + for entry in sorted(os.listdir(EXAMPLES)): + if not entry.endswith(".py"): + continue + stem = entry[:-3] + source = open(os.path.join(EXAMPLES, entry)).read() + declared = set(re.findall(r"PlotAxes\.([a-z_]+)", source)) + for name, command in commands.items(): + if not command or name in links: + continue + named = command in declared + in_filename = len(command) > 4 and command in stem + if (named or in_filename) and f".{command}(" in source: + links[name] = f"/gallery/plot_types/{stem}" + return links + + +def method_for(name): + """ + Return the PlotAxes method an icon should link to, or None. + """ + if name in METHOD_OVERRIDES: + return METHOD_OVERRIDES[name] + return name.split("(")[0].strip() + + +def check_targets(names): + """ + Verify every link target exists, so the page cannot ship broken references. + """ + from ultraplot.axes.plot import PlotAxes + + missing = [name for name in names if name and not hasattr(PlotAxes, name)] + if missing: + raise SystemExit( + "these link targets are not PlotAxes methods: " + ", ".join(missing) + ) + + +def ensure_icons(): + """ + Render the icons if they are missing, so a clean checkout can build. + + The docs build calls this; rendering is skipped when the assets are already + present and complete, which is the usual case for a local rebuild. + """ + source = os.path.join(HERE, "assets", "icons") + wanted = {slug(name) + ".png" for name in ICONS} + missing = [name for name in wanted if not os.path.isfile(os.path.join(source, name))] + if not missing: + return + print(f" rendering {len(ICONS)} icons ({len(missing)} PNGs missing)") + import icons as icons_module + + cwd = os.getcwd() + os.chdir(PARTS) + try: + icons_module.main() + finally: + os.chdir(cwd) + + +def copy_icons(): + """ + Copy the rendered icons into the documentation's static folder. + """ + source = os.path.join(HERE, "assets", "icons") + if not os.path.isdir(source): + raise SystemExit("no icons yet — run parts/icons.py first") + os.makedirs(STATIC, exist_ok=True) + wanted = {slug(name) + ".png" for name in ICONS} + count = 0 + for entry in sorted(os.listdir(source)): + if entry in wanted: + shutil.copy2(os.path.join(source, entry), os.path.join(STATIC, entry)) + count += 1 + # Renaming or dropping a command would otherwise leave its icon behind, and + # the docs would ship files nothing references. + stale = [ + entry + for entry in os.listdir(STATIC) + if entry.endswith(".png") and entry not in wanted + ] + for entry in stale: + os.remove(os.path.join(STATIC, entry)) + note = f", {len(stale)} stale removed" if stale else "" + print(f" docs/_static/plot_types/ ({count} icons{note})") + + +def write_page(): + """ + Write the reStructuredText page: one card grid per group. + """ + examples = gallery_links() + lines = [HEADER] + for group, heading, blurb in GROUPS: + entries = [(name, spec) for name, spec in ICONS.items() if spec[4] == group] + if not entries: + continue + lines.append(heading) + lines.append("=" * len(heading)) + lines.append("") + lines.append(blurb) + lines.append("") + lines.append(".. grid:: 2 3 4 6") + lines.append(" :gutter: 2") + lines.append("") + for name, spec in entries: + method = method_for(name) + label = ( + f":meth:`~ultraplot.axes.PlotAxes.{method}`" + if method + else "``proj='taylor'``" + ) + plain = "(" in name or method is None or method != name.strip() + # A keyword variant is captioned with its argument alone: the method + # link underneath already says which command it belongs to, and the + # whole call is too long to fit a card without overflowing it. + shown = name.strip() + if "(" in shown and shown.endswith(")"): + shown = shown[shown.index("(") + 1 : -1] + caption = f"``{shown}``" if plain else label + lines.append(" .. grid-item-card::") + lines.append(f" :img-top: _static/plot_types/{slug(name)}.png") + lines.append(" :text-align: center") + lines.append("") + lines.append(f" {caption}") + if plain and method: + lines.append("") + lines.append(f" {label}") + if name in examples: + lines.append("") + lines.append(f" :doc:`example <{examples[name]}>`") + lines.append("") + lines.append("") + + path = os.path.join(DOCS, "plot_types.rst") + with open(path, "w") as handle: + handle.write("\n".join(lines).rstrip() + "\n") + print(f" docs/plot_types.rst ({sum(1 for _ in ICONS)} entries)") + + +def main(): + check_targets({method_for(name) for name in ICONS}) + ensure_icons() + copy_icons() + write_page() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/drawio.py b/tools/cheatsheet/drawio.py new file mode 100644 index 000000000..70ed42e13 --- /dev/null +++ b/tools/cheatsheet/drawio.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Build a self-contained, editable A3 draw.io cheatsheet from existing assets. + +Run after build.py --figures. Requires Pygments for editable code highlighting. +The SVG preview shares the same geometry; --png additionally requires CairoSVG. +""" +from __future__ import annotations + +import argparse +import base64 +import json +from html import escape +import re + +from pygments.lexers import PythonLexer +from fix_svg_seams import crisp_colorbars +from pygments.token import Comment, Keyword, Name, Number, Operator, String +from pathlib import Path +import struct +from urllib.parse import quote +import xml.etree.ElementTree as ET + +HERE = Path(__file__).resolve().parent +ASSETS = HERE / "assets" +WIDTH, HEIGHT = 1680, 1188 +INK, MUTED = "#182b3a", "#556471" +COLORS = ["#265c86", "#257a89", "#548348", "#aa7731", "#92556f"] +SVG_NS = "http://www.w3.org/2000/svg" +ET.register_namespace("", SVG_NS) + + +class Sheet: + def __init__(self): + self.document = ET.Element("mxfile", host="app.diagrams.net", type="device") + diagram = ET.SubElement(self.document, "diagram", id="ultraplot-reference", name="UltraPlot cheatsheet") + model = ET.SubElement(diagram, "mxGraphModel", dx=str(WIDTH), dy=str(HEIGHT), + grid="1", gridSize="10", page="1", pageScale="1", + pageWidth=str(WIDTH), pageHeight=str(HEIGHT), background="#ffffff", + math="0", shadow="0") + self.root = ET.SubElement(model, "root") + ET.SubElement(self.root, "mxCell", id="0") + ET.SubElement(self.root, "mxCell", id="1", parent="0") + self.svg = ET.Element(f"{{{SVG_NS}}}svg", width=str(WIDTH), height=str(HEIGHT), + viewBox=f"0 0 {WIDTH} {HEIGHT}") + self.count = 1 + self.rect(0, 0, WIDTH, HEIGHT, "#ffffff", "none") + + def cell(self, x, y, w, h, value, style): + assert x >= 0 and y >= 0 and x + w <= WIDTH and y + h <= HEIGHT + self.count += 1 + cell = ET.SubElement(self.root, "mxCell", id=str(self.count), value=value, + style=style, vertex="1", parent="1") + ET.SubElement(cell, "mxGeometry", x=str(x), y=str(y), width=str(w), height=str(h), **{"as": "geometry"}) + + def element(self, tag, **attrs): + return ET.SubElement(self.svg, f"{{{SVG_NS}}}{tag}", {k.replace("_", "-"): str(v) for k, v in attrs.items()}) + + def rect(self, x, y, w, h, fill, stroke="#d4dee5"): + swatch = 0 < w < 5 and 8 <= h <= 16 and stroke == "none" + if swatch: + stroke = fill # overlap neighbouring colour strips to hide SVG seams + stroke_width = .5 if swatch else .7 + self.cell(x, y, w, h, "", f"rounded=0;whiteSpace=wrap;html=0;fillColor={fill};strokeColor={stroke};strokeWidth={stroke_width};") + rect = self.element("rect", x=x, y=y, width=w, height=h, fill=fill, stroke=stroke, stroke_width=stroke_width) + if swatch: + rect.set("shape-rendering", "crispEdges") + + def text(self, x, y, w, text, size=12, color=INK, bold=False, mono=False, height=None): + if size < 20: # Keep the masthead size; enlarge the reading text. + size = round(size * 1.12, 2) + lines = text.splitlines() + h = height or len(lines) * size * 1.35 + 4 + font = "DejaVu Sans Mono" if mono else "DejaVu Serif" + runs = [[] for _ in lines] + if mono: + # Unprocessed tokens preserve the exact source, including incomplete + # recipes and ellipses used in compact API captions. + line_index = 0 + for offset, kind, value in PythonLexer().get_tokens_unprocessed(text): + tint = color + rest = text[offset + len(value):] + if kind in Comment: + tint = "#657782" + elif kind in String: + tint = "#347044" + elif kind in Keyword: + tint = "#854b89" + elif kind in Number: + tint = "#a45b26" + elif kind in Name and re.match(r"\s*\(", rest): + tint = "#235e91" + elif kind in Name and re.match(r"\s*=(?!=)", rest): + tint = "#815f2c" + elif kind in Operator: + tint = "#596777" + for j, part in enumerate(value.split("\n")): + if j: + line_index += 1 + if part and line_index < len(runs): + runs[line_index].append((part, tint)) + html_lines = ["".join( + f'{escape(part)}' + for part, tint in line) for line in runs] + value = ('
' + + '
'.join(html_lines) + '
') + else: + value = text + runs = [[(line, color)] for line in lines] + self.cell(x, y, w, h, value, + f"text;html={1 if mono else 0};whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;" + f"spacing=0;fontFamily={font};fontSize={size};fontColor={color};" + f"fontStyle={1 if bold else 0};strokeColor=none;fillColor=none;") + node = self.element("text", x=x, y=y + size, font_family=font, font_size=size, + fill=color, font_weight="bold" if bold else "normal") + node.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + for i, line in enumerate(runs): + span = ET.SubElement(node, f"{{{SVG_NS}}}tspan", x=str(x), y=str(y + size + i * size * 1.35)) + for part, tint in line: + token = ET.SubElement(span, f"{{{SVG_NS}}}tspan", fill=tint) + token.text = part + return h + + def picture(self, name, x, y, w, h): + path = ASSETS / (name + ".svg") + if path.exists(): + svg = ET.parse(path).getroot() + crisp_colorbars(svg) + _, _, iw, ih = map(float, svg.attrib["viewBox"].split()) + # Normalize the XML and remove the external SVG DTD declaration. + raw = ET.tostring(svg, encoding="utf-8") + mime = "image/svg+xml" + drawio_uri = "data:image/svg+xml," + quote(raw.decode(), safe="") + else: + path = ASSETS / (name + ".png") + if not path.exists(): + raise SystemExit(f"Missing {path}. Run tools/cheatsheet/build.py --figures first.") + raw = path.read_bytes() + iw, ih = struct.unpack(">II", raw[16:24]) + mime = "image/png" + drawio_uri = "data:image/png," + base64.b64encode(raw).decode() + scale = min(w / iw, h / ih) + pw, ph = round(iw * scale, 3), round(ih * scale, 3) + px, py = round(x + (w - pw) / 2, 3), round(y + (h - ph) / 2, 3) + self.cell(px, py, pw, ph, "", "shape=image;verticalLabelPosition=bottom;verticalAlign=top;" + f"imageAspect=1;aspect=fixed;image={drawio_uri};") + self.element("image", x=px, y=py, width=pw, height=ph, + href=f"data:{mime};base64,{base64.b64encode(raw).decode()}") + + +def build(): + """Dense galleries with extra room for sharing, legends and colorbars.""" + s = Sheet() + s.text(24, 12, 550, "UltraPlot", 40, bold=True) + s.text(253, 28, 750, "WHAT ULTRAPLOT ADDS", 22, COLORS[0], bold=True) + s.text(1100, 18, 550, "A companion to the Matplotlib cheatsheet", 15, bold=True) + s.text(1100, 43, 550, "import ultraplot as uplt\nfig, ax = uplt.subplots()", 13, MUTED, mono=True) + s.text(24, 70, 1625, "Layout, guides and plotting conveniences — with room for the details that make a multi-panel figure work.", 14, MUTED) + + def panel(x, y, w, h, title, color): + s.rect(x, y, w, h, "#ffffff") + s.rect(x, y, w, 3, color, "none") + s.text(x + 10, y + 9, w - 20, title, 16 if w < 300 else 17, color, bold=True) + + def icon(asset, label, x, y, w, size, color=INK): + s.picture(asset, x + (w - size) / 2, y, size, size) + s.text(x + 3, y + size + 4, w - 6, label, 10.2, color, mono=True) + + # The three sharing renders use the same 2x2 data with different ranges. + panel(24, 106, 690, 345, "ADVANCED AXIS SHARING", COLORS[0]) + for i, (key, label, note) in enumerate([ + ("none", "share=False", "Independent axes"), + ("limits", "share='limits'", "Limits per row / column"), + ("all", "share='all'", "Limits across all panels"), + ]): + x = 34 + i * 224 + s.text(x, 146, 214, label, 12, COLORS[0], bold=True, mono=True) + s.picture("drawio/sharing_" + key, x, 168, 208, 161) + s.text(x, 334, 214, note, 11, MUTED) + s.text(34, 350, 670, + "'labels': share axis labels • 'limits': also link limits • True: also hide inner tick labels", + 10.6, MUTED) + s.text(34, 373, 670, + "uplt.subplots(nrows=2, ncols=2, sharex='all', sharey='limits',\n" + " span=True, sharexticklabels=False)\n" + "axs.share_labels(axis='both') # centre labels across this grid", 11.3, mono=True) + s.text(34, 430, 670, + "spanx / spany: spanning labels • sharexlimits / shareylabels: individual overrides", + 10.7, MUTED) + + panel(732, 106, 924, 345, "SUBPLOTS, LABELS & ANNOTATIONS", COLORS[0]) + layouts = [ + ("mosaic_array", "subplots([[…]])"), ("physical_units", "refwidth='55mm'"), + ("subplotgrid", "axs[:, 1]"), ("spanning_labels", "span=True"), + ("abc_labels", "abc='a.'"), ("edge_labels", "toplabels="), + ("corner_titles", "urtitle="), ("format", "axs.format(…)"), + ("panel_axes", "panel_axes('r')"), ("inset_axes", "inset_axes(…)"), + ("dualx", "dualx(f)"), ("curved_text", "curvedtext()"), + ] + for i, (asset, label) in enumerate(layouts): + icon("features/" + asset, label, 742 + (i % 6) * 150, + 148 + (i // 6) * 142, 150, 111, COLORS[0]) + s.text(742, 429, 900, "Slice and format a grid in one call; use physical units for axes, panels and spacing.", 11, MUTED) + + panel(24, 467, 690, 345, "COLORBARS: OUTSIDE, STACKED OR INSET", COLORS[1]) + for i, (asset, label) in enumerate([ + ("outer_guides", "loc='r'"), ("stacked_guides", "repeated loc='b'"), + ("inset_guides", "loc='ll'"), + ]): + icon("features/" + asset, label, 34 + i * 224, 510, 214, 147, COLORS[1]) + s.text(34, 685, 670, + "ax.pcolormesh(Z, levels=7, colorbar='r')\n" + "ax.colorbar(m, loc='b', width='3mm', length=.7)\n" + "fig.colorbar(m, loc='b', col=1) # align to a figure column", + 11.5, mono=True) + s.text(34, 746, 670, + "Outer guides take layout slots; repeated guides queue on the same side.\n" + "Sides: l r t b • Insets: ul ur ll lr • levels= / values= set colour intervals.", + 11.4, MUTED) + s.text(34, 788, 670, "Control width in physical units and length as a fraction of the available span.", 11, MUTED) + + panel(732, 467, 636, 345, "LEGENDS FOR DATA ENCODINGS", COLORS[1]) + legends = [ + ("cat", "catlegend()", "Categories", "ax.catlegend(names,\n colors=colors,\n markers=markers)"), + ("size", "sizelegend()", "Marker areas", "ax.sizelegend(\n [12, 60, 150],\nlabels=['S','M','L'])"), + ("num", "numlegend()", "Numeric keys", "ax.numlegend(\n levels=[0, .5, 1],\n cmap='batlow')"), + ("entry", "entrylegend()", "Custom entries", "ax.entrylegend([\n {'label': 'Model',\n 'line': True}])"), + ] + for i, (asset, label, note, code) in enumerate(legends): + x = 742 + i * 154 + s.text(x, 507, 146, label, 11.2, COLORS[1], bold=True, mono=True) + s.picture("drawio/legend_" + asset, x, 533, 146, 139) + s.text(x, 676, 146, note, 11.3, MUTED) + s.text(x, 699, 146, code, 10, mono=True) + s.text(742, 758, 616, + "ax.plot(Y, labels=names, legend='b') • ax.geolegend(…)", + 10.7, mono=True) + s.text(742, 786, 616, + "Also on fig; add=False returns handles and labels for combined legends.", + 11, MUTED) + + panel(1386, 467, 270, 345, "BUNDLED COLORMAPS", COLORS[3]) + palette_path = ASSETS / "drawio" / "colormaps.json" + if not palette_path.exists(): + raise SystemExit("Run parts/drawio_details.py to generate colormap samples.") + palettes = json.loads(palette_path.read_text()) + for group_index, (group, entries) in enumerate(palettes.items()): + y = 508 + group_index * 84 + s.text(1396, y, 250, group, 12, COLORS[3], bold=True) + for row, entry in enumerate(entries): + yy = y + 23 + row * 18 + s.text(1396, yy - 1, 72, entry["name"], 10.7, mono=True) + colors = entry["colors"] + for j, color in enumerate(colors): + s.rect(1472 + j * 174 / len(colors), yy, 174 / len(colors), 12, color, "none") + s.text(1396, 769, 250, "cmap='batlow' # any plot\nuplt.show_cmaps() # all maps", 10.5, mono=True) + + panel(24, 828, 1110, 311, "MORE PLOT TYPES & USEFUL VARIANTS", COLORS[4]) + plots = [ + ("beeswarm", "beeswarm"), ("ridgeline", "ridgeline"), + ("lollipop", "lollipop"), ("parametric", "parametric"), + ("curved_quiver", "curved_quiver"), ("graph", "graph"), + ("sankey", "sankey"), ("ribbon", "ribbon"), + ("chord_diagram", "chord_diagram"), ("radar_chart", "radar_chart"), + ("phylogeny", "phylogeny"), ("taylor", "taylor"), + ("bar-stack-True", "bar(stack=True)"), ("bar-negpos-True", "bar(negpos=True)"), + ("area-stack-True", "area(stack=True)"), ("area-negpos-True", "area(negpos=True)"), + ] + for i, (asset, label) in enumerate(plots): + icon("icons/" + asset, label, 34 + (i % 8) * 136, + 869 + (i // 8) * 125, 136, 96, COLORS[4]) + s.text(34, 1117, 1090, + "Polar: chord_diagram, radar_chart, phylogeny • Taylor: proj='taylor' • Transposed variants: plotx, scatterx, …", + 10.3, MUTED) + + panel(1152, 828, 504, 311, "GEOGRAPHY & MAP FORMATTING", COLORS[3]) + s.picture("drawio/geography", 1162, 868, 234, 139) + s.picture("drawio/regional_map", 1402, 862, 244, 145) + s.text(1162, 1009, 234, "proj='robin' + colour levels + guide", 10.6, MUTED) + s.text(1402, 1009, 244, "proj='merc' + lon/lat formatting", 10.6, MUTED) + s.text(1162, 1032, 484, + "fig, ax = uplt.subplots(proj='merc')\n" + "ax.pcolormesh(lon, lat, Z, cmap='roma', colorbar='b')\n" + "ax.format(land=True, ocean=True, coast=True,\n" + " borders=True, rivers=True, lonlabels='b',\n" + " latlabels='l', lonlim=(-15, 40), latlim=(30, 63))", + 10.5, mono=True) + s.text(1162, 1116, 484, "Bundled colormaps: batlow, roma, … • uplt.show_cmaps()", 10.5, MUTED) + s.text(24, 1150, 1630, + "ultraplot.readthedocs.io • Built-in conveniences beyond Matplotlib’s core API; many can also be assembled manually in Matplotlib.", + 12, MUTED) + s.text(24, 1167, 1630, + "Companion to matplotlib.org/cheatsheets • All plots rendered with UltraPlot • Editable draw.io text and layout; embedded SVG plots", + 10, MUTED) + return s + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=HERE / "ultraplot_cheatsheet.drawio") + parser.add_argument("--png", action="store_true", help="also render the SVG preview using CairoSVG") + args = parser.parse_args() + sheet = build() + args.output.parent.mkdir(parents=True, exist_ok=True) + ET.indent(sheet.document) + ET.ElementTree(sheet.document).write(args.output, encoding="utf-8", xml_declaration=True) + svg = args.output.with_suffix(".svg") + ET.ElementTree(sheet.svg).write(svg, encoding="utf-8", xml_declaration=True) + print(f"{args.output} ({sheet.count - 1} editable objects)") + print(svg) + if args.png: + import cairosvg + png = args.output.with_suffix(".png") + cairosvg.svg2png(url=str(svg), write_to=str(png), + output_width=WIDTH * 2, output_height=HEIGHT * 2) + print(png) + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/fix_svg_seams.py b/tools/cheatsheet/fix_svg_seams.py new file mode 100644 index 000000000..6e5fc4865 --- /dev/null +++ b/tools/cheatsheet/fix_svg_seams.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Remove vector colorbar seams without rebuilding an edited draw.io layout.""" +from __future__ import annotations + +import argparse +import base64 +from copy import deepcopy +from pathlib import Path +from urllib.parse import quote, unquote +import xml.etree.ElementTree as ET + +SVG = "http://www.w3.org/2000/svg" +ET.register_namespace("", SVG) + + +def crisp_colorbars(root): + """Touch one-dimensional QuadMesh colorbars, preserving 2D plot meshes.""" + changed = 0 + for group in root.iter(f"{{{SVG}}}g"): + if not group.get("id", "").startswith("QuadMesh"): + continue + paths = group.findall(f"{{{SVG}}}path") + # A colorbar consists of rectangles all sharing one coordinate extent. + import re + boxes = [] + for path in paths: + coords = [float(v) for v in re.findall(r"[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?", path.get("d", ""))] + if len(coords) < 8 or len(coords) % 2: + break + xs, ys = coords[::2], coords[1::2] + boxes.append((min(xs), max(xs), min(ys), max(ys))) + if not boxes or len(boxes) != len(paths): + continue + vertical = len({b[:2] for b in boxes}) == 1 + horizontal = len({b[2:] for b in boxes}) == 1 + if not (vertical or horizontal): + continue + if group.get("shape-rendering") != "crispEdges": + group.set("shape-rendering", "crispEdges") + changed += 1 + return changed + + +def patch_drawio(path): + tree = ET.parse(path) + before = deepcopy(tree.getroot()) + changed_ids = set() + bars = strips = 0 + for cell in tree.findall(".//mxCell"): + original = cell.get("style", "") + style = dict(item.split("=", 1) for item in original.split(";") if "=" in item) + image = style.get("image", "") + if image.startswith("data:image/svg+xml,"): + svg = ET.fromstring(unquote(image.split(",", 1)[1])) + count = crisp_colorbars(svg) + if count: + encoded = quote(ET.tostring(svg, encoding="unicode"), safe="") + original = original.replace(image, "data:image/svg+xml," + encoded) + bars += count + geometry = cell.find("mxGeometry") + if geometry is not None: + width = float(geometry.get("width", "0")) + height = float(geometry.get("height", "0")) + # Swatch segments only; leave section rails and plot geometry alone. + if 0 < width < 5 and 8 <= height <= 16 and style.get("strokeColor") == "none" and "fillColor" in style: + original = original.replace("strokeColor=none;", f"strokeColor={style['fillColor']};strokeWidth=0.5;") + strips += 1 + if original != cell.get("style", ""): + cell.set("style", original) + changed_ids.add(cell.get("id")) + # Verify that text, geometry, hierarchy and every other user edit survive. + comparison = deepcopy(tree.getroot()) + old = {c.get("id"): c for c in before.findall(".//mxCell")} + for cell in comparison.findall(".//mxCell"): + if cell.get("id") in changed_ids: + cell.set("style", old[cell.get("id")].get("style")) + assert ET.tostring(comparison) == ET.tostring(before) + if changed_ids: + tree.write(path, encoding="utf-8", xml_declaration=True) + return bars, strips + + +def patch_preview(path): + tree = ET.parse(path) + bars = strips = 0 + for image in tree.getroot().iter(f"{{{SVG}}}image"): + href = image.get("href", "") + if not href.startswith("data:image/svg+xml;base64,"): + continue + svg = ET.fromstring(base64.b64decode(href.split(",", 1)[1])) + count = crisp_colorbars(svg) + if count: + image.set("href", "data:image/svg+xml;base64," + base64.b64encode(ET.tostring(svg)).decode()) + bars += count + for rect in tree.getroot().iter(f"{{{SVG}}}rect"): + w, h = float(rect.get("width", "0")), float(rect.get("height", "0")) + if 0 < w < 5 and 8 <= h <= 16 and rect.get("stroke") == "none": + rect.set("stroke", rect.get("fill")) + rect.set("stroke-width", "0.5") + rect.set("shape-rendering", "crispEdges") + strips += 1 + if bars or strips: + tree.write(path, encoding="utf-8", xml_declaration=True) + return bars, strips + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=Path) + args = parser.parse_args() + print("Colorbars, swatch segments:", patch_drawio(args.path)) + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/common.py b/tools/cheatsheet/parts/common.py new file mode 100644 index 000000000..35691753a --- /dev/null +++ b/tools/cheatsheet/parts/common.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Shared style and helpers for the cheatsheet figure parts. + +Each part script renders one asset with UltraPlot and drops it in ``assets/``. +The draw.io generator assembles them; these helpers only render individual +figures. +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager + +import numpy as np + +import ultraplot as uplt + +#: Where the rendered assets land, relative to the cheatsheet directory. +ASSETS = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets" +) + +INK = "#101720" +INK_SOFT = "#47535f" +INK_FAINT = "#7e8c99" +PANEL = "#ffffff" +SUNK = "#eef1f5" +RULE = "#c9d2dc" +ACCENT = "#3b638c" + +#: Assets are rendered at this resolution. The page scales them down to their box, +#: so oversampling keeps small strokes crisp in print. +DPI = 300 + + +def use_style(fontsize=7): + """ + Apply the cheatsheet's drawing style to the global rc state. + """ + uplt.rc.update( + { + "font.size": fontsize, + "figure.facecolor": PANEL, + "savefig.facecolor": PANEL, + "axes.facecolor": PANEL, + "text.color": INK, + "axes.labelcolor": INK_SOFT, + "tick.labelcolor": INK_SOFT, + "axes.edgecolor": RULE, + "axes.linewidth": 0.6, + "tick.width": 0.5, + "tick.len": 2.0, + "grid.alpha": 0.25, + "cycle": "colorblind", + } + ) + + +def save(fig, name, *, dpi=DPI, transparent=False): + """ + Write one asset and report it, so ``build.py`` output reads as a manifest. + """ + os.makedirs(ASSETS, exist_ok=True) + path = os.path.join(ASSETS, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + fig.save(path, dpi=dpi, transparent=transparent) + # Keep a raster companion for draw.io while preserving the SVG master. + if path.endswith(".svg"): + fig.save(path[:-4] + ".png", dpi=dpi, transparent=transparent) + elif path.endswith(".png"): + fig.save(path[:-4] + ".svg", transparent=transparent) + uplt.close(fig) + print(f" {os.path.relpath(path, os.path.dirname(ASSETS))}") + return path + + +# ---------------------------------------------------------------- icons +# +# One visual language for every thumbnail. Icons are read at a glance, often at +# 10 mm, so they share a small vocabulary of shapes and a fixed set of colour +# roles: the reader learns the vocabulary once and then only sees what differs +# between two commands. + +#: Deterministic sample data, built once so two icons of the same shape are +#: literally the same data. +_STATE = np.random.default_rng(51423) + +#: A single smooth wave: the shape for anything that draws a line. +WAVE_X = np.linspace(0, 2 * np.pi, 80) +WAVE = np.sin(WAVE_X) + +#: Three phase-shifted waves, for anything that draws several series. +WAVES = np.column_stack([np.sin(WAVE_X + shift) for shift in (0, 0.9, 1.8)]) + +#: A point cloud, for scatter-shaped commands. +CLOUD = _STATE.normal(size=(60, 2)) + +#: Five categories, for bar-shaped commands. Sorted so the shape reads as a +#: ranking rather than as noise. +CATEGORIES = list("ABCDE") +VALUES = np.sort(_STATE.uniform(0.35, 1.0, 5))[::-1] + +#: Signed values, for the commands that colour by sign. +SIGNED = np.array([0.9, 0.45, -0.3, -0.75, 0.6]) + +#: Raw samples, for the commands that reduce a distribution. +SAMPLES = np.sin(WAVE_X)[None, :] + _STATE.normal(0, 0.3, (80, WAVE_X.size)) + +#: Colour roles. One accent for a single series, the qualitative cycle for +#: several, a sequential map for magnitude and a diverging one for sign. +ICON_LINE = ACCENT +ICON_STRUCTURE = "gray6" +ICON_SEQUENTIAL = "batlow" +ICON_DENSITY = "fire" +ICON_DIVERGING = "roma" + +#: Stroke and marker sizes that survive being scaled to 10 mm. +ICON_LW = 2.4 +ICON_MS = 19.0 + +#: Data margin inside an icon. Small, so the drawing reaches the edges: the +#: tile on the page supplies the frame, and empty padding inside it just makes +#: the icon look smaller than the space it occupies. +ICON_MARGIN = 0.035 + + +def smooth_field(n=48, scale=1.0, ripple=0.35): + """ + A smooth two-dimensional field: two peaks and two troughs, no noise. + + Noise makes a contour icon look like a maze at thumbnail size, so the field + the 2D icons share is deliberately clean. ``ripple`` adds a second, finer + wave that gives the filled commands more to show; the line commands pass + ``ripple=0`` and get plain nested rings. + """ + y, x = np.mgrid[0:n, 0:n] + return scale * ( + np.sin(2 * np.pi * x / n) * np.cos(2 * np.pi * y / n) + + ripple * np.sin(4 * np.pi * y / n) + ) + + +def peak_field(n=64): + """ + One broad peak and one shallow dip: the archetypal contour shape. + + A periodic field contoured at icon size reads as a maze; concentric rings + around a peak read as a contour map at a glance. + """ + axis = np.linspace(-2.2, 2.2, n) + x, y = np.meshgrid(axis, axis) + return np.exp(-((x + 0.5) ** 2 + (y - 0.3) ** 2) / 1.1) - 0.55 * np.exp( + -((x - 1.2) ** 2 + (y + 1.1) ** 2) / 0.5 + ) + + +def rotational_field(n=16, extent=2.0): + """ + A rotation, for the vector-field commands: x, y, u, v. + """ + axis = np.linspace(-extent, extent, n) + x, y = np.meshgrid(axis, axis) + return x, y, -y, x + + +@contextmanager +def without_new_text(ax): + """ + Drop only the text a command adds, leaving the axes' own titles alone. + + Some commands label themselves — the ribbon names its periods, the radar + names its spokes — and at icon size those labels are noise. Removing every + text would take UltraPlot's own title artists with it, and the next + ``format`` call would then fail on them. + """ + before = {id(text) for text in ax.texts} + yield + for text in list(ax.texts): + if id(text) not in before: + text.remove() + + +def bare(ax, **kwargs): + """ + Strip an axes to its data: no ticks, no labels, thin frame. + + Icons are read at a glance and at thumbnail size, so anything that isn't + the shape of the plot type is noise. + """ + kwargs.setdefault("linewidth", 0.5) + ax.format( + xticks=[], + yticks=[], + xlabel="", + ylabel="", + title="", + grid=False, + **kwargs, + ) + return ax diff --git a/tools/cheatsheet/parts/drawio_details.py b/tools/cheatsheet/parts/drawio_details.py new file mode 100644 index 000000000..42e04fa40 --- /dev/null +++ b/tools/cheatsheet/parts/drawio_details.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Sharing comparisons, semantic legends and geography for the draw.io sheet.""" +import numpy as np +import ultraplot as uplt +from common import ACCENT, bare, save, use_style + + +def sharing(): + for name, level in (("none", False), ("limits", "limits"), ("all", "all")): + fig, axs = uplt.subplots(nrows=2, ncols=2, figwidth="48mm", + figheight="43mm", share=level, + span=level is not False, wspace="6mm", hspace="6mm") + for i, ax in enumerate(axs): + row, col = divmod(i, 2) + x = np.linspace(0, 5 * (col + 1), 80) + ax.plot(x, (row + 1) * np.sin(x), color=ACCENT, lw=2.3) + axs.format(xlabel="X", ylabel="Y", labelsize=8, + ticklabelsize=6.5, xlocator=5, ylocator=2, grid=False) + if level == "all": + # Explicit label groups centre labels over the whole grid even + # when global numeric sharing uses a single parent axes. + axs.share_labels(axis="both") + save(fig, f"drawio/sharing_{name}.png") + + +def legends(): + for kind in ("cat", "size", "num", "entry"): + fig, ax = uplt.subplots(figwidth="43mm", figheight="34mm") + bare(ax, linewidth=0) + kw = dict(loc="c", ncols=1, frame=False, fontsize=11) + if kind == "cat": + ax.catlegend(["Control", "Treatment", "Reference"], + colors=["#3b638c", "#c47a50", "#548348"], + markers=["o", "s", "^"], markersize=12, **kw) + elif kind == "size": + ax.sizelegend([12, 60, 150], labels=["Small", "Medium", "Large"], + markercolor=ACCENT, labelspacing="1.3em", **kw) + elif kind == "num": + ax.numlegend(levels=[0, .25, .5, .75, 1], cmap="batlow", fmt="{:.2f}", **kw) + else: + ax.entrylegend([ + {"label": "Observed", "line": False, "marker": "o", "color": ACCENT}, + {"label": "Model", "line": True, "linestyle": "--", "color": "gray7"}, + {"label": "Reference", "line": True, "color": "#c47a50"}, + ], **kw) + save(fig, f"drawio/legend_{kind}.png") + + +def geography(): + lon = np.linspace(-180, 180, 145) + lat = np.linspace(-90, 90, 73) + grid_lon, grid_lat = np.meshgrid(lon, lat) + values = (np.cos(np.deg2rad(grid_lat)) ** 2 * np.sin(np.deg2rad(2 * grid_lon)) + + .4 * np.sin(np.deg2rad(3 * grid_lat))) + fig, ax = uplt.subplots(proj="robin", figwidth="57mm", figheight="36mm") + ax.pcolormesh(lon, lat, values, cmap="roma", levels=9, colorbar="b", + colorbar_kw={"width": "2mm", "length": .8, "ticklabelsize": 5}) + ax.format(coast=True, coastlinewidth=.6, grid=True, labels=False) + save(fig, "drawio/geography.png") + + +def regional_map(): + # This longitude/latitude extent is nearly square in Mercator coordinates. + # Let the projection determine the axes aspect; never stretch the image. + fig, ax = uplt.subplots(proj="merc", refwidth="42mm") + ax.format(land=True, ocean=True, coast=True, borders=True, rivers=True, + landcolor="gray3", oceancolor="denim", coastlinewidth=.6, + lonlim=(-15, 40), latlim=(30, 63), lonlabels="b", latlabels="l", + labelsize=6, gridlabelsize=8, lonlocator=20, latlocator=10, grid=True, gridalpha=.3) + save(fig, "drawio/regional_map.png") + + +def colormap_samples(): + """Cache registered colormap samples for editable draw.io swatches.""" + import json + from pathlib import Path + from matplotlib.colors import to_hex + from common import ASSETS + + groups = { + "Sequential": ["fire", "batlow", "thermal"], + "Diverging": ["roma", "vik", "balance"], + "Cyclic": ["phase", "romaO", "vikO"], + } + samples = {} + for group, names in groups.items(): + samples[group] = [ + {"name": name, "colors": [to_hex(uplt.Colormap(name)(v)) + for v in np.linspace(0, 1, 48)]} + for name in names + ] + target = Path(ASSETS) / "drawio" / "colormaps.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(samples, indent=2) + "\n") + + +def main(): + use_style() + sharing() + legends() + colormap_samples() + try: + import cartopy # noqa: F401 + except ImportError: + print(" (cartopy missing, skipping draw.io geography specimen)") + else: + geography() + regional_map() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/features.py b/tools/cheatsheet/parts/features.py new file mode 100644 index 000000000..f7b0d4c81 --- /dev/null +++ b/tools/cheatsheet/parts/features.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +""" +One small icon per UltraPlot feature that matplotlib does not have. + +The plot-type icons in ``icons.py`` answer "what can I draw"; these answer +"what does UltraPlot add". The registry contains the features used by the draw.io sheet. + +Each icon is drawn by the feature it illustrates: the sharing icon really has +sharing switched on, the outer-colorbar icon really allocates a gridspec slot. +Anything that cannot be drawn honestly at this size is left out rather than +faked. +""" + +from __future__ import annotations + +import numpy as np + +import ultraplot as uplt + +from common import ACCENT, INK, INK_FAINT, RULE, SUNK, bare, save, use_style + +#: Square vector icons are scaled by the draw.io layout. +SIZE = "26mm" + +RNG = np.random.default_rng(51423) +X = np.linspace(0, 10, 120) + + +def _field(n=28): + y, x = np.mgrid[0:n, 0:n] + return np.sin(x / 4.0) * np.cos(y / 5.0) + + +def _mark(ax, text, *, x=0.5, y=0.5, size=6.5, color=ACCENT, **kwargs): + """ + Write the keyword an icon is about, in the page's monospace. + """ + ax.text( + x, + y, + text, + transform=ax.transAxes, + ha="center", + va="center", + family="monospace", + fontsize=size, + color=color, + **kwargs, + ) + + +# --------------------------------------------------------------- layout + + +def feature_format(fig, axs): + """One call sets titles, labels, limits and ticks.""" + ax = axs[0] + ax.plot(X, np.sin(X), lw=2.5) + ax.format( + title="Title", + xlabel="X", + ylabel="Y", + abc="a.", + abcloc="ul", + titlesize=10, + labelsize=9, + abcsize=11, + ticklabelsize=6, + xlocator=5, + ylocator=2, + grid=True, + ) + + +def feature_spanning(fig, axs): + """One label spans the panels it describes.""" + for ax in axs: + ax.plot(X, np.sin(X), lw=2.2) + axs.format( + xlabel="shared X", + ylabel="y", + labelsize=9, + ticklabelsize=6, + xlocator=5, + ylocator=2, + grid=False, + ) + + +def feature_edge_labels(fig, axs): + """Row and column headers belong to the figure, not to an axes.""" + for ax in axs: + bare(ax, facecolor=SUNK, edgecolor=RULE) + axs.format( + toplabels=("A", "B"), + leftlabels=("I", "II"), + toplabelsize=12, + leftlabelsize=12, + ) + + +def feature_abc(fig, axs): + """Panel letters use the conventional upper-left position.""" + for ax in axs: + bare(ax, facecolor=SUNK, edgecolor=RULE) + ax.format(abc="a.", abcloc="ul", abcsize=10) + + +def feature_corner_titles(fig, axs): + """Six corner-title keywords, no manual text placement.""" + ax = bare(axs[0], facecolor=SUNK, edgecolor=RULE) + ax.format( + ultitle="ul", + urtitle="ur", + lltitle="ll", + lrtitle="lr", + titlesize=10, + ) + _mark(ax, "title", size=11, weight="bold") + + +def feature_mosaic(fig, axs): + """A layout array is the layout.""" + for index, ax in enumerate(axs, start=1): + bare(ax, facecolor=SUNK, edgecolor=RULE) + ax.text( + 0.5, + 0.5, + str(index), + transform=ax.transAxes, + ha="center", + va="center", + fontsize=17, fontweight="bold", + color=ACCENT, + family="monospace", + ) + + +def feature_units(fig, axs): + """Sizes and spaces are given in real units.""" + ax = bare(axs[0], facecolor=SUNK, edgecolor=RULE) + ax.annotate( + "", + xy=(0.08, 0.5), + xytext=(0.92, 0.5), + xycoords="axes fraction", + arrowprops={"arrowstyle": "<->", "color": ACCENT, "lw": 0.9}, + ) + _mark(ax, "'55mm'", y=0.68, size=12, weight="bold") + _mark(ax, "refwidth", y=0.25, size=9, color=INK, weight="bold") + + +def feature_subplotgrid(fig, axs): + """The returned grid is indexable like an array.""" + for index, ax in enumerate(axs): + column = index % 3 + bare( + ax, + facecolor=ACCENT if column == 1 else SUNK, + edgecolor=RULE, + ) + axs[1].text( + 0.5, + 0.5, + ":, 1", + transform=axs[1].transAxes, + rotation=90, + ha="center", + va="center", + fontsize=10, fontweight="bold", + color="w", + family="monospace", + ) + + +# --------------------------------------------------------------- axes + + +def feature_panels(fig, axs): + """Marginal panels take their own gridspec slot.""" + ax = axs[0] + data = RNG.normal(size=(90, 2)) + ax.scatter(data[:, 0], data[:, 1], s=10, alpha=0.8, color=ACCENT) + for side in ("r", "t"): + panel = ax.panel_axes(side, width="4mm") + values = data[:, 0 if side == "t" else 1] + (panel.hist if side == "t" else panel.histh)( + values, + bins=8, + color=ACCENT, + alpha=0.6, + lw=0, + ) + bare(panel) + bare(ax) + + +def feature_inset(fig, axs): + """A zoomed copy of the same data, connected at the facing corners.""" + from matplotlib.patches import ConnectionPatch + + ax = axs[0] + y = np.sin(X) + RNG.normal(0, 0.05, X.size) + ax.plot(X, y, lw=2.2, color=ACCENT) + inset = ax.inset_axes([0.52, 0.06, 0.44, 0.42], zoom=True) + inset.plot(X, y, lw=2.2, color=ACCENT) + inset.format(xlim=(2, 4), ylim=(0.2, 1.1)) + bare(inset) + bare(ax) + indicator = inset.indicate_inset_zoom() + connectors = indicator.connectors if hasattr(indicator, "connectors") else indicator[1] + for connector in connectors: + connector.set_visible(False) + # Explicit facing-edge links avoid running through the source rectangle. + for corner, limit in ((0, 0.2), (1, 1.1)): + connector = ConnectionPatch( + xyA=(0, corner), coordsA=inset.transAxes, + xyB=(4, limit), coordsB=ax.transData, + arrowstyle="-", color=RULE, linewidth=1, clip_on=False, + zorder=inset.get_zorder() + 1, + ) + ax.add_artist(connector) + + +def feature_dual_axes(fig, axs): + """A twin axes that carries a scaled version of the same data.""" + ax = axs[0] + ax.plot(X, np.sin(X), lw=2.3, color=ACCENT) + dual = ax.dualx(lambda value: value * 2.54) + ax.format(xlabel="in", labelsize=9, ticklabelsize=6, xlocator=5, grid=False) + dual.format(xlabel="cm", labelsize=9, ticklabelsize=6, xlocator=10) + ax.format(yticks=[]) + + +# --------------------------------------------------------------- guides + + +def feature_outer_guide(fig, axs): + """Outer guides get their own slot instead of eating the axes.""" + ax = axs[0] + mesh = ax.pcolormesh(_field(), cmap="batlow", levels=7) + ax.colorbar(mesh, loc="r", width="3mm", ticks=[]) + bare(ax) + + +def feature_stacked_guides(fig, axs): + """Several guides on one side queue up.""" + ax = axs[0] + mesh = ax.pcolormesh(_field(), cmap="batlow", levels=7) + ax.colorbar(mesh, loc="b", width="2.6mm", ticks=[], length=0.9) + ax.colorbar(mesh, loc="b", width="2.6mm", ticks=[], length=0.9) + bare(ax) + + +def feature_inset_guide(fig, axs): + """The same location codes place a guide inside the axes.""" + ax = axs[0] + mesh = ax.pcolormesh(_field(), cmap="batlow", levels=7) + ax.colorbar(mesh, loc="ll", width="1.5mm", length="0.5ax", ticks=[], frame=True) + bare(ax) + + +# --------------------------------------------------------------- color + + +# --------------------------------------------------------------- data + + +# --------------------------------------------------------------- output + + +def feature_curvedtext(fig, axs): + """Text that follows a path.""" + ax = axs[0] + theta = np.linspace(0.15 * np.pi, 0.85 * np.pi, 200) + x, y = np.cos(theta), np.sin(theta) + ax.plot(x, y, lw=0.6, color=RULE) + ax.curvedtext(x, y, "curved text", fontsize=12, fontweight="bold", color=INK) + ax.format(xlim=(-1.25, 1.25), ylim=(-0.35, 1.3)) + bare(ax, linewidth=0) + + +#: Each feature is either *de novo* — matplotlib has no equivalent at all — or +#: an *enhancement*, where matplotlib can do it but you assemble it yourself. +#: Saying which, and naming the matplotlib counterpart, keeps the sheet honest: +#: most of what UltraPlot gives you is the second kind, and that is the point. +NEW, BETTER = "new", "better" + +#: name -> spec. ``draw`` and ``subplots`` make the icon; ``label`` captions it; +#: ``kind`` and ``mpl`` classify it; ``group`` places it on the page. +FEATURES = { + 'format': { + 'draw': feature_format, + 'subplots': {}, + 'group': 'layout', + 'label': 'format()', + 'kind': BETTER, + 'mpl': 'set_title, set_xlabel, set_xlim, tick_params, …', + }, + 'spanning_labels': { + 'draw': feature_spanning, + 'subplots': {'ncols': 2, 'share': True, 'span': True}, + 'group': 'layout', + 'label': 'span=True', + 'kind': BETTER, + 'mpl': 'supxlabel spans the whole figure, not a subset', + }, + 'edge_labels': { + 'draw': feature_edge_labels, + 'subplots': {'nrows': 2, 'ncols': 2}, + 'group': 'layout', + 'label': 'toplabels=', + 'kind': NEW, + 'mpl': None, + }, + 'abc_labels': { + 'draw': feature_abc, + 'subplots': {'nrows': 2, 'ncols': 2}, + 'group': 'layout', + 'label': "abc='a.'", + 'kind': NEW, + 'mpl': None, + }, + 'corner_titles': { + 'draw': feature_corner_titles, + 'subplots': {}, + 'group': 'layout', + 'label': 'urtitle=', + 'kind': BETTER, + 'mpl': 'set_title(loc=) — three slots, all above the axes', + }, + 'mosaic_array': { + 'draw': feature_mosaic, + 'subplots': {'array': [[1, 1, 2], [3, 4, 2]]}, + 'group': 'layout', + 'label': 'subplots([[…]])', + 'kind': BETTER, + 'mpl': 'subplot_mosaic', + }, + 'physical_units': { + 'draw': feature_units, + 'subplots': {}, + 'group': 'layout', + 'label': "refwidth='55mm'", + 'kind': NEW, + 'mpl': None, + }, + 'subplotgrid': { + 'draw': feature_subplotgrid, + 'subplots': {'nrows': 2, 'ncols': 3}, + 'group': 'layout', + 'label': 'axs[:, 1]', + 'kind': BETTER, + 'mpl': 'the ndarray indexes, but will not broadcast format()', + }, + 'panel_axes': { + 'draw': feature_panels, + 'subplots': {}, + 'group': 'axes', + 'label': "panel_axes('r')", + 'kind': BETTER, + 'mpl': 'mpl_toolkits axes_grid1 divider', + }, + 'inset_axes': { + 'draw': feature_inset, + 'subplots': {}, + 'group': 'axes', + 'label': 'inset_axes(zoom=True)', + 'kind': BETTER, + 'mpl': 'inset_axes + indicate_inset_zoom', + }, + 'dualx': { + 'draw': feature_dual_axes, + 'subplots': {}, + 'group': 'axes', + 'label': 'dualx(f)', + 'kind': BETTER, + 'mpl': 'secondary_xaxis', + }, + 'outer_guides': { + 'draw': feature_outer_guide, + 'subplots': {}, + 'group': 'guides', + 'label': "colorbar(loc='r')", + 'kind': BETTER, + 'mpl': 'fig.colorbar(ax=) steals space from the axes', + }, + 'stacked_guides': { + 'draw': feature_stacked_guides, + 'subplots': {}, + 'group': 'guides', + 'label': 'two on one side', + 'kind': BETTER, + 'mpl': 'possible, but you place the second one yourself', + }, + 'inset_guides': { + 'draw': feature_inset_guide, + 'subplots': {}, + 'group': 'guides', + 'label': "colorbar(loc='ll')", + 'kind': BETTER, + 'mpl': 'colorbar(cax=inset_axes(...))', + }, + 'curved_text': { + 'draw': feature_curvedtext, + 'subplots': {}, + 'group': 'data', + 'label': 'curvedtext()', + 'kind': NEW, + 'mpl': None, + }, +} + + +def main(): + use_style(fontsize=5) + uplt.rc.update({"font.weight": "bold", "axes.labelweight": "bold", + "axes.titleweight": "bold", "abc.weight": "bold"}) + failures = [] + for name, spec in FEATURES.items(): + kwargs = dict(spec["subplots"]) + array = kwargs.pop("array", None) + args = (array,) if array is not None else () + fig, axs = uplt.subplots( + *args, + figwidth=SIZE, + figheight=SIZE, + hspace="2mm", + wspace="2mm", + **kwargs, + ) + try: + spec["draw"](fig, axs) + except Exception as error: # keep going; the build reports the gap + failures.append(f"{name}: {type(error).__name__}: {error}") + uplt.close(fig) + continue + save(fig, f"features/{name}.svg", dpi=220) + if failures: + print("feature icon failures:") + for failure in failures: + print(f" {failure}") + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/icons.py b/tools/cheatsheet/parts/icons.py new file mode 100644 index 000000000..b9e84b6aa --- /dev/null +++ b/tools/cheatsheet/parts/icons.py @@ -0,0 +1,761 @@ +#!/usr/bin/env python3 +""" +One small plot per UltraPlot command, drawn with the command itself. + +Everything is drawn from the shared vocabulary in ``common`` — one wave, one +cloud, one field, one set of categories — so two icons differ only where the +commands differ. Icons are read at a glance and often at 10 mm, so the drawing +rules are deliberately narrow: thick strokes, few marks, no ticks, and colour +used for one job at a time. + +Each entry is classified as ``SAME`` (matplotlib has the command), ``BETTER`` +(matplotlib can, but you assemble it) or ``NEW`` (no equivalent), and the +matplotlib counterpart is named for the middle case. ``GROUP`` places it on the +page and in the docs index. +""" + +from __future__ import annotations + +import numpy as np + +import ultraplot as uplt + +from common import ( + ASSETS, + CATEGORIES, + CLOUD, + ICON_DIVERGING, + ICON_LINE, + ICON_LW, + ICON_MARGIN, + ICON_MS, + ICON_DENSITY, + ICON_SEQUENTIAL, + ICON_STRUCTURE, + SAMPLES, + SIGNED, + VALUES, + WAVE, + WAVE_X, + WAVES, + bare, + rotational_field, + save, + peak_field, + smooth_field, + use_style, + without_new_text, +) + +#: Icons are square and rendered large; the pages scale them down. +SIZE = "26mm" + +#: Kinds, so the pages can say how far a command is from matplotlib. +SAME, BETTER, NEW = "same", "better", "new" + +RNG = np.random.default_rng(51423) + + +# ------------------------------------------------------------------ lines + + +def icon_plot(ax): + ax.plot(WAVE_X, WAVES, lw=ICON_LW) + + +def icon_scatter(ax): + ax.scatter(CLOUD[:, 0], CLOUD[:, 1], s=ICON_MS, alpha=0.8) + + +def icon_step(ax): + ax.step(np.arange(10), np.tile(VALUES, 2), lw=ICON_LW, color=ICON_LINE) + + +def icon_stem(ax): + # stem takes fmt strings, so its colours come from the cycle: C0 is the + # stems and marker, C1 the baseline. + ax.stem( + np.arange(8), + np.sin(np.linspace(0, 3, 8)) + 1.2, + cycle=uplt.Cycle((ICON_LINE, ICON_STRUCTURE), name="_no_name"), + ) + + +def icon_vlines(ax): + ax.vlines( + np.arange(9), 0, np.sin(np.linspace(0, 4, 9)), lw=ICON_LW, color=ICON_LINE + ) + + +def icon_hlines(ax): + ax.hlines( + np.arange(9), 0, np.sin(np.linspace(0, 4, 9)), lw=ICON_LW, color=ICON_LINE + ) + + +def icon_parametric(ax): + theta = np.linspace(0, 4 * np.pi, 300) + ax.parametric( + theta * np.cos(theta), + theta * np.sin(theta), + theta, + cmap=ICON_SEQUENTIAL, + lw=3.6, + ) + + +def icon_loglog(ax): + x = np.logspace(0, 3, 40) + ax.loglog(x, x**1.6, lw=ICON_LW, color=ICON_LINE) + ax.loglog(x, x**0.8, lw=ICON_LW, color=ICON_STRUCTURE) + + +# --------------------------------------------------------------- category + + +def icon_bar(ax): + ax.bar(CATEGORIES, VALUES, width=0.72) + + +def icon_barh(ax): + ax.barh(CATEGORIES, VALUES, width=0.72) + + +def icon_bar_stack(ax): + ax.bar(CATEGORIES, RNG.uniform(0.2, 0.6, (5, 3)), width=0.72, stack=True) + + +def icon_bar_negpos(ax): + ax.bar(CATEGORIES, SIGNED, width=0.72, negpos=True) + + +def icon_lollipop(ax): + ax.lollipop(CATEGORIES, VALUES, marker="o", markersize=9, lw=2, color=ICON_LINE) + + +def icon_lollipoph(ax): + ax.lollipoph(CATEGORIES, VALUES, marker="o", markersize=9, lw=2, color=ICON_LINE) + + +def icon_pie(ax): + ax.pie(VALUES, np.zeros(5)) + + +def icon_area(ax): + ax.area(WAVE_X, np.abs(WAVES) + 0.2, alpha=0.9) + + +def icon_area_stack(ax): + ax.area(WAVE_X, np.abs(WAVES) + 0.2, stack=True, alpha=0.9) + + +def icon_area_negpos(ax): + ax.area(WAVE_X, WAVE, negpos=True, alpha=0.9) + + +# ----------------------------------------------------------- distribution + + +def icon_hist(ax): + ax.hist(CLOUD[:, 0], bins=12, filled=True, alpha=0.9, color=ICON_LINE) + + +def icon_histh(ax): + ax.histh(CLOUD[:, 0], bins=12, filled=True, alpha=0.9, color=ICON_LINE) + + +def icon_hist2d(ax): + points = RNG.normal(size=(2, 4000)) + ax.hist2d(points[0], points[1], 16, cmap=ICON_DENSITY) + + +def icon_hexbin(ax): + points = RNG.normal(size=(2, 4000)) + ax.hexbin(points[0], points[1], gridsize=10, cmap=ICON_DENSITY) + + +def icon_box(ax): + ax.box(RNG.normal(size=(200, 3)) + [0, 0.7, -0.5], lw=0.9, showfliers=False) + + +def icon_boxh(ax): + ax.boxh(RNG.normal(size=(200, 3)) + [0, 0.7, -0.5], lw=0.9, showfliers=False) + + +def icon_violin(ax): + ax.violin(RNG.normal(size=(200, 3)) + [0, 0.7, -0.5], lw=0.9) + + +def icon_violinh(ax): + ax.violinh(RNG.normal(size=(200, 3)) + [0, 0.7, -0.5], lw=0.9) + + +def icon_beeswarm(ax): + ax.beeswarm(RNG.normal(size=(140, 3)) + [0, 0.7, -0.5], ms=4) + + +def icon_ridgeline(ax): + data = [RNG.normal(size=200) + index * 0.5 for index in range(5)] + ax.ridgeline(data, overlap=0.6, cmap=ICON_SEQUENTIAL, lw=0.7) + + +def icon_errorbars(ax): + ax.plot( + WAVE_X, + SAMPLES, + mean=True, + shadestd=1, + fadepctile=(10, 90), + lw=ICON_LW, + color=ICON_LINE, + ) + + +def icon_bars(ax): + ax.plot( + WAVE_X[::6], + SAMPLES[:, ::6], + mean=True, + bars=True, + lw=ICON_LW, + color=ICON_LINE, + barcolor=ICON_STRUCTURE, + barlw=1.0, + ) + + +# --------------------------------------------------------------- 2D fields + + +def icon_pcolormesh(ax): + ax.pcolormesh(smooth_field(), cmap=ICON_SEQUENTIAL) + + +def icon_pcolor(ax): + ax.pcolor(smooth_field(14), cmap=ICON_SEQUENTIAL) + + +def icon_contour(ax): + ax.contour(peak_field(), color=ICON_LINE, levels=7, lw=1.3) + + +def icon_contourf(ax): + ax.contourf(smooth_field(), cmap=ICON_SEQUENTIAL, levels=9) + + +def icon_contour_labels(ax): + ax.contour( + peak_field(), + color=ICON_LINE, + levels=5, + lw=1.2, + labels=True, + labels_kw={"fontsize": 5}, + ) + + +def icon_imshow(ax): + ax.imshow(smooth_field(24), cmap="dusk") + + +def icon_matshow(ax): + ax.matshow(smooth_field(8), cmap="dusk") + + +def icon_spy(ax): + ax.spy(RNG.random((18, 18)) > 0.82, markersize=1.8, color=ICON_LINE) + + +def icon_heatmap(ax): + ax.heatmap(smooth_field(5), cmap=ICON_DIVERGING, vmin=-1.3, vmax=1.3) + + +def icon_heatmap_labels(ax): + ax.heatmap( + smooth_field(3).round(1), + cmap=ICON_DIVERGING, + vmin=-1.3, + vmax=1.3, + labels=True, + labels_kw={"fontsize": 5.5}, + ) + + +def icon_levels(ax): + ax.pcolormesh(smooth_field(), cmap=ICON_SEQUENTIAL, levels=6) + + +def icon_continuous(ax): + ax.pcolormesh(smooth_field(), cmap=ICON_SEQUENTIAL, discrete=False) + + +def icon_diverging(ax): + ax.pcolormesh( + smooth_field(), + cmap=ICON_DIVERGING, + values=uplt.arange(-1.2, 1.2, 0.3), + extend="both", + ) + + +def icon_tripcolor(ax): + x, y = RNG.uniform(0, 1, 60), RNG.uniform(0, 1, 60) + ax.tripcolor(x, y, np.sin(6 * x) * np.cos(6 * y), cmap=ICON_SEQUENTIAL) + + +def icon_tricontourf(ax): + x, y = RNG.uniform(0, 1, 150), RNG.uniform(0, 1, 150) + ax.tricontourf(x, y, np.sin(5 * x) * np.cos(5 * y), cmap=ICON_SEQUENTIAL, levels=8) + + +# ------------------------------------------------------------ vector fields + + +def icon_quiver(ax): + x, y, u, v = rotational_field(8) + ax.quiver(x, y, u, v, color=ICON_LINE, width=0.013) + + +def icon_barbs(ax): + x, y, u, v = rotational_field(5, extent=1.6) + ax.barbs( + x, + y, + u * 12, + v * 12, + np.hypot(u, v), + cmap=ICON_SEQUENTIAL, + length=4.5, + linewidth=0.5, + ) + + +def icon_streamplot(ax): + x, y, u, v = rotational_field(28) + ax.streamplot(x, y, u, v, color=np.hypot(x, y), cmap=ICON_SEQUENTIAL, lw=0.8) + + +def icon_curved_quiver(ax): + x, y, u, v = rotational_field(24) + ax.curved_quiver( + x, + y, + u, + v, + color=np.hypot(x, y), + cmap=ICON_SEQUENTIAL, + density=5, + grains=7, + linewidth=1.4, + arrowsize=1.2, + arrow_at_end=True, + scale = 3, + ) + + +# ------------------------------------------------------ networks and polar + + +def icon_graph(ax): + import networkx as nx + + ax.graph( + nx.karate_club_graph(), + layout="spring", + layout_kw={"seed": 4}, + node_kw={"node_size": 35, "node_color": ICON_LINE, "linewidths": 0}, + edge_kw={"alpha": 0.55, "width": 1.1}, + label_kw={"font_size": 0}, + ) + + +def icon_sankey(ax): + ax.sankey( + nodes=["A", "B", "C", "D"], + flows=[("A", "B", 5.0, ""), ("A", "C", 3.0, ""), ("B", "D", 2.5, "")], + style="budget", + flow_labels=False, + node_label_box=False, + ) + + +def icon_ribbon(ax): + import pandas as pd + + rows = [ + { + "id": identifier, + "period": period, + "topic": f"T{(identifier + period) % 4}", + "value": 1.0, + } + for period in range(4) + for identifier in range(12) + ] + with without_new_text(ax): + ax.ribbon(pd.DataFrame(rows)) + + +def icon_chord(ax): + import pandas as pd + + names = list("ABCD") + ax.chord_diagram( + pd.DataFrame(RNG.integers(2, 10, (4, 4)), index=names, columns=names), + ticks_interval=None, + space=6, + ) + + +def icon_radar(ax): + import pandas as pd + + frame = pd.DataFrame( + {"a": [3.5, 4.2], "b": [4.2, 2.8], "c": [2.6, 4.4], "d": [3.9, 3.1]}, + index=["one", "two"], + ) + with without_new_text(ax): + ax.radar_chart(frame, vmin=0, vmax=5, fill=True, marker_size=5) + + +def icon_phylogeny(ax): + ax.phylogeny( + "(((A:1,B:1):1,(C:1,D:1):1):1,((E:1,F:1):1,(G:1,H:1):1):2);", + leaf_label_size=0, + ) + + +def icon_taylor(ax): + ax.format( + rlim=(0, 1.6), + corrlines=(1, 0.9, 0.6, 0), + rlines=0.5, + corrlabel="", + ticklabelsize=4, + labelsize=4, + ) + ax.plot_corr(1, 1, marker="*", markersize=13, color="red7") + for (corr, std), color in zip( + ((0.95, 1.15), (0.8, 0.75)), + ("denim", "green7"), + ): + ax.scatter_corr(corr, std, s=40, color=color, zorder=6) + + +# --------------------------------------------------------------------- maps +# +# Maps are the case where UltraPlot's integration shows: a projection short +# name, cartographic features as format keywords, and any plotting command on +# top in lon/lat. Each icon layers something over the map rather than showing +# an empty globe. + + +def _global_field(nlon=181, nlat=91): + """ + A smooth global field in lon/lat, for the map icons to drape. + """ + lon = np.linspace(-180, 180, nlon) + lat = np.linspace(-90, 90, nlat) + grid_lon, grid_lat = np.meshgrid(lon, lat) + data = np.cos(np.deg2rad(grid_lat)) ** 2 * np.sin( + np.deg2rad(2 * grid_lon) + ) + 0.4 * np.sin(np.deg2rad(3 * grid_lat)) + return lon, lat, data + + +def icon_map_field(ax): + lon, lat, data = _global_field() + ax.pcolormesh(lon, lat, data, cmap=ICON_DIVERGING, levels=11, extend="both") + ax.format(coast=True, coastlinewidth=0.4, labels=False, grid=False) + + +def icon_map_contour(ax): + lon, lat, data = _global_field() + ax.contourf(lon, lat, data, cmap=ICON_DIVERGING, levels=9, extend="both") + ax.contour(lon, lat, data, levels=5, color="k", lw=0.35) + ax.format(coast=True, coastlinewidth=0.4, labels=False, grid=False) + + +def icon_map_features(ax): + ax.format( + land=True, + ocean=True, + coast=True, + borders=True, + landcolor="gray3", + oceancolor=ICON_LINE, + coastlinewidth=0.35, + labels=False, + grid=True, + gridalpha=0.4, + ) + + +def icon_map_scatter(ax): + state = np.random.default_rng(7) + lon = state.uniform(-170, 170, 45) + lat = state.uniform(-70, 70, 45) + ax.format( + land=True, + landcolor="gray3", + coast=True, + coastlinewidth=0.3, + labels=False, + grid=False, + ) + ax.scatter( + lon, + lat, + s=state.uniform(6, 40, 45), + c=state.uniform(0, 1, 45), + cmap=ICON_SEQUENTIAL, + alpha=0.85, + lw=0, + ) + + +def icon_map_quiver(ax): + lon = np.linspace(-170, 170, 15) + lat = np.linspace(-70, 70, 9) + grid_lon, grid_lat = np.meshgrid(lon, lat) + u = np.cos(np.deg2rad(grid_lat)) * 10 + v = np.sin(np.deg2rad(2 * grid_lon)) * 6 + ax.format( + land=True, + landcolor="gray2", + coast=True, + coastlinewidth=0.3, + labels=False, + grid=False, + ) + ax.quiver(lon, lat, u, v, color=ICON_LINE, width=0.008) + + +def icon_map_track(ax): + steps = np.linspace(0, 1, 120) + lon = -140 + 260 * steps + lat = 55 * np.sin(np.pi * steps) - 10 + ax.format( + land=True, + landcolor="gray3", + ocean=True, + oceancolor="#dce6f0", + coast=True, + coastlinewidth=0.3, + labels=False, + grid=False, + ) + ax.plot(lon, lat, lw=1.8, color="red7") + ax.scatter(lon[::40], lat[::40], s=14, color="red7", zorder=5) + + +#: name -> (draw, projection, kind, matplotlib counterpart, group) +#: Groups place an icon on the page: relational, distribution, field, vector, +#: network, keyword (what one argument does), swapped (the …h/…x siblings). +ICONS = { + # -------------------------------------------------------- relational + "plot": (icon_plot, None, SAME, None, "relational"), + "scatter": (icon_scatter, None, SAME, None, "relational"), + "step": (icon_step, None, SAME, None, "relational"), + "stem": (icon_stem, None, SAME, None, "relational"), + "vlines": (icon_vlines, None, SAME, None, "relational"), + "hlines": (icon_hlines, None, SAME, None, "relational"), + "loglog": (icon_loglog, None, SAME, None, "relational"), + "parametric": ( + icon_parametric, + None, + BETTER, + "LineCollection by hand", + "relational", + ), + "bar": (icon_bar, None, SAME, None, "relational"), + "barh": (icon_barh, None, SAME, None, "relational"), + "lollipop": ( + icon_lollipop, + None, + BETTER, + "stem, then markers by hand", + "relational", + ), + "area": (icon_area, None, BETTER, "fill_between", "relational"), + "pie": (icon_pie, None, SAME, None, "relational"), + # ------------------------------------------------------ distribution + "hist": (icon_hist, None, SAME, None, "distribution"), + "hist2d": (icon_hist2d, None, SAME, None, "distribution"), + "hexbin": (icon_hexbin, None, SAME, None, "distribution"), + "box": (icon_box, None, SAME, None, "distribution"), + "violin": (icon_violin, None, SAME, None, "distribution"), + "beeswarm": (icon_beeswarm, None, NEW, None, "distribution"), + "ridgeline": (icon_ridgeline, None, NEW, None, "distribution"), + "errorbars": ( + icon_errorbars, + None, + BETTER, + "errorbar, after you reduce", + "distribution", + ), + # ------------------------------------------------------------- field + "pcolormesh": (icon_pcolormesh, None, SAME, None, "field"), + "pcolor": (icon_pcolor, None, SAME, None, "field"), + "contour": (icon_contour, None, SAME, None, "field"), + "contourf": (icon_contourf, None, SAME, None, "field"), + "imshow": (icon_imshow, None, SAME, None, "field"), + "matshow": (icon_matshow, None, SAME, None, "field"), + "spy": (icon_spy, None, SAME, None, "field"), + "heatmap": (icon_heatmap, None, BETTER, "imshow, then label each cell", "field"), + "tripcolor": (icon_tripcolor, None, SAME, None, "field"), + "tricontourf": (icon_tricontourf, None, SAME, None, "field"), + # ------------------------------------------------------------ vector + "quiver": (icon_quiver, None, SAME, None, "vector"), + "barbs": (icon_barbs, None, SAME, None, "vector"), + "streamplot": (icon_streamplot, None, SAME, None, "vector"), + "curved_quiver": (icon_curved_quiver, None, NEW, None, "vector"), + # ----------------------------------------------------------- network + "graph": (icon_graph, None, BETTER, "networkx draws onto an axes", "network"), + "sankey": (icon_sankey, None, BETTER, "matplotlib.sankey.Sankey", "network"), + "ribbon": (icon_ribbon, None, NEW, None, "network"), + "chord_diagram": (icon_chord, "polar", NEW, None, "network"), + "radar_chart": (icon_radar, "polar", BETTER, "a polar plot, by hand", "network"), + "phylogeny": (icon_phylogeny, "polar", NEW, None, "network"), + "taylor": (icon_taylor, "taylor", NEW, None, "network"), + # --------------------------------------------------------------- maps + "proj='robin'": ( + icon_map_field, + "robin", + BETTER, + "cartopy, wired up by hand", + "maps", + ), + "proj='ortho'": ( + icon_map_contour, + "ortho", + BETTER, + "cartopy, wired up by hand", + "maps", + ), + "coast, land, ocean": ( + icon_map_features, + "cyl", + BETTER, + "cartopy feature calls", + "maps", + ), + "scatter on a map": ( + icon_map_scatter, + "robin", + BETTER, + "transform= on every call", + "maps", + ), + "quiver on a map": ( + icon_map_quiver, + "cyl", + BETTER, + "transform= on every call", + "maps", + ), + "plot on a map": ( + icon_map_track, + "ortho", + BETTER, + "transform= on every call", + "maps", + ), + # -------------------------------- what one keyword does to a command + "bar(stack=True)": ( + icon_bar_stack, + None, + BETTER, + "bottom=, cumulatively", + "keyword", + ), + "bar(negpos=True)": (icon_bar_negpos, None, NEW, None, "keyword"), + "area(stack=True)": (icon_area_stack, None, BETTER, "stackplot", "keyword"), + "area(negpos=True)": (icon_area_negpos, None, NEW, None, "keyword"), + "plot(bars=True)": ( + icon_bars, + None, + BETTER, + "errorbar, after you reduce", + "keyword", + ), + "contour(labels=True)": (icon_contour_labels, None, BETTER, "clabel", "keyword"), + "heatmap(labels=True)": ( + icon_heatmap_labels, + None, + BETTER, + "a loop of ax.text", + "keyword", + ), + "pcolormesh(levels=6)": (icon_levels, None, BETTER, "BoundaryNorm", "keyword"), + "pcolormesh(discrete=False)": (icon_continuous, None, SAME, None, "keyword"), + "pcolormesh(values=)": (icon_diverging, None, BETTER, "TwoSlopeNorm", "keyword"), + # ------------------------------------- the siblings that swap the axes + "histh": (icon_histh, None, NEW, None, "swapped"), + "boxh": (icon_boxh, None, BETTER, "boxplot(vert=False)", "swapped"), + "violinh": (icon_violinh, None, BETTER, "violinplot(vert=False)", "swapped"), + "lollipoph": (icon_lollipoph, None, NEW, None, "swapped"), +} + + +def slug(name): + """ + Turn a command signature into a file name. + + Names carry parentheses, quotes and spaces — ``proj='robin'`` — none of + which belong in a path that draw.io and Sphinx both have to reference. + """ + name = name.strip() + for old, new in ( + ("(", "-"), + (")", ""), + ("=", "-"), + (",", "-"), + ("'", ""), + ('"', ""), + (" ", "-"), + ): + name = name.replace(old, new) + while "--" in name: + name = name.replace("--", "-") + return name.strip("-") + + +def main(): + use_style(fontsize=5) + failures = [] + for name, (draw, proj, _kind, _mpl, _group) in ICONS.items(): + # Nearly full bleed: a thin margin so the drawing breathes inside the + # tile without the empty band tight layout used to leave. Projections + # keep a little more room for their own circular frame. + edge = "0.9mm" if proj is not None else "0.6mm" + fig, ax = uplt.subplots( + figwidth=SIZE, + figheight=SIZE, + proj=proj, + tight=False, + left=edge, + right=edge, + top=edge, + bottom=edge, + ) + try: + draw(ax) + except Exception as error: # keep going; the build reports the gap + failures.append(f"{name}: {type(error).__name__}: {error}") + uplt.close(fig) + continue + if proj is None: + bare(ax, linewidth=0) + ax.margins(ICON_MARGIN) + else: + ax.format(grid=False, labelsize=0, ticklabelsize=0, title="") + save(fig, f"icons/{slug(name)}.svg", dpi=220) + if failures: + print("icon failures:") + for failure in failures: + print(f" {failure}") + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/ultraplot_cheatsheet.drawio b/tools/cheatsheet/ultraplot_cheatsheet.drawio new file mode 100644 index 000000000..19056d62f --- /dev/null +++ b/tools/cheatsheet/ultraplot_cheatsheet.drawio @@ -0,0 +1,1744 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +