From c46f953a7f1481d0e3054e5cf5476d8330afcf74 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 2 Sep 2026 14:35:36 +1000 Subject: [PATCH 1/3] Add the cheatsheet generator and a visual plot-type index --- .gitignore | 10 + docs/_scripts/build_plot_types.py | 34 ++ docs/conf.py | 2 + docs/index.rst | 1 + docs/plot_types.rst | 516 ++++++++++++++++++ tools/cheatsheet/README.md | 96 ++++ tools/cheatsheet/build.py | 103 ++++ tools/cheatsheet/cheatsheet.typ | 622 ++++++++++++++++++++++ tools/cheatsheet/docs_index.py | 268 ++++++++++ tools/cheatsheet/parts/color.py | 241 +++++++++ tools/cheatsheet/parts/common.py | 200 +++++++ tools/cheatsheet/parts/features.py | 818 ++++++++++++++++++++++++++++ tools/cheatsheet/parts/geo.py | 114 ++++ tools/cheatsheet/parts/guides.py | 159 ++++++ tools/cheatsheet/parts/icons.py | 819 +++++++++++++++++++++++++++++ tools/cheatsheet/parts/layout.py | 157 ++++++ tools/cheatsheet/poster.typ | 136 +++++ 17 files changed, 4296 insertions(+) create mode 100644 docs/_scripts/build_plot_types.py create mode 100644 docs/plot_types.rst create mode 100644 tools/cheatsheet/README.md create mode 100644 tools/cheatsheet/build.py create mode 100644 tools/cheatsheet/cheatsheet.typ create mode 100644 tools/cheatsheet/docs_index.py create mode 100644 tools/cheatsheet/parts/color.py create mode 100644 tools/cheatsheet/parts/common.py create mode 100644 tools/cheatsheet/parts/features.py create mode 100644 tools/cheatsheet/parts/geo.py create mode 100644 tools/cheatsheet/parts/guides.py create mode 100644 tools/cheatsheet/parts/icons.py create mode 100644 tools/cheatsheet/parts/layout.py create mode 100644 tools/cheatsheet/poster.typ diff --git a/.gitignore b/.gitignore index fc0755a70..3f6b2615a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,13 @@ ultraplot/_version.py # Nox build directories .nox/* + +# Cheatsheet build output: the icons, the sheets, and the copies the docs use. +# All are generated by tools/cheatsheet/build.py, and regenerated during the +# docs build, so only the sources belong in the repository. +tools/cheatsheet/assets/ +docs/_static/plot_types/ +ultraplot_cheatsheet*.pdf +ultraplot_cheatsheet*.png +ultraplot_plot_types*.pdf +ultraplot_plot_types*.png 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 cb17889ef..f029e7ba9 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 5b5ec248d..9918b5f34 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/tools/cheatsheet/README.md b/tools/cheatsheet/README.md new file mode 100644 index 000000000..417675827 --- /dev/null +++ b/tools/cheatsheet/README.md @@ -0,0 +1,96 @@ +# UltraPlot cheatsheet + +Two A3 pages in the spirit of [matplotlib's cheatsheets](https://matplotlib.org/cheatsheets/), +and built the same way: small Python scripts render the figures, and a document +engine assembles them. Matplotlib uses LaTeX for the assembly step; this uses +[Typst](https://typst.app), which keeps the layout in one readable file. + +``` +tools/cheatsheet/ +├── build.py # render the parts, compile the sheets, write the docs page +├── cheatsheet.typ # the three-page sheet: palette, panels, grid, copy +├── poster.typ # the companion plot-type poster (A3, every command) +├── docs_index.py # writes docs/plot_types.rst from the same registry +├── parts/ +│ ├── common.py # shared drawing style and the save() helper +│ ├── layout.py # axis sharing, mosaics, titles, panels +│ ├── icons.py # one thumbnail per plotting command +│ ├── features.py # one thumbnail per UltraPlot-only feature +│ ├── color.py # bundled colormap tables, cycles, norms, palette.typ +│ ├── guides.py # colorbars, legends, statistical indicators +│ └── geo.py # projections and map features +└── assets/ # generated; safe to delete +``` + +## Build + +```bash +micromamba run -n ultraplot-dev python tools/cheatsheet/build.py +``` + +writes, at the repository root, `ultraplot_cheatsheet.pdf` (three A3 pages), +`ultraplot_plot_types.pdf` (the one-page poster), PNGs of each, and +`docs/plot_types.rst` with its icons in `docs/_static/plot_types/`. Three flags +help while iterating: + +```bash +python tools/cheatsheet/build.py --figures # re-render the figures only +python tools/cheatsheet/build.py --typst # re-lay out the sheets only +python tools/cheatsheet/build.py --docs # rewrite the docs page only +``` + +Each part script also runs on its own, which is the fastest loop when you are +working on one figure: + +```bash +cd tools/cheatsheet/parts && python icons.py +``` + +Requirements: an environment with UltraPlot, cartopy (for `geo.py`), networkx +and pandas (for a few icons), plus the `typst` binary and the IBM Plex fonts. +`geo.py` skips itself with a note if cartopy is missing rather than failing the +build. + +## Conventions + +- **Two icon sets, three kinds.** `icons.py` answers "what can I draw" (one + thumbnail per plotting command); `features.py` answers "what does UltraPlot + add", following the sections of `docs/why.rst`. Both registries classify each + entry as `same` (matplotlib has the command), `better` (matplotlib can do it, + but you assemble it yourself) or `new` (no equivalent), and name the + matplotlib counterpart for the middle case. That distinction is the honest + one: most of UltraPlot's value is the middle case, and the page says so + rather than claiming everything is unprecedented. +- **The galleries are generated.** Each registry writes a Typst manifest — + `assets/icons.typ` and `assets/features.typ` — and both `cheatsheet.typ` and + `poster.typ` build their grids by filtering those. Adding an icon means adding + one registry entry; the sheets, the poster and the docs page pick it up on the + next build. The cheatsheet shows the thirty entries flagged `FEATURED`; the + poster shows all of them. +- **One drawing vocabulary.** `common.py` holds the sample data every icon draws + from — one wave, one cloud, one field, one set of categories — plus the colour + roles and the stroke weights that survive being scaled to 10 mm. Two icons + then differ only where the commands differ, which is the whole point of a + small-multiples gallery. +- **Every figure is the real command.** No mock-ups: the `contourf` thumbnail is + `ax.contourf`, the sharing comparison is two real figures with `share` set + differently, and the colormap tables are read from + `ultraplot.demos.CMAP_TABLE` — the same source `uplt.show_cmaps()` uses, so + the sheet cannot drift from what is actually registered. +- **The palette comes from the plots.** `parts/color.py` writes + `assets/palette.typ` with real `batlow` samples; the section rails and the + masthead gradient in `cheatsheet.typ` import it. +- **Parts do not know about the page.** A part renders one figure at a sensible + size and saves it. All sizing, cropping and captioning happens in Typst. +- **Panel heights are set per band.** `sheet(weights: (...))` gives each band a + share of the page, and every panel in a band matches its neighbours. If a + panel overflows, either trim its content or raise that band's weight — the + weights are the tuning knob. + +## Adding a panel + +1. If it needs a figure, add a function to the relevant part script, save with + `save(fig, "name.png")`, and check it renders on its own. +2. Add a `panel(...)` block to `cheatsheet.typ` in the right band. +3. Rebuild and look at the PNGs. Content that overflows its panel is visible + immediately — Typst does not clip it, it runs over the frame. diff --git a/tools/cheatsheet/build.py b/tools/cheatsheet/build.py new file mode 100644 index 000000000..fdec32d11 --- /dev/null +++ b/tools/cheatsheet/build.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Build the UltraPlot cheatsheet. + +Renders every figure part with UltraPlot, then hands the assets to Typst to +lay out. This mirrors how matplotlib builds its own cheatsheets: small scripts +produce the panels, and the document engine assembles them. + + micromamba run -n ultraplot-dev python tools/cheatsheet/build.py + micromamba run -n ultraplot-dev python tools/cheatsheet/build.py --figures + micromamba run -n ultraplot-dev python tools/cheatsheet/build.py --typst +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PARTS = os.path.join(HERE, "parts") +ROOT = os.path.dirname(os.path.dirname(HERE)) + +#: Part modules, in the order their output appears on the page. +MODULES = ("layout", "icons", "features", "color", "guides", "geo") + + +def render_figures(): + """ + Run every part script in its own process, so one failure is isolated. + """ + sys.path.insert(0, PARTS) + for name in MODULES: + print(f"{name}.py") + result = subprocess.run( + [sys.executable, os.path.join(PARTS, f"{name}.py")], + cwd=PARTS, + ) + if result.returncode: + raise SystemExit(f"{name}.py failed with code {result.returncode}") + + +def compile_document(name, stem, png=True): + """ + Compile one Typst document to PDF, and optionally to PNG pages. + """ + source = os.path.join(HERE, name) + pdf = os.path.join(ROOT, f"{stem}.pdf") + subprocess.run(["typst", "compile", "--root", HERE, source, pdf], check=True) + print(f" {os.path.relpath(pdf, ROOT)}") + if png: + pattern = os.path.join(ROOT, stem + "_p{p}.png") + subprocess.run( + [ + "typst", + "compile", + "--root", + HERE, + "--format", + "png", + "--ppi", + "150", + source, + pattern, + ], + check=True, + ) + print(f" {os.path.relpath(pattern, ROOT)}") + + +def compile_typst(png=True): + """ + Compile both sheets: the three-page cheatsheet and the plot-type poster. + """ + compile_document("cheatsheet.typ", "ultraplot_cheatsheet", png=png) + compile_document("poster.typ", "ultraplot_plot_types", png=png) + + +def write_docs_page(): + """ + Regenerate the documentation's visual plot-type index. + """ + subprocess.run([sys.executable, os.path.join(HERE, "docs_index.py")], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--figures", action="store_true", help="only render figures") + parser.add_argument("--typst", action="store_true", help="only run typst") + parser.add_argument("--docs", action="store_true", help="only write the docs page") + args = parser.parse_args() + only = args.figures or args.typst or args.docs + if args.figures or not only: + render_figures() + if args.typst or not only: + compile_typst() + if args.docs or not only: + write_docs_page() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/cheatsheet.typ b/tools/cheatsheet/cheatsheet.typ new file mode 100644 index 000000000..948e5c5fa --- /dev/null +++ b/tools/cheatsheet/cheatsheet.typ @@ -0,0 +1,622 @@ +// UltraPlot cheatsheet — layout and typography. +// +// Every figure on these pages is rendered by a script in parts/ and dropped in +// assets/. This file only arranges them, the way matplotlib's own cheatsheets +// assemble their generated panels. Build with tools/cheatsheet/build.py. + +#import "assets/palette.typ": batlow, rails +#import "assets/features.typ": features +#import "assets/icons.typ": commands + +// ---------------------------------------------------------------- palette +#let paper = rgb("#f2f4f7") +#let panelbg = rgb("#ffffff") +#let ink = rgb("#0f151d") +#let inksoft = rgb("#4a5663") +#let inkfaint = rgb("#8593a1") +#let rule = rgb("#dbe1e8") +#let sunk = rgb("#f0f3f7") +#let accent = rgb("#3b638c") +#let badgecolor = rgb("#a8414f") +#let codeink = rgb("#243040") + +// ---------------------------------------------------------------- page +#set page( + paper: "a3", + flipped: true, + margin: (x: 10mm, top: 8mm, bottom: 9mm), + fill: paper, + footer: context [ + #set text(size: 6pt, fill: rgb("#8593a1")) + #grid(columns: (1fr, auto), align: (left + horizon, right + horizon), + [Figures rendered by #raw("tools/cheatsheet/parts/*.py"), assembled by + #raw("cheatsheet.typ") · rails and swatches are real #raw("batlow") samples + · ultraplot.readthedocs.io], + [#counter(page).display() / #counter(page).final().first()], + ) + ], + footer-descent: 4mm, +) +#set text(font: ("IBM Plex Sans", "DejaVu Sans"), size: 7.4pt, fill: ink) +#set par(leading: 0.52em, spacing: 0.62em, justify: false) + +// Code is set plain rather than syntax-coloured: a cheatsheet is scanned, and +// four token colours per block fight the section rails for attention. +#show raw: set text(font: ("IBM Plex Mono", "DejaVu Sans Mono"), size: 6.3pt, fill: codeink) +#show raw.where(block: true): block.with( + fill: sunk, + inset: (x: 4.5pt, y: 4pt), + radius: 2pt, + width: 100%, +) + +// ---------------------------------------------------------------- pieces +#let chip(fill-color, text-color, label) = box( + fill: fill-color, + inset: (x: 3pt, y: 1.2pt), + radius: 1.5pt, + text(size: 5.2pt, font: "IBM Plex Mono", fill: text-color, weight: 500, label), +) + +#let badge = chip(rgb("#fbeef0"), badgecolor, "de novo") +#let betterbadge = chip(rgb("#e9eff6"), accent, "enhancement") + +#let note(body) = text(size: 6.2pt, fill: inksoft, style: "italic", body) + +// One cheatsheet cell. Panels stretch to their row so a section reads as a +// tiled band rather than a ragged shelf. +#let panel(title, rail, body, kind: none) = block( + fill: panelbg, + stroke: (top: 2pt + rail, rest: 0.4pt + rule), + radius: 2pt, + inset: (x: 7pt, y: 6pt), + width: 100%, + height: 100%, + breakable: false, +)[ + #grid( + columns: (1fr, auto), + align: (left + horizon, right + horizon), + text(size: 8.4pt, weight: 600, tracking: -0.01em, title), + if kind == "new" { badge } else if kind == "better" { betterbadge } else { none }, + ) + #v(1.5pt) + #line(length: 100%, stroke: 0.4pt + rail.lighten(55%)) + #v(4pt) + #body +] + +#let band(label, rail, note-text) = block(width: 100%, above: 0pt, below: 3.5pt)[ + #grid( + columns: (auto, 1fr, auto), + column-gutter: 5pt, + align: (left + horizon, left + horizon, right + horizon), + box(width: 7pt, height: 7pt, radius: 1pt, fill: rail), + text(size: 10.5pt, weight: 700, tracking: 0.04em, upper(label)), + text(size: 6.8pt, fill: inkfaint, note-text), + ) + #v(2pt) + #line(length: 100%, stroke: 0.6pt + rule) +] + +#let shot(path, caption: none, width: 100%) = block(width: 100%)[ + #align(center, image(path, width: width)) + #if caption != none [ #v(2pt) #note(caption) ] +] + +#let newcolor = badgecolor +#let bettercolor = accent + +// An UltraPlot-exclusive thumbnail gets a full outline and a corner badge, not +// just a coloured caption: it is the one thing on the page you cannot get from +// matplotlib at all, so it should be findable at arm's length. +#let exclusive-badge = box( + fill: newcolor, + inset: (x: 2.2pt, y: 0.7pt), + radius: (bottom-left: 1.5pt), + text(size: 4.2pt, font: "IBM Plex Sans", fill: white, weight: 600, tracking: 0.05em, "EXCLUSIVE"), +) + +#let thumb(path, kind) = box( + fill: panelbg, + stroke: if kind == "new" { 1pt + newcolor } else if kind == "better" { (top: 1.6pt + bettercolor, rest: 0.4pt + rule) } else { 0.4pt + rule }, + radius: 1.5pt, + inset: 0pt, + clip: true, + width: 100%, +)[ + #image(path, width: 100%) + #if kind == "new" { place(top + right, exclusive-badge) } +] + +// A command thumbnail. The caption colour says whether matplotlib has the +// command already, can be made to do it, or has nothing like it. +#let icon(entry) = block(width: 100%, breakable: false)[ + #thumb("assets/icons/" + entry.file + ".png", entry.kind) + #v(1.5pt) + #align(center, text( + size: 5.4pt, + font: "IBM Plex Mono", + fill: if entry.kind == "new" { newcolor } else if entry.kind == "better" { bettercolor } else { inksoft }, + entry.name, + )) + #if entry.mpl != none [ + #v(0.8pt) + #align(center, text(size: 4.5pt, fill: inkfaint, style: "italic", entry.mpl)) + ] +] + +#let cells(..items) = grid( + columns: (1fr,) * 4, + rows: (100%,), + gutter: 3.2mm, + ..items, +) + +// A page is bands and panel rows alternating. Panel rows are fractional so the +// page always fills and every panel in a band matches its neighbours' height; +// the weights below say which band needs the most room. +#let sheet(weights: none, ..blocks) = { + let items = blocks.pos() + let rows = () + let index = 0 + let band-index = 0 + for _ in items { + if calc.even(index) { + rows.push(auto) + } else { + let weight = if weights == none { 1.0 } else { weights.at(band-index) } + rows.push(weight * 1fr) + band-index += 1 + } + index += 1 + } + grid(columns: 1, rows: rows, row-gutter: 3.5mm, ..items) +} + +// ---------------------------------------------------------------- masthead +#let masthead(subtitle) = block(width: 100%, below: 5pt)[ + #grid( + columns: (auto, 1fr, auto), + column-gutter: 10mm, + align: (left + bottom, left + bottom, right + bottom), + [ + #text(size: 34pt, weight: 700, tracking: -0.02em, "UltraPlot") + #v(-11pt) + #text(size: 10pt, weight: 600, fill: accent, tracking: 3.6pt, "CHEATSHEET") + ], + text(size: 7.8pt, fill: inksoft, subtitle), + text(size: 7pt, font: "IBM Plex Mono", fill: inkfaint)[ + uplt.subplots() → fig, axs \ + axs.format(…) → everything \ + fig.save(…) → done + ], + ) + #v(4pt) + #rect(width: 100%, height: 3.5pt, stroke: none, radius: 1pt, + fill: gradient.linear(..batlow)) +] + +// ================================================================ PAGE ONE +#masthead[ + Everything assumes `import ultraplot as uplt`. UltraPlot subclasses matplotlib's + `Figure`, `Axes` and `GridSpec`, so every matplotlib call still works — these pages + are what UltraPlot *adds*, and #badge marks what has no matplotlib equivalent. + #linebreak() + Page 1 gets a figure laid out and labelled. Page 2 is what you can draw in it. +] + +#sheet( + weights: (1.00, 0.92, 1.08), + band("Sharing and layout", rails.at(0), "the defaults that change a multi-panel figure before you have formatted anything"), + cells( + panel("Axis sharing is on", rails.at(0), kind: "better")[ + #grid(columns: (1fr, 1fr), gutter: 4pt, + shot("assets/sharing_off.png"), + shot("assets/sharing_on.png"), + ) + #v(1pt) + #grid(columns: (1fr, 1fr), gutter: 4pt, + align(center, note[`share=False`]), + align(center, note[`share=True`, the default]), + ) + #v(4pt) + ``` + uplt.subplots(nrows=2, ncols=2, share=True, span=True) + # True | False | 'labels' | 'limits' | 0 | 1 | 2 | 3 + ``` + #note[Limits, ticks and labels are shared per row and column, and repeated labels collapse into one spanning label.] + ], + panel("Mosaic layouts", rails.at(0), kind: "better")[ + #shot("assets/mosaic.png", width: 88%) + #v(4pt) + ``` + fig, axs = uplt.subplots([[1, 1, 2], + [3, 4, 2]], refwidth=1.8) + + gs = uplt.GridSpec(nrows=2, ncols=2, pad=1) + ax = fig.subplot(gs[:, 0]) + ``` + #note[Draw the layout as an array: `0` leaves a gap, a repeated number spans cells.] + ], + panel("Size in real units", rails.at(0), kind: "new")[ + ``` + refwidth size of the reference subplot + refheight ... its height + refaspect ... its width:height + figwidth total figure width + hratios relative row sizes + wratios relative column sizes + wspace gaps; None = solve it + pad outer padding + ``` + #note[Numbers are inches; strings work too — `'55mm'`, `'2cm'`, `'8em'`, `'120pt'`. Convert by hand with `uplt.units('3cm', 'in')`.] + #v(2pt) + ``` + axs[0]; axs[:, 0]; axs[1, 1:] # SubplotGrid + axs.format(...) # broadcasts + ``` + ], + panel("Panels, insets, twins", rails.at(0))[ + #shot("assets/panels.png", width: 94%) + #v(4pt) + ``` + px = ax.panel_axes('r', width='4em') + ix = ax.inset_axes([.6, .6, .3, .3], zoom=True) + axt = ax.altx(); axr = ax.alty(ylabel='mm') + axd = ax.dualx(lambda x: 1 / x) + ``` + #note[Outer panels take their own gridspec slot, so they never squeeze or distort the subplot.] + ], + ), + + band("format()", rails.at(1), "call it on a figure, an axes or a grid — or pass the same keywords straight into subplots()"), + cells( + grid.cell(colspan: 2, panel("The canonical call", rails.at(1))[ + ``` + axs.format( + suptitle='Model intercomparison', # figure + toplabels=('Control', 'Perturbed'), # column headers + leftlabels=('DJF', 'JJA'), # row headers + abc='a.', abcloc='ul', # panel letters + title='centre', urtitle='corner', # axes titles + xlabel='time (s)', ylabel='signal (mV)', + xlim=(0, 10), ylim=(-1, 1), xscale='log', + xlocator=2, xminorlocator=.5, xformatter='sci', + xtickdir='inout', xtickloc='both', xrotation=45, + grid=True, gridminor=False, facecolor='gray1', + rc_kw={'font.size': 11}, # any rc setting + ) + ``` + #note[Unrecognised keywords are read as rc settings, so `abcloc` sets `abc.loc` and `titlepad` sets `title.pad`.] + ]), + panel("Titles and panel letters", rails.at(1), kind: "new")[ + #shot("assets/titles.png", width: 94%) + #v(4pt) + ``` + abc = True | 'a.' | 'A.' | '(a)' | 'a)' + abcloc = 'ul' # l c r ul uc ur ll lc lr + toplabels leftlabels rightlabels bottomlabels + ``` + #note[The letter is placed for you, in or above the axes, and never over a tick label.] + ], + panel("Ticks", rails.at(1))[ + ``` + ax.format( + xlocator=0.5, # every 0.5 + xlocator=[0, 1, 5], # exactly these + xminorlocator=0.1, + xformatter='sci', # 'deg' 'pi' 'lat' + xformatter='%.1f', + xformatter=['a', 'b'], # literal labels + xbounds=(0, 8), # crop the spine + xtickloc='both', + xtickdir='inout', + ) + + uplt.arange(-3, 3, .5) # endpoint kept + ``` + #note[Locators and formatters are built from plain values — no importing `mticker`. `uplt.arange` keeps its endpoint, which is what level and tick lists want.] + ], + ), + + band("Colorbars and legends", rails.at(2), "outer guides take their own gridspec slot — they never steal space from the subplot"), + cells( + panel("Where guides go", rails.at(2))[ + #shot("assets/guides.png", width: 96%) + #note[Outer sides `'l' 'r' 't' 'b'`; inset corners `'ul' 'ur' 'll' 'lr'`, plus `'uc'` and `'lc'`. Several guides on one side queue up.] + ], + panel("Building them", rails.at(2))[ + ``` + ax.pcolormesh(data, cmap='batlow', colorbar='r', + colorbar_kw={'label': 'K'}) + ax.plot(Y, labels=['a', 'b'], legend='b', + legend_kw={'ncols': 3, 'frame': False}) + + fig.colorbar(m, loc='b', col=1, length=.7) + fig.legend(hs, loc='r', rows=(1, 2)) + ax.colorbar(lines, values=[1, 2, 3]) + ax.colorbar('Blues', values=range(10)) + ``` + #note[Legends find their own handles, and restyle in place through `lw=`, `color=`, `markersize=`. Width and length are physical units, not fractions of the axes.] + ], + grid.cell(colspan: 2, panel("Semantic legends", rails.at(2), kind: "new")[ + #grid(columns: (1.05fr, 1fr), gutter: 6pt, + shot("assets/semantic.png"), + [ + ``` + ax.catlegend(names, colors={...}, + markers={...}) + ax.sizelegend([10, 50, 200], + labels=['S', 'M', 'L']) + ax.numlegend(levels=[0, .25, .5, .75, 1], + cmap='viko', fmt='{:.2f}') + ax.entrylegend([{...}, {...}]) + ax.geolegend([...]) + ``` + #note[These describe an *encoding*, so nothing invisible has to be plotted first just to make a handle. All exist on `fig` too, and `add=False` returns `(handles, labels)` for composing your own.] + ], + ) + ]), + ), +) + +#pagebreak() + +// ================================================================ PAGE TWO +#masthead[ + What you can draw, and the colour you draw it in. Every thumbnail below is the + output of the command it names, rendered by the scripts in `parts/` — none of it + is a mock-up. The caption colour says how far it is from matplotlib. +] + +#sheet( + weights: (0.90, 1.00, 1.10), + band("Plot types", rails.at(0), "one picture per command — grey: matplotlib has it · blue: matplotlib can, by hand · red: no equivalent"), + block( + fill: panelbg, + stroke: (top: 2pt + rails.at(0), rest: 0.4pt + rule), + radius: 2pt, + inset: (x: 8pt, y: 7pt), + width: 100%, + height: 100%, + )[ + #grid( + columns: (1fr,) * 15, + column-gutter: 2.6mm, + row-gutter: 3mm, + align: center + top, + ..commands.filter(entry => entry.featured).map(icon), + ) + #v(5pt) + #grid(columns: (1fr, 1fr), gutter: 8mm, + note[Every `x`-oriented 1D command has a `…x` sibling — `plotx`, `scatterx`, `areax` — that swaps the axes properly instead of transposing by hand. Feed any of them pandas or xarray objects and the labels, coordinates and units come along.], + note[The polar family (`chord_diagram`, `radar_chart`, `phylogeny`, `circos_bed`) wants `proj='polar'`, and `taylor` is its own projection. These thirty span the kinds of plot; all fifty-six, the keyword variants and the swapped-axis siblings are on the companion poster, `ultraplot_plot_types.pdf`.], + ) + ], + + band("Fields, distributions, colour", rails.at(1), "levels and norms, the statistics UltraPlot computes for you, and the maps it ships with"), + cells( + panel("Discrete levels by default", rails.at(1), kind: "better")[ + #shot("assets/norms.png") + #v(3pt) + ``` + ax.pcolormesh(x, y, z, cmap='roma', + levels=11, # count or edges + values=uplt.arange(-4, 4), # level centres + extend='both', discrete=True, + norm='div', labels=True) + ``` + #note[`values=` pins a diverging midpoint to the real zero. `labels=True` writes the value into every cell or contour, in a colour that stays legible on the fill.] + ], + panel("Statistics from raw samples", rails.at(1), kind: "new")[ + #shot("assets/statistics.png") + #v(3pt) + ``` + ax.plot(x, runs, mean=True, shadestd=1, + fadepctile=(5, 95)) + ax.bar(x, runs, median=True, bars=True) + ``` + #note[Hand the command the raw samples, one column per `x`, then pick the reduction (`mean`, `median`) and the indicator. Each takes a `…std`, `…pctile` or explicit `…data` form.] + ], + panel("Cycles", rails.at(2))[ + #shot("assets/cycles.png", width: 96%) + #v(3pt) + ``` + uplt.rc.cycle = 'colorblind' + ax.plot(Y, cycle='538') + ax.plot(Y, cycle='Blues', cycle_kw={'left': .2}) + uplt.Cycle(lw=3, dashes=[(1, .5), (3, 1.5)]) + ``` + #note[Hand a 2D array to a 1D command and every column takes the next colour.] + ], + panel("Build and check a colormap", rails.at(2), kind: "new")[ + ``` + uplt.Colormap('prussian blue', l=100, space='hpl') + uplt.Colormap(['blue', 'white', 'red']) + uplt.Colormap(h=(0, 360), c=50, l=70, + space='hcl', cyclic=True) + uplt.Colormap('Blues4_r', 'Reds3', ratios=(1, 3)) + + # cmap_kw: left right cut shift alpha gamma + # suffixes: _r reverse, _s shift + ``` + #v(2pt) + #shot("assets/luminance.png", width: 80%) + #note[A sound sequential map ramps luminance monotonically. `jet` does not.] + ], + ), + + band("Bundled colormaps", rails.at(2), "registered on import — uplt.show_cmaps() prints the full set"), + cells( + grid.cell(colspan: 2, panel("UltraPlot, cmOcean, Crameri", rails.at(2))[ + #grid(columns: (1fr, 1fr), gutter: 7pt, + [ + #text(size: 6pt, weight: 600, fill: inksoft, "UltraPlot") + #v(1pt) + #shot("assets/cmaps_uplt.png") + #v(4pt) + #text(size: 6pt, weight: 600, fill: inksoft, "cmOcean") + #v(1pt) + #shot("assets/cmaps_cmocean.png") + ], + [ + #text(size: 6pt, weight: 600, fill: inksoft, "Scientific colour maps (Crameri)") + #v(1pt) + #shot("assets/cmaps_scientific.png") + ], + ) + ]), + panel("Maps", rails.at(3))[ + #shot("assets/geo_projections.png") + #v(2pt) + ``` + fig, axs = uplt.subplots( + proj=('robin', 'ortho', 'npstere'), ncols=3) + ax.pcolormesh(lon, lat, data, cmap='roma') + ``` + #note[Short names cover the usual set: `cyl moll hammer eqearth laea lcc geos npstere aeqd`. Projection arguments go through `proj_kw`; cartopy is the default backend.] + ], + panel("Features, rc, output", rails.at(3))[ + #shot("assets/geo_features.png", width: 68%) + #v(2pt) + ``` + ax.format(land=True, ocean=True, coast=True, + borders=True, rivers=True, + lonlim=(-15, 40), latlim=(33, 62), + lonlabels='b', latlabels='l') + ``` + #v(1pt) + #v(2pt) + ``` + uplt.rc.update({'fontsize': 11, 'tickdir': 'in'}) + ani = uplt.FuncAnimation(fig, update, 100) + ani.save('waves.mp4') # blit=True + ``` + ], + ), +) + +#pagebreak() + +// ============================================================== PAGE THREE +// The gallery is generated from assets/features.typ, which parts/features.py +// writes, so the classification lives with the drawing code. + +#let newcolor = badgecolor +#let bettercolor = accent + +#let feat(entry) = block(width: 100%, breakable: false)[ + #thumb("assets/features/" + entry.name + ".png", entry.kind) + #v(1.8pt) + #align(center, text( + size: 5.4pt, + font: "IBM Plex Mono", + fill: if entry.kind == "new" { newcolor } else { bettercolor }, + entry.label, + )) + #if entry.mpl != none [ + #v(0.8pt) + #align(center, text(size: 4.6pt, fill: inkfaint, style: "italic", entry.mpl)) + ] +] + +#let gallery(rail, group) = { + let items = features.filter(entry => entry.group == group) + block( + fill: panelbg, + stroke: (top: 2pt + rail, rest: 0.4pt + rule), + radius: 2pt, + inset: (x: 8pt, y: 7pt), + width: 100%, + height: 100%, + )[ + #grid( + columns: (1fr,) * items.len(), + column-gutter: 3mm, + align: center + top, + ..items.map(feat), + ) + ] +} + +#let kindkey = [ + #box(width: 6pt, height: 2pt, fill: newcolor, baseline: -1pt) + #h(1.5pt) #text(fill: inksoft)[de novo: matplotlib has no equivalent] + #h(7pt) + #box(width: 6pt, height: 2pt, fill: bettercolor, baseline: -1pt) + #h(1.5pt) #text(fill: inksoft)[enhancement: matplotlib can, but you assemble it — its counterpart is named underneath] +] + +#masthead[ + What UltraPlot adds, one picture each, drawn by the feature it shows. Two kinds, + and the difference matters: a handful of these have no matplotlib equivalent at + all, but most are things matplotlib *can* do and UltraPlot does for you. + #linebreak() + #kindkey +] + +#sheet( + weights: (1.00, 0.86, 0.86, 1.28), + + band("Figures and subplots", rails.at(0), "the layout engine, and the labels that come with it"), + gallery(rails.at(0), "layout"), + + band("Axes and guides", rails.at(1), "extra axes, and guides that take their own gridspec slot"), + grid(columns: (5fr, 5fr), column-gutter: 3.2mm, + gallery(rails.at(1), "axes"), + gallery(rails.at(1), "guides"), + ), + + band("Colour and data", rails.at(2), "the colour engine, and what UltraPlot reads off your data"), + grid(columns: (6fr, 6fr), column-gutter: 3.2mm, + gallery(rails.at(2), "color"), + gallery(rails.at(2), "data"), + ), + + band("Additions without a picture", rails.at(3), "the rest, where a thumbnail would say nothing"), + cells( + panel("Constructor functions", rails.at(3), kind: "better")[ + ``` + uplt.Colormap uplt.Cycle uplt.Norm + uplt.Locator uplt.Formatter + uplt.Scale uplt.Proj + ``` + #note[Every `cmap=`, `cycle=`, `norm=`, `locator=`, `formatter=`, `scale=` and `proj=` argument is passed through the matching constructor, so a string, a number or a list works anywhere matplotlib would want a class instance.] + ], + panel("Registries and loading", rails.at(3), kind: "new")[ + ``` + uplt.register_cmaps(user=True) + uplt.register_cycles() uplt.register_colors() + uplt.register_fonts() + uplt.show_cmaps() uplt.show_cycles() + uplt.show_colors() uplt.show_fonts() + uplt.show_channels('fire') + ``` + #note[Drop files in the config folder and they are registered on import; the `show_` commands print what is available, including the perceptual channels of a map.] + ], + panel("Units and helpers", rails.at(3), kind: "new")[ + ``` + uplt.units('5cm', 'in') + uplt.arange(-3, 3, .5) # endpoint kept + uplt.edges(centres) # centres → edges + uplt.edges2d(grid) + uplt.to_xyz(color, space='hcl') + uplt.set_alpha scale_luminance + shift_hue scale_saturation + ``` + #note[Sizes, spaces, widths and font sizes accept `'55mm'`, `'2cm'`, `'8em'`, `'120pt'` wherever a number would do.] + ], + panel("Figure-level plumbing", rails.at(3), kind: "better")[ + ``` + fig.save('~/figure.pdf') # ~ expanded + uplt.config_inline_backend() + uplt.rc.context({...}) + ax.format(style='ggplot') # per axes + ExternalAxesContainer # host a + # third-party axes + ``` + #note[Tight layout runs before every draw and save, so what you see is what lands in the file, and journal-ready defaults are already set.] + ], + ), +) diff --git a/tools/cheatsheet/docs_index.py b/tools/cheatsheet/docs_index.py new file mode 100644 index 000000000..a2a16de1e --- /dev/null +++ b/tools/cheatsheet/docs_index.py @@ -0,0 +1,268 @@ +#!/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 and the poster all 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") + have = len([f for f in os.listdir(source)]) if os.path.isdir(source) else 0 + if have >= len(ICONS): + return + print(f" rendering {len(ICONS)} icons (found {have})") + 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.endswith(".png"): + 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/parts/color.py b/tools/cheatsheet/parts/color.py new file mode 100644 index 000000000..7fd2d134c --- /dev/null +++ b/tools/cheatsheet/parts/color.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Color figures: the bundled colormaps, the property cycles, and the perceptual +check that tells you whether a map is safe to use. + +The colormap tables come from ``ultraplot.demos.CMAP_TABLE``, the same source +``uplt.show_cmaps()`` draws from, so the sheet cannot drift from what is +actually registered. +""" + +from __future__ import annotations + +import numpy as np + +import ultraplot as uplt +from ultraplot.demos import CMAP_TABLE, CYCLE_TABLE + +import os + +from common import ACCENT, ASSETS, INK, INK_FAINT, save, use_style + +GRADIENT = np.linspace(0, 1, 512)[None, :] + +#: Families to print, and the label to print them under. Everything registered +#: is listed in CMAP_TABLE; these are the families worth a swatch on one page. +FAMILIES = { + "uplt": ("UltraPlot", ["UltraPlot sequential", "UltraPlot diverging"]), + "scientific": ( + "Scientific colour maps (Crameri)", + [ + "Scientific colour maps sequential", + "Scientific colour maps diverging", + "Scientific colour maps cyclic", + ], + ), + "cmocean": ( + "cmOcean", + ["cmOcean sequential", "cmOcean diverging", "cmOcean cyclic"], + ), + "brewer": ( + "ColorBrewer 2.0", + ["ColorBrewer2.0 sequential", "ColorBrewer2.0 diverging"], + ), + "other": ( + "Matplotlib, seaborn, SciVisColor", + [ + "Matplotlib sequential", + "Matplotlib cyclic", + "Seaborn sequential", + "Seaborn diverging", + "Other sequential", + "Other diverging", + "Grayscale", + ], + ), +} + + +def _swatches(names, path, *, ncols=2, labelwidth=0.34, rowmm=3.3): + """ + Draw a table of colormap swatches with their names. + """ + nrows = int(np.ceil(len(names) / ncols)) + pitch = 1 / nrows + fig = uplt.figure(figwidth="86mm", figheight=f"{nrows * rowmm:.1f}mm") + ax = fig.subplot() + ax.format(xticks=[], yticks=[], grid=False, linewidth=0, xlim=(0, 1), ylim=(0, 1)) + ax.patch.set_visible(False) + for index, name in enumerate(names): + column, row = divmod(index, nrows) + left = column / ncols + labelwidth / ncols + width = (1 / ncols) * (1 - labelwidth) * 0.93 + bottom = 1 - (row + 0.85) * pitch + bar = ax.inset_axes( + [left, bottom, width, pitch * 0.66], + transform=ax.transAxes, + zoom=False, + ) + bar = bar[0] if hasattr(bar, "__len__") else bar + bar.imshow(GRADIENT, aspect="auto", cmap=name) + bar.format(xticks=[], yticks=[], grid=False, linewidth=0.3) + ax.text( + left - 0.012, + bottom + pitch * 0.33, + name, + transform=ax.transAxes, + ha="right", + va="center", + fontsize=5.4, + family="monospace", + color=INK, + ) + save(fig, path) + + +def colormaps(): + """ + One swatch table per bundled family. + """ + for key, (_, categories) in FAMILIES.items(): + names = [] + for category in categories: + names.extend(CMAP_TABLE[category]) + # Three columns keeps even the big families to a short block. + ncols = 2 if len(names) <= 12 else 3 + _swatches(names, f"cmaps_{key}.png", ncols=ncols) + + +def cycles(): + """ + The registered property cycles, as their actual colors. + """ + names = [ + name + for category in ( + "Matplotlib stylesheets", + "Other qualitative", + "ColorBrewer2.0 qualitative", + ) + for name in CYCLE_TABLE[category] + ][:11] + fig = uplt.figure(figwidth="86mm", figheight=f"{len(names) * 3.4:.0f}mm") + ax = fig.subplot() + ax.format( + xticks=[], + yticks=[], + grid=False, + linewidth=0, + xlim=(0, 12), + ylim=(0, len(names)), + ) + ax.patch.set_visible(False) + for row, name in enumerate(names): + colors = uplt.get_colors(name) + y = len(names) - row - 1 + for index, color in enumerate(colors[:12]): + ax.bar(index + 0.5, 0.62, bottom=y + 0.2, width=0.92, color=color, lw=0) + ax.text( + -0.35, + y + 0.5, + name, + ha="right", + va="center", + fontsize=5.4, + family="monospace", + color=INK, + ) + save(fig, "cycles.png") + + +def luminance(): + """ + Why perceptual uniformity is checkable: luminance against position. + """ + fig, ax = uplt.subplots(figwidth="58mm", figheight="34mm") + position = np.linspace(0, 1, 128) + for name, color, dash in ( + ("batlow", ACCENT, "-"), + ("fire", "#b6394f", "-"), + ("viridis", "#3c6d56", "-"), + ("jet", INK_FAINT, "--"), + ): + cmap = uplt.Colormap(name) + lum = [uplt.to_xyz(cmap(value), space="hcl")[2] for value in position] + ax.plot(position, lum, color=color, lw=1.2, ls=dash, label=name) + ax.format( + xlim=(0, 1), + ylim=(0, 105), + xticks=[], + yticks=[0, 50, 100], + ylabel="luminance", + labelsize=6, + ticklabelsize=5.5, + grid=True, + ) + ax.legend(loc="lr", ncols=1, frame=False, fontsize=5.6, handlelength=1.3) + save(fig, "luminance.png") + + +def norms(): + """ + The same field under a continuous norm, discrete levels, and a pinned + diverging centre. + """ + state = np.random.default_rng(4) + y, x = np.mgrid[0:40, 0:40] + field = np.sin(x / 6) * np.cos(y / 7) * 4 + state.normal(0, 0.4, (40, 40)) + fig, axs = uplt.subplots(ncols=3, figwidth="86mm", figheight="30mm", wspace="2mm") + axs[0].pcolormesh(field, cmap="roma", discrete=False) + axs[1].pcolormesh(field, cmap="roma", levels=9) + axs[2].pcolormesh(field, cmap="roma", values=uplt.arange(-4, 4, 1), extend="both") + for ax, label in zip(axs, ("discrete=False", "levels=9", "values=arange(-4, 4)")): + ax.format( + xticks=[], + yticks=[], + grid=False, + title=label, + titlesize=5.4, + titleloc="l", + titlepad=1.5, + ) + save(fig, "norms.png") + + +def palette(): + """ + Emit the page palette as Typst data. + + The rails on the page and the swatches in the figures are the same batlow + samples, and writing them from here is what keeps them that way. + """ + from matplotlib.colors import to_hex + + cmap = uplt.Colormap("batlow") + stops = [to_hex(cmap(value)) for value in np.linspace(0, 1, 16)] + rails = [to_hex(cmap(value)) for value in (0.0, 0.22, 0.42, 0.66, 0.88)] + path = os.path.join(ASSETS, "palette.typ") + os.makedirs(ASSETS, exist_ok=True) + with open(path, "w") as handle: + handle.write("// Generated by parts/color.py — do not edit.\n") + handle.write("#let batlow = (\n") + for stop in stops: + handle.write(f' rgb("{stop}"),\n') + handle.write(")\n\n#let rails = (\n") + for rail in rails: + handle.write(f' rgb("{rail}"),\n') + handle.write(")\n") + print(" assets/palette.typ") + + +def main(): + use_style() + palette() + colormaps() + cycles() + luminance() + norms() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/common.py b/tools/cheatsheet/parts/common.py new file mode 100644 index 000000000..2554b1523 --- /dev/null +++ b/tools/cheatsheet/parts/common.py @@ -0,0 +1,200 @@ +#!/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 Typst document is what assembles them, so nothing here knows about page +layout — only about drawing one small, self-contained figure well. +""" + +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" +) + +#: Section rails, sampled along ``batlow`` so the sheet is colored by the thing +#: it documents. Kept in step with the palette in ``cheatsheet.typ``. +RAILS = ["#011959", "#144d62", "#3c6d56", "#828231", "#b0455a"] + +INK = "#101720" +INK_SOFT = "#47535f" +INK_FAINT = "#7e8c99" +PANEL = "#ffffff" +SUNK = "#eef1f5" +RULE = "#c9d2dc" +ACCENT = "#3b638c" + +#: Assets are rendered at this resolution. Typst 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) + 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 = 1.7 +ICON_MS = 11.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/features.py b/tools/cheatsheet/parts/features.py new file mode 100644 index 000000000..ec8d9b9a5 --- /dev/null +++ b/tools/cheatsheet/parts/features.py @@ -0,0 +1,818 @@ +#!/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 list follows the sections of ``docs/why.rst``, +so it stays tied to the project's own account of what it is for. + +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 + +#: Icons are square and rendered large; Typst scales them into the page. +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=1.4) + ax.format( + title="title", + xlabel="x label", + ylabel="y label", + abc="a.", + abcloc="ul", + titlesize=6, + labelsize=6, + abcsize=6, + ticklabelsize=5, + xlocator=5, + ylocator=1, + grid=True, + ) + + +def feature_sharing(fig, axs): + """Ticks and labels appear once per row and column, not per panel.""" + for index, ax in enumerate(axs): + ax.plot(X, np.sin(X + index), lw=1) + axs.format( + xlabel="x", + ylabel="y", + labelsize=6, + ticklabelsize=4.5, + xlocator=5, + ylocator=1, + grid=False, + ) + + +def feature_spanning(fig, axs): + """One label spans the panels it describes.""" + for ax in axs: + ax.plot(X, np.sin(X), lw=1) + axs.format( + xlabel="one spanning label", + ylabel="y", + labelsize=5.5, + ticklabelsize=4.5, + xlocator=5, + ylocator=1, + 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=("col", "col"), + leftlabels=("row", "row"), + toplabelsize=5.5, + leftlabelsize=5.5, + ) + + +def feature_abc(fig, axs): + """Panel letters are placed for you, in any of nine slots.""" + for index, ax in enumerate(axs): + bare(ax, facecolor=SUNK, edgecolor=RULE) + ax.format(abc="a.", abcloc=("ul", "ur", "ll", "lr")[index], abcsize=7) + + +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=5.5, + ) + _mark(ax, "…title", size=6) + + +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=7, + color=INK_FAINT, + 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.63) + _mark(ax, "refwidth", y=0.28, size=5.5, color=INK_FAINT) + + +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, + "axs[:, 1]", + transform=axs[1].transAxes, + rotation=90, + ha="center", + va="center", + fontsize=5.5, + color="w", + family="monospace", + ) + + +# --------------------------------------------------------------- axes + + +def feature_panels(fig, axs): + """Marginal panels take their own gridspec slot.""" + ax = axs[0] + data = RNG.normal(size=(400, 2)) + ax.scatter(data[:, 0], data[:, 1], s=2, alpha=0.5, 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=16, + color=ACCENT, + alpha=0.6, + lw=0, + ) + bare(panel) + bare(ax) + + +def feature_inset(fig, axs): + """Insets can draw their own zoom indicator.""" + ax = axs[0] + ax.plot(X, np.sin(X) + RNG.normal(0, 0.05, X.size), lw=1, color=ACCENT) + inset = ax.inset_axes([0.52, 0.06, 0.44, 0.42], zoom=True) + inset.plot(X, np.sin(X) + RNG.normal(0, 0.05, X.size), lw=1, color=ACCENT) + inset.format(xlim=(2, 4), ylim=(0.2, 1.1)) + bare(inset) + bare(ax) + + +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=1.2, color=ACCENT) + dual = ax.dualx(lambda value: value * 2.54) + ax.format(xlabel="in", labelsize=5.5, ticklabelsize=4.5, xlocator=5, grid=False) + dual.format(xlabel="cm", labelsize=5.5, ticklabelsize=4.5, xlocator=10) + ax.format(yticks=[]) + + +def feature_projections(fig, axs): + """Projections by short name, with cartographic features built in.""" + axs[0].format( + land=True, + ocean=True, + coast=True, + landcolor="gray3", + oceancolor=ACCENT, + coastlinewidth=0.3, + grid=True, + gridalpha=0.35, + labels=False, + ) + + +def feature_taylor(fig, axs): + """Projections that are whole diagram types.""" + ax = axs[0] + 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=9, color="red7") + for (corr, std), color in zip( + ((0.95, 1.15), (0.8, 0.75)), + ("denim", "green7"), + ): + ax.scatter_corr(corr, std, s=24, color=color, zorder=6) + + +# --------------------------------------------------------------- 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="3.4mm", length=0.78, ticks=[], frame=True) + bare(ax) + + +def feature_semantic_legend(fig, axs): + """Legends that describe an encoding, with no artist to point at.""" + ax = axs[0] + ax.sizelegend( + [12, 60, 150], + labels=["S", "M", "L"], + loc="c", + ncols=1, + frame=False, + fontsize=6, + markercolor=ACCENT, + ) + bare(ax) + + +def feature_on_the_fly(fig, axs): + """A plotting command can build its own guide.""" + ax = axs[0] + lines = ax.plot( + X, + np.column_stack([np.sin(X), np.cos(X), np.sin(X / 2)]), + lw=1.2, + labels=["a", "b", "c"], + cycle="colorblind", + ) + ax.legend(lines, loc="b", ncols=3, frame=False, fontsize=5.5) + bare(ax) + + +# --------------------------------------------------------------- color + + +def feature_discrete_norm(fig, axs): + """Levels are discrete by default, so a colorbar reads as steps.""" + ax = axs[0] + mesh = ax.pcolormesh(_field(), cmap="batlow", levels=7) + ax.colorbar(mesh, loc="b", width="3mm", ticks=[], length=0.95) + bare(ax) + + +def feature_centred_levels(fig, axs): + """`values=` pins a diverging midpoint to the real zero.""" + ax = axs[0] + mesh = ax.pcolormesh( + _field() * 4, + cmap="BuRd", + values=uplt.arange(-4, 4, 1), + extend="both", + ) + ax.colorbar(mesh, loc="b", width="3mm", ticks=[0], length=0.95, ticklabelsize=5) + bare(ax) + + +def feature_colormap_surgery(fig, axs): + """Existing colormaps can be truncated, shifted and merged.""" + ax = axs[0] + gradient = np.linspace(0, 1, 256)[None, :] + recipes = ( + ("roma", {}), + ("roma", {"left": 0.35}), + ("roma", {"shift": 90}), + ("roma", {"cut": 0.35}), + ) + for index, (name, kwargs) in enumerate(recipes): + bar = ax.inset_axes( + [0.06, 0.80 - index * 0.24, 0.88, 0.16], + transform=ax.transAxes, + zoom=False, + ) + bar = bar[0] if hasattr(bar, "__len__") else bar + bar.imshow(gradient, aspect="auto", cmap=uplt.Colormap(name, **kwargs)) + bare(bar) + bare(ax, linewidth=0) + + +def feature_perceptual(fig, axs): + """Colormaps can be built from perceptual channel values.""" + ax = axs[0] + gradient = np.linspace(0, 1, 256)[None, :] + recipes = ( + {"h": (0, 120), "s": 80, "l": (20, 90), "space": "hpl"}, + {"h": (200, 320), "s": 60, "l": (25, 95), "space": "hpl"}, + {"h": (0, 360), "c": 50, "l": 70, "space": "hcl", "cyclic": True}, + ) + for index, kwargs in enumerate(recipes): + bar = ax.inset_axes( + [0.06, 0.72 - index * 0.30, 0.88, 0.20], + transform=ax.transAxes, + zoom=False, + ) + bar = bar[0] if hasattr(bar, "__len__") else bar + bar.imshow(gradient, aspect="auto", cmap=uplt.Colormap(**kwargs)) + bare(bar) + bare(ax, linewidth=0) + + +def feature_cycle_from_cmap(fig, axs): + """Any colormap can become a property cycle.""" + ax = axs[0] + values = np.column_stack([np.sin(X + shift / 2) for shift in range(6)]) + ax.plot(X, values, lw=1.3, cycle="Blues", cycle_kw={"left": 0.25}) + bare(ax) + + +def feature_named_colors(fig, axs): + """A registry of named colors from xkcd and open-color.""" + ax = axs[0] + names = ( + "denim", + "rose", + "ocean blue", + "sky blue", + "kelly green", + "orange7", + "violet7", + "gray6", + "red7", + ) + for index, name in enumerate(names): + row, column = divmod(index, 3) + swatch = ax.inset_axes( + [0.08 + column * 0.30, 0.66 - row * 0.28, 0.24, 0.20], + transform=ax.transAxes, + zoom=False, + ) + swatch = swatch[0] if hasattr(swatch, "__len__") else swatch + bare(swatch, facecolor=name, linewidth=0) + bare(ax, linewidth=0) + + +# --------------------------------------------------------------- data + + +def feature_statistics(fig, axs): + """Reductions and spread indicators computed from raw samples.""" + ax = axs[0] + runs = np.sin(X)[None, :] + RNG.normal(0, 0.3, (60, X.size)) + ax.plot(X, runs, mean=True, shadestd=1, fadepctile=(10, 90), lw=1.5) + bare(ax) + + +def feature_dataframe(fig, axs): + """Labels, coordinates and units are read off pandas and xarray.""" + import pandas as pd + + ax = axs[0] + frame = pd.DataFrame( + {"signal (mV)": np.sin(X) + RNG.normal(0, 0.05, X.size)}, + index=pd.Index(X, name="time (s)"), + ) + ax.plot(frame, lw=1.3, color=ACCENT) + ax.format(labelsize=5.5, ticklabelsize=4.5, xlocator=5, ylocator=1, grid=False) + + +def feature_labels(fig, axs): + """Cell and contour labels in a colour that stays legible.""" + axs[0].heatmap( + RNG.uniform(-1, 1, (3, 3)).round(1), + cmap="BuRd", + vmin=-1, + vmax=1, + labels=True, + labels_kw={"fontsize": 5.5}, + ) + bare(axs[0]) + + +# --------------------------------------------------------------- output + + +def feature_rc_context(fig, axs): + """Settings cascade, and apply inside a context.""" + ax = axs[0] + _mark(ax, "uplt.rc", y=0.70, size=6.5, color=INK) + _mark(ax, "fontsize", y=0.47, size=5.5, color=INK_FAINT) + _mark(ax, "tickdir", y=0.30, size=5.5, color=INK_FAINT) + _mark(ax, "cycle", y=0.13, size=5.5, color=INK_FAINT) + bare(ax, facecolor=SUNK, edgecolor=RULE) + + +def feature_animation(fig, axs): + """A faster writer behind matplotlib's animation API.""" + ax = axs[0] + for index, alpha in enumerate((0.2, 0.45, 1.0)): + ax.plot(X, np.sin(X + index * 0.6), lw=1.5, color=ACCENT, alpha=alpha) + bare(ax) + + +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=6.5, 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 = { + # ----------------------------------------------------------- layout + "format": { + "draw": feature_format, + "subplots": {}, + "group": "layout", + "label": "format()", + "kind": BETTER, + "mpl": "set_title, set_xlabel, set_xlim, tick_params, …", + }, + "sharing": { + "draw": feature_sharing, + "subplots": {"nrows": 2, "ncols": 2, "share": True}, + "group": "layout", + "label": "share=True", + "kind": BETTER, + "mpl": "sharex=, sharey= — without the label collapsing", + }, + "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()", + }, + # ------------------------------------------------------------- axes + "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", + }, + "projections": { + "draw": feature_projections, + "subplots": {"proj": "ortho"}, + "group": "axes", + "label": "proj='ortho'", + "kind": BETTER, + "mpl": "cartopy GeoAxes, wired up by hand", + }, + "taylor_axes": { + "draw": feature_taylor, + "subplots": {"proj": "taylor"}, + "group": "axes", + "label": "proj='taylor'", + "kind": NEW, + "mpl": None, + }, + # ----------------------------------------------------------- guides + "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(...))", + }, + "guides_on_the_fly": { + "draw": feature_on_the_fly, + "subplots": {}, + "group": "guides", + "label": "legend='b'", + "kind": NEW, + "mpl": None, + }, + "semantic_legends": { + "draw": feature_semantic_legend, + "subplots": {}, + "group": "guides", + "label": "sizelegend()", + "kind": NEW, + "mpl": None, + }, + # ------------------------------------------------------------ color + "discrete_levels": { + "draw": feature_discrete_norm, + "subplots": {}, + "group": "color", + "label": "levels=7", + "kind": BETTER, + "mpl": "BoundaryNorm, constructed by hand", + }, + "centred_levels": { + "draw": feature_centred_levels, + "subplots": {}, + "group": "color", + "label": "values=arange()", + "kind": BETTER, + "mpl": "TwoSlopeNorm, CenteredNorm", + }, + "colormap_surgery": { + "draw": feature_colormap_surgery, + "subplots": {}, + "group": "color", + "label": "cmap_kw={...}", + "kind": BETTER, + "mpl": "resampled() truncates; no cut or shift", + }, + "perceptual_colormaps": { + "draw": feature_perceptual, + "subplots": {}, + "group": "color", + "label": "Colormap(h=, s=, l=)", + "kind": NEW, + "mpl": None, + }, + "cycle_from_cmap": { + "draw": feature_cycle_from_cmap, + "subplots": {}, + "group": "color", + "label": "cycle='Blues'", + "kind": BETTER, + "mpl": "cycler(color=cmap(...)) by hand", + }, + "named_colors": { + "draw": feature_named_colors, + "subplots": {}, + "group": "color", + "label": "'denim' 'orange7'", + "kind": BETTER, + "mpl": "xkcd: and CSS4 names, prefixed", + }, + # ------------------------------------------------------------- data + "statistics": { + "draw": feature_statistics, + "subplots": {}, + "group": "data", + "label": "mean=True", + "kind": NEW, + "mpl": None, + }, + "pandas_xarray": { + "draw": feature_dataframe, + "subplots": {}, + "group": "data", + "label": "pandas / xarray", + "kind": NEW, + "mpl": None, + }, + "auto_labels": { + "draw": feature_labels, + "subplots": {}, + "group": "data", + "label": "labels=True", + "kind": BETTER, + "mpl": "clabel, for contours only", + }, + "rc_settings": { + "draw": feature_rc_context, + "subplots": {}, + "group": "data", + "label": "uplt.rc", + "kind": BETTER, + "mpl": "rcParams, one setting at a time", + }, + "fast_animation": { + "draw": feature_animation, + "subplots": {}, + "group": "data", + "label": "FuncAnimation", + "kind": BETTER, + "mpl": "same API, slower writer", + }, + "curved_text": { + "draw": feature_curvedtext, + "subplots": {}, + "group": "data", + "label": "curvedtext()", + "kind": NEW, + "mpl": None, + }, +} + + +def write_manifest(): + """ + Emit the registry as Typst data. + + The page builds its galleries from this, so the classification lives in one + place and a new icon reaches the sheet by being added here. + """ + import os + + from common import ASSETS + + path = os.path.join(ASSETS, "features.typ") + with open(path, "w") as handle: + handle.write("// Generated by parts/features.py — do not edit.\n") + handle.write("#let features = (\n") + for name, spec in FEATURES.items(): + mpl = spec["mpl"] + mpl = f'"{mpl}"' if mpl else "none" + handle.write( + f' (name: "{name}", label: "{spec["label"]}", ' + f'kind: "{spec["kind"]}", mpl: {mpl}, ' + f'group: "{spec["group"]}"),\n' + ) + handle.write(")\n") + print(" assets/features.typ") + + +def main(): + use_style(fontsize=5) + 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="1mm", + wspace="1mm", + **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}.png", dpi=220) + write_manifest() + if failures: + print("feature icon failures:") + for failure in failures: + print(f" {failure}") + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/geo.py b/tools/cheatsheet/parts/geo.py new file mode 100644 index 000000000..febbb5d92 --- /dev/null +++ b/tools/cheatsheet/parts/geo.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Map figures: a few projections, and what ``format`` puts on them. + +Needs cartopy. If it is missing the build skips this part rather than failing, +and the page falls back to the code column alone. +""" + +from __future__ import annotations + +import numpy as np + +import ultraplot as uplt + +from common import save, use_style + +#: Projection short names, in the order they appear on the page. +PROJECTIONS = ("robin", "ortho", "npstere", "hammer", "eqearth", "lcc") + + +def _field(): + """ + A smooth global field to drape over the projections. + """ + lon = np.linspace(-180, 180, 145) + lat = np.linspace(-90, 90, 73) + 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 projections(): + """ + One panel per projection, each labelled with the string that makes it. + """ + lon, lat, data = _field() + fig, axs = uplt.subplots( + proj=PROJECTIONS, + ncols=3, + nrows=2, + figwidth="120mm", + figheight="52mm", + wspace="3mm", + hspace="5mm", + ) + for ax, name in zip(axs, PROJECTIONS): + mesh = ax.pcolormesh(lon, lat, data, cmap="roma", levels=11, extend="both") + ax.format( + coast=True, + coastlinewidth=0.3, + title=f"proj='{name}'", + titlesize=5.6, + titlepad=1.5, + grid=True, + gridalpha=0.25, + labels=False, + ) + fig.colorbar( + mesh, + loc="b", + length=0.5, + width="2.5mm", + label="anomaly", + labelsize=5.6, + ticklabelsize=5, + ) + save(fig, "geo_projections.png") + + +def features(): + """ + The cartographic features ``format`` can switch on, and gridline labels. + """ + # Height is left to the layout solver: pinning both dimensions clips the + # gridline labels, which have nowhere to go. + fig, ax = uplt.subplots(proj="cyl", refwidth="72mm") + ax.format( + land=True, + ocean=True, + coast=True, + borders=True, + rivers=True, + landcolor="gray3", + oceancolor="denim", + coastlinewidth=0.3, + lonlim=(-15, 40), + latlim=(33, 62), + lonlabels="b", + latlabels="l", + labelsize=5.5, + gridlabelsize=5.5, + grid=True, + gridalpha=0.3, + title="", + titlesize=5.6, + ) + save(fig, "geo_features.png") + + +def main(): + try: + import cartopy # noqa: F401 + except ImportError: + print(" (cartopy missing, skipping the map figures)") + return + use_style() + projections() + features() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/guides.py b/tools/cheatsheet/parts/guides.py new file mode 100644 index 000000000..527e26056 --- /dev/null +++ b/tools/cheatsheet/parts/guides.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Colorbar, legend, and statistical-indicator figures. +""" + +from __future__ import annotations + +import numpy as np + +import ultraplot as uplt + +from common import save, use_style + + +def guides(): + """ + Outer guides on three sides plus an inset legend, all on one axes. + + Each outer guide takes its own gridspec slot, which is why the map itself + stays exactly as wide as it started. + """ + state = np.random.default_rng(51423) + y, x = np.mgrid[0:40, 0:40] + field = np.sin(x / 6) * np.cos(y / 7) + state.normal(0, 0.08, (40, 40)) + + fig, ax = uplt.subplots(figwidth="104mm", figheight="50mm") + mesh = ax.pcolormesh(field, cmap="batlow", levels=9) + lines = ax.plot( + np.linspace(0, 39, 40), + np.column_stack( + [ + 12 + 8 * np.sin(np.linspace(0, 6, 40)), + 26 + 6 * np.cos(np.linspace(0, 6, 40)), + ] + ), + labels=["first", "second"], + lw=1.4, + cycle=("white", "gray2"), + ) + ax.colorbar( + mesh, loc="r", label="loc='r'", width="3mm", labelsize=5.5, ticklabelsize=5 + ) + ax.colorbar( + mesh, + loc="b", + label="loc='b'", + width="3mm", + length=0.7, + labelsize=5.5, + ticklabelsize=5, + ) + ax.legend( + lines, + loc="t", + ncols=2, + frame=False, + fontsize=5.5, + title="loc='t'", + titlefontsize=5.5, + ) + ax.legend( + lines, + loc="ul", + ncols=1, + fontsize=5.2, + title="loc='ul'", + titlefontsize=5.2, + framealpha=0.85, + ) + ax.format(xticks=[], yticks=[], grid=False) + save(fig, "guides.png") + + +def semantic(): + """ + The three legends that describe an encoding rather than an artist. + """ + state = np.random.default_rng(7) + size = state.uniform(8, 120, 90) + value = state.uniform(0, 1, 90) + fig, ax = uplt.subplots(figwidth="104mm", figheight="44mm") + ax.scatter( + state.normal(size=90), + state.normal(size=90), + s=size, + c=value, + cmap="viko", + alpha=0.75, + lw=0, + ) + ax.numlegend( + levels=[0, 0.25, 0.5, 0.75, 1.0], + cmap="viko", + fmt="{:.2f}", + loc="r", + ncols=1, + title="numlegend", + fontsize=5.2, + titlefontsize=5.4, + frame=False, + ) + ax.sizelegend( + [10, 60, 120], + labels=["S", "M", "L"], + loc="b", + ncols=3, + title="sizelegend", + fontsize=5.2, + titlefontsize=5.4, + frame=False, + ) + ax.format(xticks=[], yticks=[], grid=False) + save(fig, "semantic.png") + + +def statistics(): + """ + One dataset of raw samples, four ways of showing its spread. + """ + state = np.random.default_rng(51423) + x = np.linspace(0, 10, 20) + runs = np.sin(x)[None, :] + state.normal(0, 0.35, (120, x.size)) + + fig, axs = uplt.subplots( + ncols=4, + figwidth="120mm", + figheight="30mm", + wspace="3mm", + share=True, + ) + axs[0].plot(x, runs, mean=True, bars=True, barcolor="gray7", barlw=0.6, lw=1.4) + axs[1].plot(x, runs, mean=True, boxes=True, boxcolor="gray7", boxlw=2.0, lw=1.4) + axs[2].plot(x, runs, mean=True, shadestd=1, lw=1.4) + axs[3].plot(x, runs, mean=True, shadestd=1, fadepctile=(5, 95), lw=1.4) + for ax, label in zip( + axs, + ("bars=True", "boxes=True", "shadestd=1", "shade + fadepctile"), + ): + ax.format( + title=label, + titlesize=5.4, + titleloc="l", + titlepad=1.5, + xticks=[], + yticks=[], + grid=False, + ) + save(fig, "statistics.png") + + +def main(): + use_style() + guides() + semantic() + statistics() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/icons.py b/tools/cheatsheet/parts/icons.py new file mode 100644 index 000000000..e74d5e19b --- /dev/null +++ b/tools/cheatsheet/parts/icons.py @@ -0,0 +1,819 @@ +#!/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=2.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=5, color=ICON_LINE) + + +def icon_lollipoph(ax): + ax.lollipoph(CATEGORIES, VALUES, marker="o", markersize=5, 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=7, + grains=7, + linewidth=0.6, + arrowsize=0.6, + ) + + +# ------------------------------------------------------ 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": 18, "node_color": ICON_LINE, "linewidths": 0}, + edge_kw={"alpha": 0.35, "width": 0.6}, + 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=2) + + +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"), +} + + +#: The commands the cheatsheet shows: two rows of fifteen, chosen to span the +#: kinds of plot rather than to be complete. The poster carries all of them. +FEATURED = ( + "plot", + "scatter", + "step", + "stem", + "bar", + "barh", + "area", + "hist", + "box", + "violin", + "parametric", + "lollipop", + "ridgeline", + "beeswarm", + "errorbars", + "pcolormesh", + "contour", + "contourf", + "imshow", + "heatmap", + "hexbin", + "tripcolor", + "quiver", + "streamplot", + "curved_quiver", + "graph", + "sankey", + "chord_diagram", + "radar_chart", + "taylor", + "proj='robin'", + "scatter on a map", +) + + +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 Typst 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 write_manifest(): + """ + Emit the registry as Typst data, so the pages are built from this list. + """ + import os + + path = os.path.join(ASSETS, "icons.typ") + with open(path, "w") as handle: + handle.write("// Generated by parts/icons.py — do not edit.\n") + handle.write("#let commands = (\n") + for name, (_, _, kind, mpl, group) in ICONS.items(): + mpl = f'"{mpl}"' if mpl else "none" + handle.write( + f' (name: "{name.strip()}", file: "{slug(name)}", ' + f'kind: "{kind}", mpl: {mpl}, group: "{group}", ' + f"featured: {str(name in FEATURED).lower()}),\n" + ) + handle.write(")\n") + print(" assets/icons.typ") + + +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)}.png", dpi=220) + write_manifest() + if failures: + print("icon failures:") + for failure in failures: + print(f" {failure}") + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/parts/layout.py b/tools/cheatsheet/parts/layout.py new file mode 100644 index 000000000..9e3f42e77 --- /dev/null +++ b/tools/cheatsheet/parts/layout.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Layout figures: axis sharing, mosaic grids, title and panel-letter placement. +""" + +from __future__ import annotations + +import numpy as np + +import ultraplot as uplt + +from common import ACCENT, INK_FAINT, RULE, SUNK, bare, save, use_style + + +def sharing(): + """ + The same four panels with sharing off and on. + + Sharing is a figure-level setting, so this is two figures rather than one: + the Typst page sets them side by side. It opens the sheet because it is the + feature that changes what a multi-panel figure looks like before you have + formatted anything. + """ + state = np.random.default_rng(51423) + x = np.linspace(0, 10, 120) + series = [ + np.sin(x + shift) * scale + for shift, scale in zip(range(4), (1.0, 0.8, 1.2, 0.9)) + ] + + for share, name in ((False, "sharing_off.png"), (True, "sharing_on.png")): + fig, axs = uplt.subplots( + nrows=2, + ncols=2, + figwidth="60mm", + figheight="42mm", + share=share, + span=share, + ) + for index, ax in enumerate(axs): + ax.plot(x, series[index] + state.normal(0, 0.03, x.size), lw=1) + axs.format( + xlim=(0, 10), + ylim=(-1.35, 1.35), + xlabel="time (s)", + ylabel="signal (mV)", + labelsize=5.5, + ticklabelsize=4.8, + grid=False, + ) + save(fig, name) + + +def mosaic(): + """ + A layout array rendered as the grid it produces. + """ + fig, axs = uplt.subplots( + [[1, 1, 2], [3, 4, 2]], + figwidth="60mm", + figheight="32mm", + hspace="2mm", + wspace="2mm", + ) + 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=9, + color=INK_FAINT, + family="monospace", + ) + save(fig, "mosaic.png") + + +def titles(): + """ + Every title and panel-letter slot, filled with its own keyword. + """ + fig, ax = uplt.subplots(figwidth="60mm", figheight="32mm") + bare(ax, facecolor=SUNK, edgecolor=RULE) + ax.format( + abc="a.", + abcloc="ul", + abcsize=7, + title="title", + titlesize=6.5, + ltitle="ltitle", + rtitle="rtitle", + titlepad=2, + ) + for label, (px, py, ha, va) in { + "ultitle": (0.035, 0.93, "left", "top"), + "urtitle": (0.965, 0.93, "right", "top"), + "lltitle": (0.035, 0.07, "left", "bottom"), + "lrtitle": (0.965, 0.07, "right", "bottom"), + }.items(): + ax.text( + px, + py, + label, + transform=ax.transAxes, + ha=ha, + va=va, + fontsize=6, + family="monospace", + color=INK_FAINT, + ) + ax.text( + 0.5, + 0.45, + "abc='a.' abcloc='ul'", + transform=ax.transAxes, + ha="center", + va="center", + fontsize=6, + family="monospace", + color=ACCENT, + ) + save(fig, "titles.png") + + +def panels(): + """ + An axes with outer panels and an inset, showing what each slot costs. + """ + fig, ax = uplt.subplots(figwidth="66mm", figheight="36mm") + state = np.random.default_rng(1) + data = state.normal(size=(300, 2)) + ax.scatter(data[:, 0], data[:, 1], s=3, alpha=0.5, color=ACCENT) + right = ax.panel_axes("r", width="7mm") + top = ax.panel_axes("t", width="7mm") + right.histh(data[:, 1], bins=18, color=ACCENT, alpha=0.6, lw=0) + top.hist(data[:, 0], bins=18, color=ACCENT, alpha=0.6, lw=0) + inset = ax.inset_axes([0.03, 0.03, 0.3, 0.3], zoom=False) + inset.scatter(data[:, 0], data[:, 1], s=1, alpha=0.5, color=ACCENT) + for child in (right, top, inset): + bare(child) + ax.format(xlabel="x", ylabel="y", labelsize=5.5, ticklabelsize=4.8, grid=False) + save(fig, "panels.png") + + +def main(): + use_style() + sharing() + mosaic() + titles() + panels() + + +if __name__ == "__main__": + main() diff --git a/tools/cheatsheet/poster.typ b/tools/cheatsheet/poster.typ new file mode 100644 index 000000000..78d8fe6a3 --- /dev/null +++ b/tools/cheatsheet/poster.typ @@ -0,0 +1,136 @@ +// UltraPlot plot-type poster — every command, one picture each. +// +// A companion to cheatsheet.typ, sharing its assets and its palette. Where the +// cheatsheet has to earn its space with code, this is only the small plots: +// bigger, grouped by what the command is for, and captioned with the call. + +#import "assets/palette.typ": batlow, rails +#import "assets/icons.typ": commands + +#let paper = rgb("#f2f4f7") +#let panelbg = rgb("#ffffff") +#let ink = rgb("#0f151d") +#let inksoft = rgb("#4a5663") +#let inkfaint = rgb("#8593a1") +#let rule = rgb("#dbe1e8") +#let accent = rgb("#3b638c") +#let badgecolor = rgb("#a8414f") + +#set page( + paper: "a3", + flipped: false, + margin: (x: 11mm, top: 10mm, bottom: 9mm), + fill: paper, + footer: context [ + #set text(size: 7pt, fill: inkfaint) + #grid(columns: (1fr, auto), align: (left + horizon, right + horizon), + [Every picture is the output of the command it names, drawn by + #raw("tools/cheatsheet/parts/icons.py") · ultraplot.readthedocs.io], + [#counter(page).display()], + ) + ], + footer-descent: 5mm, +) +#set text(font: ("IBM Plex Sans", "DejaVu Sans"), size: 8pt, fill: ink) +#set par(leading: 0.5em) +#show raw: set text(font: ("IBM Plex Mono", "DejaVu Sans Mono"), size: 7.6pt) + +// How far a command is from matplotlib, as a colour. Written on one line: a +// multi-line if/else chain in markup mode does not bind as one expression. +#let tone-of(kind) = if kind == "new" { badgecolor } else if kind == "better" { accent } else { ink } +#let rail-of(kind) = if kind == "new" { badgecolor } else if kind == "better" { accent } else { rule } + +#let exclusive-badge = box( + fill: badgecolor, + inset: (x: 2.6pt, y: 0.9pt), + radius: (bottom-left: 2pt), + text(size: 4.8pt, font: "IBM Plex Sans", fill: white, weight: 600, tracking: 0.05em, "EXCLUSIVE"), +) + +// One thumbnail. An UltraPlot-exclusive command gets a full outline and a +// corner badge; the rest carry a section rail only. +#let tile(entry) = block(width: 100%, breakable: false)[ + #box( + fill: panelbg, + stroke: if entry.kind == "new" { 1.2pt + badgecolor } else if entry.kind == "better" { (top: 2pt + accent, rest: 0.5pt + rule) } else { 0.5pt + rule }, + radius: 2pt, + inset: 0pt, + clip: true, + width: 100%, + )[ + #image("assets/icons/" + entry.file + ".png", width: 100%) + #if entry.kind == "new" { place(top + right, exclusive-badge) } + ] + #v(2.5pt) + #let parts = entry.name.split("(") + #align(center, text(size: 5.9pt, font: "IBM Plex Mono", fill: tone-of(entry.kind), + if parts.len() > 1 [ + #parts.at(0) \ #text(size: 5.4pt)[(#parts.at(1)] + ] else [ + #entry.name + ], + )) + #if entry.mpl != none [ + #v(1pt) + #align(center, text(size: 5pt, fill: inkfaint, style: "italic", entry.mpl)) + ] +] + +#let section(title, blurb, group, columns: 13) = { + let items = commands.filter(entry => entry.group == group) + block(width: 100%, breakable: false, above: 11pt, below: 2pt)[ + #grid(columns: (auto, 1fr), column-gutter: 7pt, align: (left + bottom, left + bottom), + text(size: 11pt, weight: 700, tracking: 0.03em, upper(title)), + text(size: 7pt, fill: inkfaint, blurb), + ) + #v(2.5pt) + #line(length: 100%, stroke: 0.7pt + rule) + #v(5pt) + #grid( + columns: (1fr,) * columns, + column-gutter: 2.4mm, + row-gutter: 3mm, + align: center + top, + ..items.map(tile), + ) + ] +} + +// ------------------------------------------------------------- masthead +#block(width: 100%, below: 8pt)[ + #grid(columns: (auto, 1fr), column-gutter: 14mm, align: (left + bottom, left + bottom), + [ + #text(size: 34pt, weight: 700, tracking: -0.02em, "UltraPlot") + #v(-11pt) + #text(size: 10pt, weight: 600, fill: accent, tracking: 4pt, "PLOT TYPES") + ], + [ + #text(size: 8pt, fill: inksoft)[ + Every command UltraPlot can draw, one picture each — and each picture is + that command's own output, at 26 mm. Assumes `import ultraplot as uplt`, + then `fig, ax = uplt.subplots()`. + ] + #v(4pt) + #text(size: 7.2pt)[ + #box(width: 8pt, height: 2.5pt, fill: rule, baseline: -1pt) #h(2pt) + #text(fill: inksoft)[matplotlib has the command] #h(9pt) + #box(width: 8pt, height: 2.5pt, fill: accent, baseline: -1pt) #h(2pt) + #text(fill: inksoft)[matplotlib can, but you assemble it] #h(9pt) + #box(width: 8pt, height: 2.5pt, fill: badgecolor, baseline: -1pt) #h(2pt) + #text(fill: inksoft)[UltraPlot exclusive] + ] + ], + ) + #v(6pt) + #rect(width: 100%, height: 4pt, stroke: none, radius: 1pt, + fill: gradient.linear(..batlow)) +] + +#section("Relational", "how one variable relates to another", "relational") +#section("Distributions", "the shape and spread of a sample", "distribution") +#section("Fields", "a value over a two-dimensional grid", "field") +#section("Vectors", "direction and magnitude on a grid", "vector") +#section("Networks and diagrams", "relationships that are not a grid", "network") +#section("Maps", "a projection by name, with anything drawn on top in lon/lat", "maps") +#section("What one keyword does", "the same command, changed by a single argument", "keyword") +#section("Swapped axes", "the siblings that put the categories on the other axis", "swapped") From bed382f2cb81597b16fd666e15dc0c10d5845a60 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 9 Sep 2026 17:18:11 +1000 Subject: [PATCH 2/3] Replace Typst cheatsheets with editable draw.io reference --- .gitignore | 9 +- MANIFEST.in | 2 + pyproject.toml | 2 +- tools/cheatsheet/README.md | 125 +- tools/cheatsheet/build.py | 94 +- tools/cheatsheet/cheatsheet.typ | 622 ------- tools/cheatsheet/docs_index.py | 11 +- tools/cheatsheet/drawio.py | 319 ++++ tools/cheatsheet/fix_svg_seams.py | 115 ++ tools/cheatsheet/parts/color.py | 241 --- tools/cheatsheet/parts/common.py | 19 +- tools/cheatsheet/parts/drawio_details.py | 112 ++ tools/cheatsheet/parts/features.py | 708 ++----- tools/cheatsheet/parts/geo.py | 114 -- tools/cheatsheet/parts/guides.py | 159 -- tools/cheatsheet/parts/icons.py | 84 +- tools/cheatsheet/parts/layout.py | 157 -- tools/cheatsheet/poster.typ | 136 -- tools/cheatsheet/ultraplot_cheatsheet.drawio | 1744 ++++++++++++++++++ tools/cheatsheet/ultraplot_cheatsheet.svg | 2 + 20 files changed, 2561 insertions(+), 2214 deletions(-) create mode 100644 MANIFEST.in delete mode 100644 tools/cheatsheet/cheatsheet.typ create mode 100644 tools/cheatsheet/drawio.py create mode 100644 tools/cheatsheet/fix_svg_seams.py delete mode 100644 tools/cheatsheet/parts/color.py create mode 100644 tools/cheatsheet/parts/drawio_details.py delete mode 100644 tools/cheatsheet/parts/geo.py delete mode 100644 tools/cheatsheet/parts/guides.py delete mode 100644 tools/cheatsheet/parts/layout.py delete mode 100644 tools/cheatsheet/poster.typ create mode 100644 tools/cheatsheet/ultraplot_cheatsheet.drawio create mode 100644 tools/cheatsheet/ultraplot_cheatsheet.svg diff --git a/.gitignore b/.gitignore index 2e58b786b..ba78a3adf 100644 --- a/.gitignore +++ b/.gitignore @@ -63,12 +63,7 @@ ultraplot/_version.py # Nox build directories .nox/* -# Cheatsheet build output: the icons, the sheets, and the copies the docs use. -# All are generated by tools/cheatsheet/build.py, and regenerated during the -# docs build, so only the sources belong in the repository. +# Generated docs and draw.io assets. The edited diagram remains a repository asset. tools/cheatsheet/assets/ docs/_static/plot_types/ -ultraplot_cheatsheet*.pdf -ultraplot_cheatsheet*.png -ultraplot_plot_types*.pdf -ultraplot_plot_types*.png +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/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 index 417675827..13a2643de 100644 --- a/tools/cheatsheet/README.md +++ b/tools/cheatsheet/README.md @@ -1,96 +1,69 @@ -# UltraPlot cheatsheet +# UltraPlot cheatsheet and docs icons -Two A3 pages in the spirit of [matplotlib's cheatsheets](https://matplotlib.org/cheatsheets/), -and built the same way: small Python scripts render the figures, and a document -engine assembles them. Matplotlib uses LaTeX for the assembly step; this uses -[Typst](https://typst.app), which keeps the layout in one readable file. +This folder contains the editable A3 draw.io cheatsheet and the renderers shared +with the documentation’s visual plot-type index. -``` -tools/cheatsheet/ -├── build.py # render the parts, compile the sheets, write the docs page -├── cheatsheet.typ # the three-page sheet: palette, panels, grid, copy -├── poster.typ # the companion plot-type poster (A3, every command) -├── docs_index.py # writes docs/plot_types.rst from the same registry -├── parts/ -│ ├── common.py # shared drawing style and the save() helper -│ ├── layout.py # axis sharing, mosaics, titles, panels -│ ├── icons.py # one thumbnail per plotting command -│ ├── features.py # one thumbnail per UltraPlot-only feature -│ ├── color.py # bundled colormap tables, cycles, norms, palette.typ -│ ├── guides.py # colorbars, legends, statistical indicators -│ └── geo.py # projections and map features -└── assets/ # generated; safe to delete -``` +- `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 -micromamba run -n ultraplot-dev python tools/cheatsheet/build.py +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 ``` -writes, at the repository root, `ultraplot_cheatsheet.pdf` (three A3 pages), -`ultraplot_plot_types.pdf` (the one-page poster), PNGs of each, and -`docs/plot_types.rst` with its icons in `docs/_static/plot_types/`. Three flags -help while iterating: +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 --figures # re-render the figures only -python tools/cheatsheet/build.py --typst # re-lay out the sheets only -python tools/cheatsheet/build.py --docs # rewrite the docs page only +python tools/cheatsheet/build.py --drawio /tmp/ultraplot-regenerated.drawio ``` -Each part script also runs on its own, which is the fastest loop when you are -working on one figure: +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 -cd tools/cheatsheet/parts && python icons.py +python tools/cheatsheet/parts/icons.py +python tools/cheatsheet/parts/features.py +python tools/cheatsheet/parts/drawio_details.py ``` -Requirements: an environment with UltraPlot, cartopy (for `geo.py`), networkx -and pandas (for a few icons), plus the `typst` binary and the IBM Plex fonts. -`geo.py` skips itself with a note if cartopy is missing rather than failing the -build. +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. -## Conventions +## Docs and packaging -- **Two icon sets, three kinds.** `icons.py` answers "what can I draw" (one - thumbnail per plotting command); `features.py` answers "what does UltraPlot - add", following the sections of `docs/why.rst`. Both registries classify each - entry as `same` (matplotlib has the command), `better` (matplotlib can do it, - but you assemble it yourself) or `new` (no equivalent), and name the - matplotlib counterpart for the middle case. That distinction is the honest - one: most of UltraPlot's value is the middle case, and the page says so - rather than claiming everything is unprecedented. -- **The galleries are generated.** Each registry writes a Typst manifest — - `assets/icons.typ` and `assets/features.typ` — and both `cheatsheet.typ` and - `poster.typ` build their grids by filtering those. Adding an icon means adding - one registry entry; the sheets, the poster and the docs page pick it up on the - next build. The cheatsheet shows the thirty entries flagged `FEATURED`; the - poster shows all of them. -- **One drawing vocabulary.** `common.py` holds the sample data every icon draws - from — one wave, one cloud, one field, one set of categories — plus the colour - roles and the stroke weights that survive being scaled to 10 mm. Two icons - then differ only where the commands differ, which is the whole point of a - small-multiples gallery. -- **Every figure is the real command.** No mock-ups: the `contourf` thumbnail is - `ax.contourf`, the sharing comparison is two real figures with `share` set - differently, and the colormap tables are read from - `ultraplot.demos.CMAP_TABLE` — the same source `uplt.show_cmaps()` uses, so - the sheet cannot drift from what is actually registered. -- **The palette comes from the plots.** `parts/color.py` writes - `assets/palette.typ` with real `batlow` samples; the section rails and the - masthead gradient in `cheatsheet.typ` import it. -- **Parts do not know about the page.** A part renders one figure at a sensible - size and saves it. All sizing, cropping and captioning happens in Typst. -- **Panel heights are set per band.** `sheet(weights: (...))` gives each band a - share of the page, and every panel in a band matches its neighbours. If a - panel overflows, either trim its content or raise that band's weight — the - weights are the tuning knob. +`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. -## Adding a panel +The cheatsheet stays in the repository but is excluded from wheels and source +distributions. Docs builds should run from a repository checkout. -1. If it needs a figure, add a function to the relevant part script, save with - `save(fig, "name.png")`, and check it renders on its own. -2. Add a `panel(...)` block to `cheatsheet.typ` in the right band. -3. Rebuild and look at the PNGs. Content that overflows its panel is visible - immediately — Typst does not clip it, it runs over the frame. +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 index fdec32d11..34d4b80c9 100644 --- a/tools/cheatsheet/build.py +++ b/tools/cheatsheet/build.py @@ -1,102 +1,44 @@ #!/usr/bin/env python3 -""" -Build the UltraPlot cheatsheet. - -Renders every figure part with UltraPlot, then hands the assets to Typst to -lay out. This mirrors how matplotlib builds its own cheatsheets: small scripts -produce the panels, and the document engine assembles them. +"""Build docs icons and draw.io assets without overwriting an edited diagram. - micromamba run -n ultraplot-dev python tools/cheatsheet/build.py - micromamba run -n ultraplot-dev python tools/cheatsheet/build.py --figures - micromamba run -n ultraplot-dev python tools/cheatsheet/build.py --typst +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 -import os +from pathlib import Path import subprocess import sys -HERE = os.path.dirname(os.path.abspath(__file__)) -PARTS = os.path.join(HERE, "parts") -ROOT = os.path.dirname(os.path.dirname(HERE)) - -#: Part modules, in the order their output appears on the page. -MODULES = ("layout", "icons", "features", "color", "guides", "geo") +HERE = Path(__file__).resolve().parent +PARTS = HERE / "parts" +MODULES = ("icons", "features", "drawio_details") def render_figures(): - """ - Run every part script in its own process, so one failure is isolated. - """ - sys.path.insert(0, PARTS) for name in MODULES: - print(f"{name}.py") - result = subprocess.run( - [sys.executable, os.path.join(PARTS, f"{name}.py")], - cwd=PARTS, - ) - if result.returncode: - raise SystemExit(f"{name}.py failed with code {result.returncode}") - - -def compile_document(name, stem, png=True): - """ - Compile one Typst document to PDF, and optionally to PNG pages. - """ - source = os.path.join(HERE, name) - pdf = os.path.join(ROOT, f"{stem}.pdf") - subprocess.run(["typst", "compile", "--root", HERE, source, pdf], check=True) - print(f" {os.path.relpath(pdf, ROOT)}") - if png: - pattern = os.path.join(ROOT, stem + "_p{p}.png") - subprocess.run( - [ - "typst", - "compile", - "--root", - HERE, - "--format", - "png", - "--ppi", - "150", - source, - pattern, - ], - check=True, - ) - print(f" {os.path.relpath(pattern, ROOT)}") - - -def compile_typst(png=True): - """ - Compile both sheets: the three-page cheatsheet and the plot-type poster. - """ - compile_document("cheatsheet.typ", "ultraplot_cheatsheet", png=png) - compile_document("poster.typ", "ultraplot_plot_types", png=png) + subprocess.run([sys.executable, str(PARTS / f"{name}.py")], cwd=PARTS, check=True) def write_docs_page(): - """ - Regenerate the documentation's visual plot-type index. - """ - subprocess.run([sys.executable, os.path.join(HERE, "docs_index.py")], check=True) + 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="only render figures") - parser.add_argument("--typst", action="store_true", help="only run typst") - parser.add_argument("--docs", action="store_true", help="only write the docs page") + 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() - only = args.figures or args.typst or args.docs - if args.figures or not only: + default = not (args.figures or args.docs or args.drawio) + if args.figures or default: render_figures() - if args.typst or not only: - compile_typst() - if args.docs or not only: + 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__": diff --git a/tools/cheatsheet/cheatsheet.typ b/tools/cheatsheet/cheatsheet.typ deleted file mode 100644 index 948e5c5fa..000000000 --- a/tools/cheatsheet/cheatsheet.typ +++ /dev/null @@ -1,622 +0,0 @@ -// UltraPlot cheatsheet — layout and typography. -// -// Every figure on these pages is rendered by a script in parts/ and dropped in -// assets/. This file only arranges them, the way matplotlib's own cheatsheets -// assemble their generated panels. Build with tools/cheatsheet/build.py. - -#import "assets/palette.typ": batlow, rails -#import "assets/features.typ": features -#import "assets/icons.typ": commands - -// ---------------------------------------------------------------- palette -#let paper = rgb("#f2f4f7") -#let panelbg = rgb("#ffffff") -#let ink = rgb("#0f151d") -#let inksoft = rgb("#4a5663") -#let inkfaint = rgb("#8593a1") -#let rule = rgb("#dbe1e8") -#let sunk = rgb("#f0f3f7") -#let accent = rgb("#3b638c") -#let badgecolor = rgb("#a8414f") -#let codeink = rgb("#243040") - -// ---------------------------------------------------------------- page -#set page( - paper: "a3", - flipped: true, - margin: (x: 10mm, top: 8mm, bottom: 9mm), - fill: paper, - footer: context [ - #set text(size: 6pt, fill: rgb("#8593a1")) - #grid(columns: (1fr, auto), align: (left + horizon, right + horizon), - [Figures rendered by #raw("tools/cheatsheet/parts/*.py"), assembled by - #raw("cheatsheet.typ") · rails and swatches are real #raw("batlow") samples - · ultraplot.readthedocs.io], - [#counter(page).display() / #counter(page).final().first()], - ) - ], - footer-descent: 4mm, -) -#set text(font: ("IBM Plex Sans", "DejaVu Sans"), size: 7.4pt, fill: ink) -#set par(leading: 0.52em, spacing: 0.62em, justify: false) - -// Code is set plain rather than syntax-coloured: a cheatsheet is scanned, and -// four token colours per block fight the section rails for attention. -#show raw: set text(font: ("IBM Plex Mono", "DejaVu Sans Mono"), size: 6.3pt, fill: codeink) -#show raw.where(block: true): block.with( - fill: sunk, - inset: (x: 4.5pt, y: 4pt), - radius: 2pt, - width: 100%, -) - -// ---------------------------------------------------------------- pieces -#let chip(fill-color, text-color, label) = box( - fill: fill-color, - inset: (x: 3pt, y: 1.2pt), - radius: 1.5pt, - text(size: 5.2pt, font: "IBM Plex Mono", fill: text-color, weight: 500, label), -) - -#let badge = chip(rgb("#fbeef0"), badgecolor, "de novo") -#let betterbadge = chip(rgb("#e9eff6"), accent, "enhancement") - -#let note(body) = text(size: 6.2pt, fill: inksoft, style: "italic", body) - -// One cheatsheet cell. Panels stretch to their row so a section reads as a -// tiled band rather than a ragged shelf. -#let panel(title, rail, body, kind: none) = block( - fill: panelbg, - stroke: (top: 2pt + rail, rest: 0.4pt + rule), - radius: 2pt, - inset: (x: 7pt, y: 6pt), - width: 100%, - height: 100%, - breakable: false, -)[ - #grid( - columns: (1fr, auto), - align: (left + horizon, right + horizon), - text(size: 8.4pt, weight: 600, tracking: -0.01em, title), - if kind == "new" { badge } else if kind == "better" { betterbadge } else { none }, - ) - #v(1.5pt) - #line(length: 100%, stroke: 0.4pt + rail.lighten(55%)) - #v(4pt) - #body -] - -#let band(label, rail, note-text) = block(width: 100%, above: 0pt, below: 3.5pt)[ - #grid( - columns: (auto, 1fr, auto), - column-gutter: 5pt, - align: (left + horizon, left + horizon, right + horizon), - box(width: 7pt, height: 7pt, radius: 1pt, fill: rail), - text(size: 10.5pt, weight: 700, tracking: 0.04em, upper(label)), - text(size: 6.8pt, fill: inkfaint, note-text), - ) - #v(2pt) - #line(length: 100%, stroke: 0.6pt + rule) -] - -#let shot(path, caption: none, width: 100%) = block(width: 100%)[ - #align(center, image(path, width: width)) - #if caption != none [ #v(2pt) #note(caption) ] -] - -#let newcolor = badgecolor -#let bettercolor = accent - -// An UltraPlot-exclusive thumbnail gets a full outline and a corner badge, not -// just a coloured caption: it is the one thing on the page you cannot get from -// matplotlib at all, so it should be findable at arm's length. -#let exclusive-badge = box( - fill: newcolor, - inset: (x: 2.2pt, y: 0.7pt), - radius: (bottom-left: 1.5pt), - text(size: 4.2pt, font: "IBM Plex Sans", fill: white, weight: 600, tracking: 0.05em, "EXCLUSIVE"), -) - -#let thumb(path, kind) = box( - fill: panelbg, - stroke: if kind == "new" { 1pt + newcolor } else if kind == "better" { (top: 1.6pt + bettercolor, rest: 0.4pt + rule) } else { 0.4pt + rule }, - radius: 1.5pt, - inset: 0pt, - clip: true, - width: 100%, -)[ - #image(path, width: 100%) - #if kind == "new" { place(top + right, exclusive-badge) } -] - -// A command thumbnail. The caption colour says whether matplotlib has the -// command already, can be made to do it, or has nothing like it. -#let icon(entry) = block(width: 100%, breakable: false)[ - #thumb("assets/icons/" + entry.file + ".png", entry.kind) - #v(1.5pt) - #align(center, text( - size: 5.4pt, - font: "IBM Plex Mono", - fill: if entry.kind == "new" { newcolor } else if entry.kind == "better" { bettercolor } else { inksoft }, - entry.name, - )) - #if entry.mpl != none [ - #v(0.8pt) - #align(center, text(size: 4.5pt, fill: inkfaint, style: "italic", entry.mpl)) - ] -] - -#let cells(..items) = grid( - columns: (1fr,) * 4, - rows: (100%,), - gutter: 3.2mm, - ..items, -) - -// A page is bands and panel rows alternating. Panel rows are fractional so the -// page always fills and every panel in a band matches its neighbours' height; -// the weights below say which band needs the most room. -#let sheet(weights: none, ..blocks) = { - let items = blocks.pos() - let rows = () - let index = 0 - let band-index = 0 - for _ in items { - if calc.even(index) { - rows.push(auto) - } else { - let weight = if weights == none { 1.0 } else { weights.at(band-index) } - rows.push(weight * 1fr) - band-index += 1 - } - index += 1 - } - grid(columns: 1, rows: rows, row-gutter: 3.5mm, ..items) -} - -// ---------------------------------------------------------------- masthead -#let masthead(subtitle) = block(width: 100%, below: 5pt)[ - #grid( - columns: (auto, 1fr, auto), - column-gutter: 10mm, - align: (left + bottom, left + bottom, right + bottom), - [ - #text(size: 34pt, weight: 700, tracking: -0.02em, "UltraPlot") - #v(-11pt) - #text(size: 10pt, weight: 600, fill: accent, tracking: 3.6pt, "CHEATSHEET") - ], - text(size: 7.8pt, fill: inksoft, subtitle), - text(size: 7pt, font: "IBM Plex Mono", fill: inkfaint)[ - uplt.subplots() → fig, axs \ - axs.format(…) → everything \ - fig.save(…) → done - ], - ) - #v(4pt) - #rect(width: 100%, height: 3.5pt, stroke: none, radius: 1pt, - fill: gradient.linear(..batlow)) -] - -// ================================================================ PAGE ONE -#masthead[ - Everything assumes `import ultraplot as uplt`. UltraPlot subclasses matplotlib's - `Figure`, `Axes` and `GridSpec`, so every matplotlib call still works — these pages - are what UltraPlot *adds*, and #badge marks what has no matplotlib equivalent. - #linebreak() - Page 1 gets a figure laid out and labelled. Page 2 is what you can draw in it. -] - -#sheet( - weights: (1.00, 0.92, 1.08), - band("Sharing and layout", rails.at(0), "the defaults that change a multi-panel figure before you have formatted anything"), - cells( - panel("Axis sharing is on", rails.at(0), kind: "better")[ - #grid(columns: (1fr, 1fr), gutter: 4pt, - shot("assets/sharing_off.png"), - shot("assets/sharing_on.png"), - ) - #v(1pt) - #grid(columns: (1fr, 1fr), gutter: 4pt, - align(center, note[`share=False`]), - align(center, note[`share=True`, the default]), - ) - #v(4pt) - ``` - uplt.subplots(nrows=2, ncols=2, share=True, span=True) - # True | False | 'labels' | 'limits' | 0 | 1 | 2 | 3 - ``` - #note[Limits, ticks and labels are shared per row and column, and repeated labels collapse into one spanning label.] - ], - panel("Mosaic layouts", rails.at(0), kind: "better")[ - #shot("assets/mosaic.png", width: 88%) - #v(4pt) - ``` - fig, axs = uplt.subplots([[1, 1, 2], - [3, 4, 2]], refwidth=1.8) - - gs = uplt.GridSpec(nrows=2, ncols=2, pad=1) - ax = fig.subplot(gs[:, 0]) - ``` - #note[Draw the layout as an array: `0` leaves a gap, a repeated number spans cells.] - ], - panel("Size in real units", rails.at(0), kind: "new")[ - ``` - refwidth size of the reference subplot - refheight ... its height - refaspect ... its width:height - figwidth total figure width - hratios relative row sizes - wratios relative column sizes - wspace gaps; None = solve it - pad outer padding - ``` - #note[Numbers are inches; strings work too — `'55mm'`, `'2cm'`, `'8em'`, `'120pt'`. Convert by hand with `uplt.units('3cm', 'in')`.] - #v(2pt) - ``` - axs[0]; axs[:, 0]; axs[1, 1:] # SubplotGrid - axs.format(...) # broadcasts - ``` - ], - panel("Panels, insets, twins", rails.at(0))[ - #shot("assets/panels.png", width: 94%) - #v(4pt) - ``` - px = ax.panel_axes('r', width='4em') - ix = ax.inset_axes([.6, .6, .3, .3], zoom=True) - axt = ax.altx(); axr = ax.alty(ylabel='mm') - axd = ax.dualx(lambda x: 1 / x) - ``` - #note[Outer panels take their own gridspec slot, so they never squeeze or distort the subplot.] - ], - ), - - band("format()", rails.at(1), "call it on a figure, an axes or a grid — or pass the same keywords straight into subplots()"), - cells( - grid.cell(colspan: 2, panel("The canonical call", rails.at(1))[ - ``` - axs.format( - suptitle='Model intercomparison', # figure - toplabels=('Control', 'Perturbed'), # column headers - leftlabels=('DJF', 'JJA'), # row headers - abc='a.', abcloc='ul', # panel letters - title='centre', urtitle='corner', # axes titles - xlabel='time (s)', ylabel='signal (mV)', - xlim=(0, 10), ylim=(-1, 1), xscale='log', - xlocator=2, xminorlocator=.5, xformatter='sci', - xtickdir='inout', xtickloc='both', xrotation=45, - grid=True, gridminor=False, facecolor='gray1', - rc_kw={'font.size': 11}, # any rc setting - ) - ``` - #note[Unrecognised keywords are read as rc settings, so `abcloc` sets `abc.loc` and `titlepad` sets `title.pad`.] - ]), - panel("Titles and panel letters", rails.at(1), kind: "new")[ - #shot("assets/titles.png", width: 94%) - #v(4pt) - ``` - abc = True | 'a.' | 'A.' | '(a)' | 'a)' - abcloc = 'ul' # l c r ul uc ur ll lc lr - toplabels leftlabels rightlabels bottomlabels - ``` - #note[The letter is placed for you, in or above the axes, and never over a tick label.] - ], - panel("Ticks", rails.at(1))[ - ``` - ax.format( - xlocator=0.5, # every 0.5 - xlocator=[0, 1, 5], # exactly these - xminorlocator=0.1, - xformatter='sci', # 'deg' 'pi' 'lat' - xformatter='%.1f', - xformatter=['a', 'b'], # literal labels - xbounds=(0, 8), # crop the spine - xtickloc='both', - xtickdir='inout', - ) - - uplt.arange(-3, 3, .5) # endpoint kept - ``` - #note[Locators and formatters are built from plain values — no importing `mticker`. `uplt.arange` keeps its endpoint, which is what level and tick lists want.] - ], - ), - - band("Colorbars and legends", rails.at(2), "outer guides take their own gridspec slot — they never steal space from the subplot"), - cells( - panel("Where guides go", rails.at(2))[ - #shot("assets/guides.png", width: 96%) - #note[Outer sides `'l' 'r' 't' 'b'`; inset corners `'ul' 'ur' 'll' 'lr'`, plus `'uc'` and `'lc'`. Several guides on one side queue up.] - ], - panel("Building them", rails.at(2))[ - ``` - ax.pcolormesh(data, cmap='batlow', colorbar='r', - colorbar_kw={'label': 'K'}) - ax.plot(Y, labels=['a', 'b'], legend='b', - legend_kw={'ncols': 3, 'frame': False}) - - fig.colorbar(m, loc='b', col=1, length=.7) - fig.legend(hs, loc='r', rows=(1, 2)) - ax.colorbar(lines, values=[1, 2, 3]) - ax.colorbar('Blues', values=range(10)) - ``` - #note[Legends find their own handles, and restyle in place through `lw=`, `color=`, `markersize=`. Width and length are physical units, not fractions of the axes.] - ], - grid.cell(colspan: 2, panel("Semantic legends", rails.at(2), kind: "new")[ - #grid(columns: (1.05fr, 1fr), gutter: 6pt, - shot("assets/semantic.png"), - [ - ``` - ax.catlegend(names, colors={...}, - markers={...}) - ax.sizelegend([10, 50, 200], - labels=['S', 'M', 'L']) - ax.numlegend(levels=[0, .25, .5, .75, 1], - cmap='viko', fmt='{:.2f}') - ax.entrylegend([{...}, {...}]) - ax.geolegend([...]) - ``` - #note[These describe an *encoding*, so nothing invisible has to be plotted first just to make a handle. All exist on `fig` too, and `add=False` returns `(handles, labels)` for composing your own.] - ], - ) - ]), - ), -) - -#pagebreak() - -// ================================================================ PAGE TWO -#masthead[ - What you can draw, and the colour you draw it in. Every thumbnail below is the - output of the command it names, rendered by the scripts in `parts/` — none of it - is a mock-up. The caption colour says how far it is from matplotlib. -] - -#sheet( - weights: (0.90, 1.00, 1.10), - band("Plot types", rails.at(0), "one picture per command — grey: matplotlib has it · blue: matplotlib can, by hand · red: no equivalent"), - block( - fill: panelbg, - stroke: (top: 2pt + rails.at(0), rest: 0.4pt + rule), - radius: 2pt, - inset: (x: 8pt, y: 7pt), - width: 100%, - height: 100%, - )[ - #grid( - columns: (1fr,) * 15, - column-gutter: 2.6mm, - row-gutter: 3mm, - align: center + top, - ..commands.filter(entry => entry.featured).map(icon), - ) - #v(5pt) - #grid(columns: (1fr, 1fr), gutter: 8mm, - note[Every `x`-oriented 1D command has a `…x` sibling — `plotx`, `scatterx`, `areax` — that swaps the axes properly instead of transposing by hand. Feed any of them pandas or xarray objects and the labels, coordinates and units come along.], - note[The polar family (`chord_diagram`, `radar_chart`, `phylogeny`, `circos_bed`) wants `proj='polar'`, and `taylor` is its own projection. These thirty span the kinds of plot; all fifty-six, the keyword variants and the swapped-axis siblings are on the companion poster, `ultraplot_plot_types.pdf`.], - ) - ], - - band("Fields, distributions, colour", rails.at(1), "levels and norms, the statistics UltraPlot computes for you, and the maps it ships with"), - cells( - panel("Discrete levels by default", rails.at(1), kind: "better")[ - #shot("assets/norms.png") - #v(3pt) - ``` - ax.pcolormesh(x, y, z, cmap='roma', - levels=11, # count or edges - values=uplt.arange(-4, 4), # level centres - extend='both', discrete=True, - norm='div', labels=True) - ``` - #note[`values=` pins a diverging midpoint to the real zero. `labels=True` writes the value into every cell or contour, in a colour that stays legible on the fill.] - ], - panel("Statistics from raw samples", rails.at(1), kind: "new")[ - #shot("assets/statistics.png") - #v(3pt) - ``` - ax.plot(x, runs, mean=True, shadestd=1, - fadepctile=(5, 95)) - ax.bar(x, runs, median=True, bars=True) - ``` - #note[Hand the command the raw samples, one column per `x`, then pick the reduction (`mean`, `median`) and the indicator. Each takes a `…std`, `…pctile` or explicit `…data` form.] - ], - panel("Cycles", rails.at(2))[ - #shot("assets/cycles.png", width: 96%) - #v(3pt) - ``` - uplt.rc.cycle = 'colorblind' - ax.plot(Y, cycle='538') - ax.plot(Y, cycle='Blues', cycle_kw={'left': .2}) - uplt.Cycle(lw=3, dashes=[(1, .5), (3, 1.5)]) - ``` - #note[Hand a 2D array to a 1D command and every column takes the next colour.] - ], - panel("Build and check a colormap", rails.at(2), kind: "new")[ - ``` - uplt.Colormap('prussian blue', l=100, space='hpl') - uplt.Colormap(['blue', 'white', 'red']) - uplt.Colormap(h=(0, 360), c=50, l=70, - space='hcl', cyclic=True) - uplt.Colormap('Blues4_r', 'Reds3', ratios=(1, 3)) - - # cmap_kw: left right cut shift alpha gamma - # suffixes: _r reverse, _s shift - ``` - #v(2pt) - #shot("assets/luminance.png", width: 80%) - #note[A sound sequential map ramps luminance monotonically. `jet` does not.] - ], - ), - - band("Bundled colormaps", rails.at(2), "registered on import — uplt.show_cmaps() prints the full set"), - cells( - grid.cell(colspan: 2, panel("UltraPlot, cmOcean, Crameri", rails.at(2))[ - #grid(columns: (1fr, 1fr), gutter: 7pt, - [ - #text(size: 6pt, weight: 600, fill: inksoft, "UltraPlot") - #v(1pt) - #shot("assets/cmaps_uplt.png") - #v(4pt) - #text(size: 6pt, weight: 600, fill: inksoft, "cmOcean") - #v(1pt) - #shot("assets/cmaps_cmocean.png") - ], - [ - #text(size: 6pt, weight: 600, fill: inksoft, "Scientific colour maps (Crameri)") - #v(1pt) - #shot("assets/cmaps_scientific.png") - ], - ) - ]), - panel("Maps", rails.at(3))[ - #shot("assets/geo_projections.png") - #v(2pt) - ``` - fig, axs = uplt.subplots( - proj=('robin', 'ortho', 'npstere'), ncols=3) - ax.pcolormesh(lon, lat, data, cmap='roma') - ``` - #note[Short names cover the usual set: `cyl moll hammer eqearth laea lcc geos npstere aeqd`. Projection arguments go through `proj_kw`; cartopy is the default backend.] - ], - panel("Features, rc, output", rails.at(3))[ - #shot("assets/geo_features.png", width: 68%) - #v(2pt) - ``` - ax.format(land=True, ocean=True, coast=True, - borders=True, rivers=True, - lonlim=(-15, 40), latlim=(33, 62), - lonlabels='b', latlabels='l') - ``` - #v(1pt) - #v(2pt) - ``` - uplt.rc.update({'fontsize': 11, 'tickdir': 'in'}) - ani = uplt.FuncAnimation(fig, update, 100) - ani.save('waves.mp4') # blit=True - ``` - ], - ), -) - -#pagebreak() - -// ============================================================== PAGE THREE -// The gallery is generated from assets/features.typ, which parts/features.py -// writes, so the classification lives with the drawing code. - -#let newcolor = badgecolor -#let bettercolor = accent - -#let feat(entry) = block(width: 100%, breakable: false)[ - #thumb("assets/features/" + entry.name + ".png", entry.kind) - #v(1.8pt) - #align(center, text( - size: 5.4pt, - font: "IBM Plex Mono", - fill: if entry.kind == "new" { newcolor } else { bettercolor }, - entry.label, - )) - #if entry.mpl != none [ - #v(0.8pt) - #align(center, text(size: 4.6pt, fill: inkfaint, style: "italic", entry.mpl)) - ] -] - -#let gallery(rail, group) = { - let items = features.filter(entry => entry.group == group) - block( - fill: panelbg, - stroke: (top: 2pt + rail, rest: 0.4pt + rule), - radius: 2pt, - inset: (x: 8pt, y: 7pt), - width: 100%, - height: 100%, - )[ - #grid( - columns: (1fr,) * items.len(), - column-gutter: 3mm, - align: center + top, - ..items.map(feat), - ) - ] -} - -#let kindkey = [ - #box(width: 6pt, height: 2pt, fill: newcolor, baseline: -1pt) - #h(1.5pt) #text(fill: inksoft)[de novo: matplotlib has no equivalent] - #h(7pt) - #box(width: 6pt, height: 2pt, fill: bettercolor, baseline: -1pt) - #h(1.5pt) #text(fill: inksoft)[enhancement: matplotlib can, but you assemble it — its counterpart is named underneath] -] - -#masthead[ - What UltraPlot adds, one picture each, drawn by the feature it shows. Two kinds, - and the difference matters: a handful of these have no matplotlib equivalent at - all, but most are things matplotlib *can* do and UltraPlot does for you. - #linebreak() - #kindkey -] - -#sheet( - weights: (1.00, 0.86, 0.86, 1.28), - - band("Figures and subplots", rails.at(0), "the layout engine, and the labels that come with it"), - gallery(rails.at(0), "layout"), - - band("Axes and guides", rails.at(1), "extra axes, and guides that take their own gridspec slot"), - grid(columns: (5fr, 5fr), column-gutter: 3.2mm, - gallery(rails.at(1), "axes"), - gallery(rails.at(1), "guides"), - ), - - band("Colour and data", rails.at(2), "the colour engine, and what UltraPlot reads off your data"), - grid(columns: (6fr, 6fr), column-gutter: 3.2mm, - gallery(rails.at(2), "color"), - gallery(rails.at(2), "data"), - ), - - band("Additions without a picture", rails.at(3), "the rest, where a thumbnail would say nothing"), - cells( - panel("Constructor functions", rails.at(3), kind: "better")[ - ``` - uplt.Colormap uplt.Cycle uplt.Norm - uplt.Locator uplt.Formatter - uplt.Scale uplt.Proj - ``` - #note[Every `cmap=`, `cycle=`, `norm=`, `locator=`, `formatter=`, `scale=` and `proj=` argument is passed through the matching constructor, so a string, a number or a list works anywhere matplotlib would want a class instance.] - ], - panel("Registries and loading", rails.at(3), kind: "new")[ - ``` - uplt.register_cmaps(user=True) - uplt.register_cycles() uplt.register_colors() - uplt.register_fonts() - uplt.show_cmaps() uplt.show_cycles() - uplt.show_colors() uplt.show_fonts() - uplt.show_channels('fire') - ``` - #note[Drop files in the config folder and they are registered on import; the `show_` commands print what is available, including the perceptual channels of a map.] - ], - panel("Units and helpers", rails.at(3), kind: "new")[ - ``` - uplt.units('5cm', 'in') - uplt.arange(-3, 3, .5) # endpoint kept - uplt.edges(centres) # centres → edges - uplt.edges2d(grid) - uplt.to_xyz(color, space='hcl') - uplt.set_alpha scale_luminance - shift_hue scale_saturation - ``` - #note[Sizes, spaces, widths and font sizes accept `'55mm'`, `'2cm'`, `'8em'`, `'120pt'` wherever a number would do.] - ], - panel("Figure-level plumbing", rails.at(3), kind: "better")[ - ``` - fig.save('~/figure.pdf') # ~ expanded - uplt.config_inline_backend() - uplt.rc.context({...}) - ax.format(style='ggplot') # per axes - ExternalAxesContainer # host a - # third-party axes - ``` - #note[Tight layout runs before every draw and save, so what you see is what lands in the file, and journal-ready defaults are already set.] - ], - ), -) diff --git a/tools/cheatsheet/docs_index.py b/tools/cheatsheet/docs_index.py index a2a16de1e..4c6402a18 100644 --- a/tools/cheatsheet/docs_index.py +++ b/tools/cheatsheet/docs_index.py @@ -3,7 +3,7 @@ Generate the visual plot-type index for the documentation. Reuses the icon registry the cheatsheet is built from, so the docs page, the -cheatsheet and the poster all show the same thumbnails and cannot drift apart. +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 @@ -163,10 +163,11 @@ def ensure_icons(): present and complete, which is the usual case for a local rebuild. """ source = os.path.join(HERE, "assets", "icons") - have = len([f for f in os.listdir(source)]) if os.path.isdir(source) else 0 - if have >= len(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 (found {have})") + print(f" rendering {len(ICONS)} icons ({len(missing)} PNGs missing)") import icons as icons_module cwd = os.getcwd() @@ -188,7 +189,7 @@ def copy_icons(): wanted = {slug(name) + ".png" for name in ICONS} count = 0 for entry in sorted(os.listdir(source)): - if entry.endswith(".png"): + 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 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/color.py b/tools/cheatsheet/parts/color.py deleted file mode 100644 index 7fd2d134c..000000000 --- a/tools/cheatsheet/parts/color.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -""" -Color figures: the bundled colormaps, the property cycles, and the perceptual -check that tells you whether a map is safe to use. - -The colormap tables come from ``ultraplot.demos.CMAP_TABLE``, the same source -``uplt.show_cmaps()`` draws from, so the sheet cannot drift from what is -actually registered. -""" - -from __future__ import annotations - -import numpy as np - -import ultraplot as uplt -from ultraplot.demos import CMAP_TABLE, CYCLE_TABLE - -import os - -from common import ACCENT, ASSETS, INK, INK_FAINT, save, use_style - -GRADIENT = np.linspace(0, 1, 512)[None, :] - -#: Families to print, and the label to print them under. Everything registered -#: is listed in CMAP_TABLE; these are the families worth a swatch on one page. -FAMILIES = { - "uplt": ("UltraPlot", ["UltraPlot sequential", "UltraPlot diverging"]), - "scientific": ( - "Scientific colour maps (Crameri)", - [ - "Scientific colour maps sequential", - "Scientific colour maps diverging", - "Scientific colour maps cyclic", - ], - ), - "cmocean": ( - "cmOcean", - ["cmOcean sequential", "cmOcean diverging", "cmOcean cyclic"], - ), - "brewer": ( - "ColorBrewer 2.0", - ["ColorBrewer2.0 sequential", "ColorBrewer2.0 diverging"], - ), - "other": ( - "Matplotlib, seaborn, SciVisColor", - [ - "Matplotlib sequential", - "Matplotlib cyclic", - "Seaborn sequential", - "Seaborn diverging", - "Other sequential", - "Other diverging", - "Grayscale", - ], - ), -} - - -def _swatches(names, path, *, ncols=2, labelwidth=0.34, rowmm=3.3): - """ - Draw a table of colormap swatches with their names. - """ - nrows = int(np.ceil(len(names) / ncols)) - pitch = 1 / nrows - fig = uplt.figure(figwidth="86mm", figheight=f"{nrows * rowmm:.1f}mm") - ax = fig.subplot() - ax.format(xticks=[], yticks=[], grid=False, linewidth=0, xlim=(0, 1), ylim=(0, 1)) - ax.patch.set_visible(False) - for index, name in enumerate(names): - column, row = divmod(index, nrows) - left = column / ncols + labelwidth / ncols - width = (1 / ncols) * (1 - labelwidth) * 0.93 - bottom = 1 - (row + 0.85) * pitch - bar = ax.inset_axes( - [left, bottom, width, pitch * 0.66], - transform=ax.transAxes, - zoom=False, - ) - bar = bar[0] if hasattr(bar, "__len__") else bar - bar.imshow(GRADIENT, aspect="auto", cmap=name) - bar.format(xticks=[], yticks=[], grid=False, linewidth=0.3) - ax.text( - left - 0.012, - bottom + pitch * 0.33, - name, - transform=ax.transAxes, - ha="right", - va="center", - fontsize=5.4, - family="monospace", - color=INK, - ) - save(fig, path) - - -def colormaps(): - """ - One swatch table per bundled family. - """ - for key, (_, categories) in FAMILIES.items(): - names = [] - for category in categories: - names.extend(CMAP_TABLE[category]) - # Three columns keeps even the big families to a short block. - ncols = 2 if len(names) <= 12 else 3 - _swatches(names, f"cmaps_{key}.png", ncols=ncols) - - -def cycles(): - """ - The registered property cycles, as their actual colors. - """ - names = [ - name - for category in ( - "Matplotlib stylesheets", - "Other qualitative", - "ColorBrewer2.0 qualitative", - ) - for name in CYCLE_TABLE[category] - ][:11] - fig = uplt.figure(figwidth="86mm", figheight=f"{len(names) * 3.4:.0f}mm") - ax = fig.subplot() - ax.format( - xticks=[], - yticks=[], - grid=False, - linewidth=0, - xlim=(0, 12), - ylim=(0, len(names)), - ) - ax.patch.set_visible(False) - for row, name in enumerate(names): - colors = uplt.get_colors(name) - y = len(names) - row - 1 - for index, color in enumerate(colors[:12]): - ax.bar(index + 0.5, 0.62, bottom=y + 0.2, width=0.92, color=color, lw=0) - ax.text( - -0.35, - y + 0.5, - name, - ha="right", - va="center", - fontsize=5.4, - family="monospace", - color=INK, - ) - save(fig, "cycles.png") - - -def luminance(): - """ - Why perceptual uniformity is checkable: luminance against position. - """ - fig, ax = uplt.subplots(figwidth="58mm", figheight="34mm") - position = np.linspace(0, 1, 128) - for name, color, dash in ( - ("batlow", ACCENT, "-"), - ("fire", "#b6394f", "-"), - ("viridis", "#3c6d56", "-"), - ("jet", INK_FAINT, "--"), - ): - cmap = uplt.Colormap(name) - lum = [uplt.to_xyz(cmap(value), space="hcl")[2] for value in position] - ax.plot(position, lum, color=color, lw=1.2, ls=dash, label=name) - ax.format( - xlim=(0, 1), - ylim=(0, 105), - xticks=[], - yticks=[0, 50, 100], - ylabel="luminance", - labelsize=6, - ticklabelsize=5.5, - grid=True, - ) - ax.legend(loc="lr", ncols=1, frame=False, fontsize=5.6, handlelength=1.3) - save(fig, "luminance.png") - - -def norms(): - """ - The same field under a continuous norm, discrete levels, and a pinned - diverging centre. - """ - state = np.random.default_rng(4) - y, x = np.mgrid[0:40, 0:40] - field = np.sin(x / 6) * np.cos(y / 7) * 4 + state.normal(0, 0.4, (40, 40)) - fig, axs = uplt.subplots(ncols=3, figwidth="86mm", figheight="30mm", wspace="2mm") - axs[0].pcolormesh(field, cmap="roma", discrete=False) - axs[1].pcolormesh(field, cmap="roma", levels=9) - axs[2].pcolormesh(field, cmap="roma", values=uplt.arange(-4, 4, 1), extend="both") - for ax, label in zip(axs, ("discrete=False", "levels=9", "values=arange(-4, 4)")): - ax.format( - xticks=[], - yticks=[], - grid=False, - title=label, - titlesize=5.4, - titleloc="l", - titlepad=1.5, - ) - save(fig, "norms.png") - - -def palette(): - """ - Emit the page palette as Typst data. - - The rails on the page and the swatches in the figures are the same batlow - samples, and writing them from here is what keeps them that way. - """ - from matplotlib.colors import to_hex - - cmap = uplt.Colormap("batlow") - stops = [to_hex(cmap(value)) for value in np.linspace(0, 1, 16)] - rails = [to_hex(cmap(value)) for value in (0.0, 0.22, 0.42, 0.66, 0.88)] - path = os.path.join(ASSETS, "palette.typ") - os.makedirs(ASSETS, exist_ok=True) - with open(path, "w") as handle: - handle.write("// Generated by parts/color.py — do not edit.\n") - handle.write("#let batlow = (\n") - for stop in stops: - handle.write(f' rgb("{stop}"),\n') - handle.write(")\n\n#let rails = (\n") - for rail in rails: - handle.write(f' rgb("{rail}"),\n') - handle.write(")\n") - print(" assets/palette.typ") - - -def main(): - use_style() - palette() - colormaps() - cycles() - luminance() - norms() - - -if __name__ == "__main__": - main() diff --git a/tools/cheatsheet/parts/common.py b/tools/cheatsheet/parts/common.py index 2554b1523..35691753a 100644 --- a/tools/cheatsheet/parts/common.py +++ b/tools/cheatsheet/parts/common.py @@ -3,8 +3,8 @@ Shared style and helpers for the cheatsheet figure parts. Each part script renders one asset with UltraPlot and drops it in ``assets/``. -The Typst document is what assembles them, so nothing here knows about page -layout — only about drawing one small, self-contained figure well. +The draw.io generator assembles them; these helpers only render individual +figures. """ from __future__ import annotations @@ -21,10 +21,6 @@ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets" ) -#: Section rails, sampled along ``batlow`` so the sheet is colored by the thing -#: it documents. Kept in step with the palette in ``cheatsheet.typ``. -RAILS = ["#011959", "#144d62", "#3c6d56", "#828231", "#b0455a"] - INK = "#101720" INK_SOFT = "#47535f" INK_FAINT = "#7e8c99" @@ -33,7 +29,7 @@ RULE = "#c9d2dc" ACCENT = "#3b638c" -#: Assets are rendered at this resolution. Typst scales them down to their box, +#: Assets are rendered at this resolution. The page scales them down to their box, #: so oversampling keeps small strokes crisp in print. DPI = 300 @@ -69,6 +65,11 @@ def save(fig, name, *, dpi=DPI, transparent=False): 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 @@ -115,8 +116,8 @@ def save(fig, name, *, dpi=DPI, transparent=False): ICON_DIVERGING = "roma" #: Stroke and marker sizes that survive being scaled to 10 mm. -ICON_LW = 1.7 -ICON_MS = 11.0 +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 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 index ec8d9b9a5..f7b0d4c81 100644 --- a/tools/cheatsheet/parts/features.py +++ b/tools/cheatsheet/parts/features.py @@ -3,8 +3,7 @@ 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 list follows the sections of ``docs/why.rst``, -so it stays tied to the project's own account of what it is for. +"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. @@ -20,7 +19,7 @@ from common import ACCENT, INK, INK_FAINT, RULE, SUNK, bare, save, use_style -#: Icons are square and rendered large; Typst scales them into the page. +#: Square vector icons are scaled by the draw.io layout. SIZE = "26mm" RNG = np.random.default_rng(51423) @@ -56,49 +55,34 @@ def _mark(ax, text, *, x=0.5, y=0.5, size=6.5, color=ACCENT, **kwargs): def feature_format(fig, axs): """One call sets titles, labels, limits and ticks.""" ax = axs[0] - ax.plot(X, np.sin(X), lw=1.4) + ax.plot(X, np.sin(X), lw=2.5) ax.format( - title="title", - xlabel="x label", - ylabel="y label", + title="Title", + xlabel="X", + ylabel="Y", abc="a.", abcloc="ul", - titlesize=6, - labelsize=6, - abcsize=6, - ticklabelsize=5, + titlesize=10, + labelsize=9, + abcsize=11, + ticklabelsize=6, xlocator=5, - ylocator=1, + ylocator=2, grid=True, ) -def feature_sharing(fig, axs): - """Ticks and labels appear once per row and column, not per panel.""" - for index, ax in enumerate(axs): - ax.plot(X, np.sin(X + index), lw=1) - axs.format( - xlabel="x", - ylabel="y", - labelsize=6, - ticklabelsize=4.5, - xlocator=5, - ylocator=1, - grid=False, - ) - - def feature_spanning(fig, axs): """One label spans the panels it describes.""" for ax in axs: - ax.plot(X, np.sin(X), lw=1) + ax.plot(X, np.sin(X), lw=2.2) axs.format( - xlabel="one spanning label", + xlabel="shared X", ylabel="y", - labelsize=5.5, - ticklabelsize=4.5, + labelsize=9, + ticklabelsize=6, xlocator=5, - ylocator=1, + ylocator=2, grid=False, ) @@ -108,18 +92,18 @@ def feature_edge_labels(fig, axs): for ax in axs: bare(ax, facecolor=SUNK, edgecolor=RULE) axs.format( - toplabels=("col", "col"), - leftlabels=("row", "row"), - toplabelsize=5.5, - leftlabelsize=5.5, + toplabels=("A", "B"), + leftlabels=("I", "II"), + toplabelsize=12, + leftlabelsize=12, ) def feature_abc(fig, axs): - """Panel letters are placed for you, in any of nine slots.""" - for index, ax in enumerate(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", "ur", "ll", "lr")[index], abcsize=7) + ax.format(abc="a.", abcloc="ul", abcsize=10) def feature_corner_titles(fig, axs): @@ -130,9 +114,9 @@ def feature_corner_titles(fig, axs): urtitle="ur", lltitle="ll", lrtitle="lr", - titlesize=5.5, + titlesize=10, ) - _mark(ax, "…title", size=6) + _mark(ax, "title", size=11, weight="bold") def feature_mosaic(fig, axs): @@ -146,8 +130,8 @@ def feature_mosaic(fig, axs): transform=ax.transAxes, ha="center", va="center", - fontsize=7, - color=INK_FAINT, + fontsize=17, fontweight="bold", + color=ACCENT, family="monospace", ) @@ -162,8 +146,8 @@ def feature_units(fig, axs): xycoords="axes fraction", arrowprops={"arrowstyle": "<->", "color": ACCENT, "lw": 0.9}, ) - _mark(ax, "'55mm'", y=0.63) - _mark(ax, "refwidth", y=0.28, size=5.5, color=INK_FAINT) + _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): @@ -178,12 +162,12 @@ def feature_subplotgrid(fig, axs): axs[1].text( 0.5, 0.5, - "axs[:, 1]", + ":, 1", transform=axs[1].transAxes, rotation=90, ha="center", va="center", - fontsize=5.5, + fontsize=10, fontweight="bold", color="w", family="monospace", ) @@ -195,14 +179,14 @@ def feature_subplotgrid(fig, axs): def feature_panels(fig, axs): """Marginal panels take their own gridspec slot.""" ax = axs[0] - data = RNG.normal(size=(400, 2)) - ax.scatter(data[:, 0], data[:, 1], s=2, alpha=0.5, color=ACCENT) + 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=16, + bins=8, color=ACCENT, alpha=0.6, lw=0, @@ -212,60 +196,42 @@ def feature_panels(fig, axs): def feature_inset(fig, axs): - """Insets can draw their own zoom indicator.""" + """A zoomed copy of the same data, connected at the facing corners.""" + from matplotlib.patches import ConnectionPatch + ax = axs[0] - ax.plot(X, np.sin(X) + RNG.normal(0, 0.05, X.size), lw=1, color=ACCENT) + 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, np.sin(X) + RNG.normal(0, 0.05, X.size), lw=1, color=ACCENT) + 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=1.2, color=ACCENT) + ax.plot(X, np.sin(X), lw=2.3, color=ACCENT) dual = ax.dualx(lambda value: value * 2.54) - ax.format(xlabel="in", labelsize=5.5, ticklabelsize=4.5, xlocator=5, grid=False) - dual.format(xlabel="cm", labelsize=5.5, ticklabelsize=4.5, xlocator=10) + 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=[]) -def feature_projections(fig, axs): - """Projections by short name, with cartographic features built in.""" - axs[0].format( - land=True, - ocean=True, - coast=True, - landcolor="gray3", - oceancolor=ACCENT, - coastlinewidth=0.3, - grid=True, - gridalpha=0.35, - labels=False, - ) - - -def feature_taylor(fig, axs): - """Projections that are whole diagram types.""" - ax = axs[0] - 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=9, color="red7") - for (corr, std), color in zip( - ((0.95, 1.15), (0.8, 0.75)), - ("denim", "green7"), - ): - ax.scatter_corr(corr, std, s=24, color=color, zorder=6) - - # --------------------------------------------------------------- guides @@ -290,205 +256,26 @@ 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="3.4mm", length=0.78, ticks=[], frame=True) - bare(ax) - - -def feature_semantic_legend(fig, axs): - """Legends that describe an encoding, with no artist to point at.""" - ax = axs[0] - ax.sizelegend( - [12, 60, 150], - labels=["S", "M", "L"], - loc="c", - ncols=1, - frame=False, - fontsize=6, - markercolor=ACCENT, - ) - bare(ax) - - -def feature_on_the_fly(fig, axs): - """A plotting command can build its own guide.""" - ax = axs[0] - lines = ax.plot( - X, - np.column_stack([np.sin(X), np.cos(X), np.sin(X / 2)]), - lw=1.2, - labels=["a", "b", "c"], - cycle="colorblind", - ) - ax.legend(lines, loc="b", ncols=3, frame=False, fontsize=5.5) + ax.colorbar(mesh, loc="ll", width="1.5mm", length="0.5ax", ticks=[], frame=True) bare(ax) # --------------------------------------------------------------- color -def feature_discrete_norm(fig, axs): - """Levels are discrete by default, so a colorbar reads as steps.""" - ax = axs[0] - mesh = ax.pcolormesh(_field(), cmap="batlow", levels=7) - ax.colorbar(mesh, loc="b", width="3mm", ticks=[], length=0.95) - bare(ax) - - -def feature_centred_levels(fig, axs): - """`values=` pins a diverging midpoint to the real zero.""" - ax = axs[0] - mesh = ax.pcolormesh( - _field() * 4, - cmap="BuRd", - values=uplt.arange(-4, 4, 1), - extend="both", - ) - ax.colorbar(mesh, loc="b", width="3mm", ticks=[0], length=0.95, ticklabelsize=5) - bare(ax) - - -def feature_colormap_surgery(fig, axs): - """Existing colormaps can be truncated, shifted and merged.""" - ax = axs[0] - gradient = np.linspace(0, 1, 256)[None, :] - recipes = ( - ("roma", {}), - ("roma", {"left": 0.35}), - ("roma", {"shift": 90}), - ("roma", {"cut": 0.35}), - ) - for index, (name, kwargs) in enumerate(recipes): - bar = ax.inset_axes( - [0.06, 0.80 - index * 0.24, 0.88, 0.16], - transform=ax.transAxes, - zoom=False, - ) - bar = bar[0] if hasattr(bar, "__len__") else bar - bar.imshow(gradient, aspect="auto", cmap=uplt.Colormap(name, **kwargs)) - bare(bar) - bare(ax, linewidth=0) - - -def feature_perceptual(fig, axs): - """Colormaps can be built from perceptual channel values.""" - ax = axs[0] - gradient = np.linspace(0, 1, 256)[None, :] - recipes = ( - {"h": (0, 120), "s": 80, "l": (20, 90), "space": "hpl"}, - {"h": (200, 320), "s": 60, "l": (25, 95), "space": "hpl"}, - {"h": (0, 360), "c": 50, "l": 70, "space": "hcl", "cyclic": True}, - ) - for index, kwargs in enumerate(recipes): - bar = ax.inset_axes( - [0.06, 0.72 - index * 0.30, 0.88, 0.20], - transform=ax.transAxes, - zoom=False, - ) - bar = bar[0] if hasattr(bar, "__len__") else bar - bar.imshow(gradient, aspect="auto", cmap=uplt.Colormap(**kwargs)) - bare(bar) - bare(ax, linewidth=0) - - -def feature_cycle_from_cmap(fig, axs): - """Any colormap can become a property cycle.""" - ax = axs[0] - values = np.column_stack([np.sin(X + shift / 2) for shift in range(6)]) - ax.plot(X, values, lw=1.3, cycle="Blues", cycle_kw={"left": 0.25}) - bare(ax) - - -def feature_named_colors(fig, axs): - """A registry of named colors from xkcd and open-color.""" - ax = axs[0] - names = ( - "denim", - "rose", - "ocean blue", - "sky blue", - "kelly green", - "orange7", - "violet7", - "gray6", - "red7", - ) - for index, name in enumerate(names): - row, column = divmod(index, 3) - swatch = ax.inset_axes( - [0.08 + column * 0.30, 0.66 - row * 0.28, 0.24, 0.20], - transform=ax.transAxes, - zoom=False, - ) - swatch = swatch[0] if hasattr(swatch, "__len__") else swatch - bare(swatch, facecolor=name, linewidth=0) - bare(ax, linewidth=0) - - # --------------------------------------------------------------- data -def feature_statistics(fig, axs): - """Reductions and spread indicators computed from raw samples.""" - ax = axs[0] - runs = np.sin(X)[None, :] + RNG.normal(0, 0.3, (60, X.size)) - ax.plot(X, runs, mean=True, shadestd=1, fadepctile=(10, 90), lw=1.5) - bare(ax) - - -def feature_dataframe(fig, axs): - """Labels, coordinates and units are read off pandas and xarray.""" - import pandas as pd - - ax = axs[0] - frame = pd.DataFrame( - {"signal (mV)": np.sin(X) + RNG.normal(0, 0.05, X.size)}, - index=pd.Index(X, name="time (s)"), - ) - ax.plot(frame, lw=1.3, color=ACCENT) - ax.format(labelsize=5.5, ticklabelsize=4.5, xlocator=5, ylocator=1, grid=False) - - -def feature_labels(fig, axs): - """Cell and contour labels in a colour that stays legible.""" - axs[0].heatmap( - RNG.uniform(-1, 1, (3, 3)).round(1), - cmap="BuRd", - vmin=-1, - vmax=1, - labels=True, - labels_kw={"fontsize": 5.5}, - ) - bare(axs[0]) - - # --------------------------------------------------------------- output -def feature_rc_context(fig, axs): - """Settings cascade, and apply inside a context.""" - ax = axs[0] - _mark(ax, "uplt.rc", y=0.70, size=6.5, color=INK) - _mark(ax, "fontsize", y=0.47, size=5.5, color=INK_FAINT) - _mark(ax, "tickdir", y=0.30, size=5.5, color=INK_FAINT) - _mark(ax, "cycle", y=0.13, size=5.5, color=INK_FAINT) - bare(ax, facecolor=SUNK, edgecolor=RULE) - - -def feature_animation(fig, axs): - """A faster writer behind matplotlib's animation API.""" - ax = axs[0] - for index, alpha in enumerate((0.2, 0.45, 1.0)): - ax.plot(X, np.sin(X + index * 0.6), lw=1.5, color=ACCENT, alpha=alpha) - bare(ax) - - 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=6.5, color=INK) + 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) @@ -502,291 +289,133 @@ def feature_curvedtext(fig, axs): #: name -> spec. ``draw`` and ``subplots`` make the icon; ``label`` captions it; #: ``kind`` and ``mpl`` classify it; ``group`` places it on the page. FEATURES = { - # ----------------------------------------------------------- layout - "format": { - "draw": feature_format, - "subplots": {}, - "group": "layout", - "label": "format()", - "kind": BETTER, - "mpl": "set_title, set_xlabel, set_xlim, tick_params, …", - }, - "sharing": { - "draw": feature_sharing, - "subplots": {"nrows": 2, "ncols": 2, "share": True}, - "group": "layout", - "label": "share=True", - "kind": BETTER, - "mpl": "sharex=, sharey= — without the label collapsing", - }, - "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()", + 'format': { + 'draw': feature_format, + 'subplots': {}, + 'group': 'layout', + 'label': 'format()', + 'kind': BETTER, + 'mpl': 'set_title, set_xlabel, set_xlim, tick_params, …', }, - # ------------------------------------------------------------- axes - "panel_axes": { - "draw": feature_panels, - "subplots": {}, - "group": "axes", - "label": "panel_axes('r')", - "kind": BETTER, - "mpl": "mpl_toolkits axes_grid1 divider", + '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', }, - "inset_axes": { - "draw": feature_inset, - "subplots": {}, - "group": "axes", - "label": "inset_axes(zoom=True)", - "kind": BETTER, - "mpl": "inset_axes + indicate_inset_zoom", + 'edge_labels': { + 'draw': feature_edge_labels, + 'subplots': {'nrows': 2, 'ncols': 2}, + 'group': 'layout', + 'label': 'toplabels=', + 'kind': NEW, + 'mpl': None, }, - "dualx": { - "draw": feature_dual_axes, - "subplots": {}, - "group": "axes", - "label": "dualx(f)", - "kind": BETTER, - "mpl": "secondary_xaxis", + 'abc_labels': { + 'draw': feature_abc, + 'subplots': {'nrows': 2, 'ncols': 2}, + 'group': 'layout', + 'label': "abc='a.'", + 'kind': NEW, + 'mpl': None, }, - "projections": { - "draw": feature_projections, - "subplots": {"proj": "ortho"}, - "group": "axes", - "label": "proj='ortho'", - "kind": BETTER, - "mpl": "cartopy GeoAxes, wired up by hand", + 'corner_titles': { + 'draw': feature_corner_titles, + 'subplots': {}, + 'group': 'layout', + 'label': 'urtitle=', + 'kind': BETTER, + 'mpl': 'set_title(loc=) — three slots, all above the axes', }, - "taylor_axes": { - "draw": feature_taylor, - "subplots": {"proj": "taylor"}, - "group": "axes", - "label": "proj='taylor'", - "kind": NEW, - "mpl": None, + 'mosaic_array': { + 'draw': feature_mosaic, + 'subplots': {'array': [[1, 1, 2], [3, 4, 2]]}, + 'group': 'layout', + 'label': 'subplots([[…]])', + 'kind': BETTER, + 'mpl': 'subplot_mosaic', }, - # ----------------------------------------------------------- guides - "outer_guides": { - "draw": feature_outer_guide, - "subplots": {}, - "group": "guides", - "label": "colorbar(loc='r')", - "kind": BETTER, - "mpl": "fig.colorbar(ax=) steals space from the axes", + 'physical_units': { + 'draw': feature_units, + 'subplots': {}, + 'group': 'layout', + 'label': "refwidth='55mm'", + 'kind': NEW, + 'mpl': None, }, - "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", + '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()', }, - "inset_guides": { - "draw": feature_inset_guide, - "subplots": {}, - "group": "guides", - "label": "colorbar(loc='ll')", - "kind": BETTER, - "mpl": "colorbar(cax=inset_axes(...))", + 'panel_axes': { + 'draw': feature_panels, + 'subplots': {}, + 'group': 'axes', + 'label': "panel_axes('r')", + 'kind': BETTER, + 'mpl': 'mpl_toolkits axes_grid1 divider', }, - "guides_on_the_fly": { - "draw": feature_on_the_fly, - "subplots": {}, - "group": "guides", - "label": "legend='b'", - "kind": NEW, - "mpl": None, + 'inset_axes': { + 'draw': feature_inset, + 'subplots': {}, + 'group': 'axes', + 'label': 'inset_axes(zoom=True)', + 'kind': BETTER, + 'mpl': 'inset_axes + indicate_inset_zoom', }, - "semantic_legends": { - "draw": feature_semantic_legend, - "subplots": {}, - "group": "guides", - "label": "sizelegend()", - "kind": NEW, - "mpl": None, + 'dualx': { + 'draw': feature_dual_axes, + 'subplots': {}, + 'group': 'axes', + 'label': 'dualx(f)', + 'kind': BETTER, + 'mpl': 'secondary_xaxis', }, - # ------------------------------------------------------------ color - "discrete_levels": { - "draw": feature_discrete_norm, - "subplots": {}, - "group": "color", - "label": "levels=7", - "kind": BETTER, - "mpl": "BoundaryNorm, constructed by hand", + 'outer_guides': { + 'draw': feature_outer_guide, + 'subplots': {}, + 'group': 'guides', + 'label': "colorbar(loc='r')", + 'kind': BETTER, + 'mpl': 'fig.colorbar(ax=) steals space from the axes', }, - "centred_levels": { - "draw": feature_centred_levels, - "subplots": {}, - "group": "color", - "label": "values=arange()", - "kind": BETTER, - "mpl": "TwoSlopeNorm, CenteredNorm", + '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', }, - "colormap_surgery": { - "draw": feature_colormap_surgery, - "subplots": {}, - "group": "color", - "label": "cmap_kw={...}", - "kind": BETTER, - "mpl": "resampled() truncates; no cut or shift", + 'inset_guides': { + 'draw': feature_inset_guide, + 'subplots': {}, + 'group': 'guides', + 'label': "colorbar(loc='ll')", + 'kind': BETTER, + 'mpl': 'colorbar(cax=inset_axes(...))', }, - "perceptual_colormaps": { - "draw": feature_perceptual, - "subplots": {}, - "group": "color", - "label": "Colormap(h=, s=, l=)", - "kind": NEW, - "mpl": None, - }, - "cycle_from_cmap": { - "draw": feature_cycle_from_cmap, - "subplots": {}, - "group": "color", - "label": "cycle='Blues'", - "kind": BETTER, - "mpl": "cycler(color=cmap(...)) by hand", - }, - "named_colors": { - "draw": feature_named_colors, - "subplots": {}, - "group": "color", - "label": "'denim' 'orange7'", - "kind": BETTER, - "mpl": "xkcd: and CSS4 names, prefixed", - }, - # ------------------------------------------------------------- data - "statistics": { - "draw": feature_statistics, - "subplots": {}, - "group": "data", - "label": "mean=True", - "kind": NEW, - "mpl": None, - }, - "pandas_xarray": { - "draw": feature_dataframe, - "subplots": {}, - "group": "data", - "label": "pandas / xarray", - "kind": NEW, - "mpl": None, - }, - "auto_labels": { - "draw": feature_labels, - "subplots": {}, - "group": "data", - "label": "labels=True", - "kind": BETTER, - "mpl": "clabel, for contours only", - }, - "rc_settings": { - "draw": feature_rc_context, - "subplots": {}, - "group": "data", - "label": "uplt.rc", - "kind": BETTER, - "mpl": "rcParams, one setting at a time", - }, - "fast_animation": { - "draw": feature_animation, - "subplots": {}, - "group": "data", - "label": "FuncAnimation", - "kind": BETTER, - "mpl": "same API, slower writer", - }, - "curved_text": { - "draw": feature_curvedtext, - "subplots": {}, - "group": "data", - "label": "curvedtext()", - "kind": NEW, - "mpl": None, + 'curved_text': { + 'draw': feature_curvedtext, + 'subplots': {}, + 'group': 'data', + 'label': 'curvedtext()', + 'kind': NEW, + 'mpl': None, }, } -def write_manifest(): - """ - Emit the registry as Typst data. - - The page builds its galleries from this, so the classification lives in one - place and a new icon reaches the sheet by being added here. - """ - import os - - from common import ASSETS - - path = os.path.join(ASSETS, "features.typ") - with open(path, "w") as handle: - handle.write("// Generated by parts/features.py — do not edit.\n") - handle.write("#let features = (\n") - for name, spec in FEATURES.items(): - mpl = spec["mpl"] - mpl = f'"{mpl}"' if mpl else "none" - handle.write( - f' (name: "{name}", label: "{spec["label"]}", ' - f'kind: "{spec["kind"]}", mpl: {mpl}, ' - f'group: "{spec["group"]}"),\n' - ) - handle.write(")\n") - print(" assets/features.typ") - - 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"]) @@ -796,8 +425,8 @@ def main(): *args, figwidth=SIZE, figheight=SIZE, - hspace="1mm", - wspace="1mm", + hspace="2mm", + wspace="2mm", **kwargs, ) try: @@ -806,8 +435,7 @@ def main(): failures.append(f"{name}: {type(error).__name__}: {error}") uplt.close(fig) continue - save(fig, f"features/{name}.png", dpi=220) - write_manifest() + save(fig, f"features/{name}.svg", dpi=220) if failures: print("feature icon failures:") for failure in failures: diff --git a/tools/cheatsheet/parts/geo.py b/tools/cheatsheet/parts/geo.py deleted file mode 100644 index febbb5d92..000000000 --- a/tools/cheatsheet/parts/geo.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -""" -Map figures: a few projections, and what ``format`` puts on them. - -Needs cartopy. If it is missing the build skips this part rather than failing, -and the page falls back to the code column alone. -""" - -from __future__ import annotations - -import numpy as np - -import ultraplot as uplt - -from common import save, use_style - -#: Projection short names, in the order they appear on the page. -PROJECTIONS = ("robin", "ortho", "npstere", "hammer", "eqearth", "lcc") - - -def _field(): - """ - A smooth global field to drape over the projections. - """ - lon = np.linspace(-180, 180, 145) - lat = np.linspace(-90, 90, 73) - 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 projections(): - """ - One panel per projection, each labelled with the string that makes it. - """ - lon, lat, data = _field() - fig, axs = uplt.subplots( - proj=PROJECTIONS, - ncols=3, - nrows=2, - figwidth="120mm", - figheight="52mm", - wspace="3mm", - hspace="5mm", - ) - for ax, name in zip(axs, PROJECTIONS): - mesh = ax.pcolormesh(lon, lat, data, cmap="roma", levels=11, extend="both") - ax.format( - coast=True, - coastlinewidth=0.3, - title=f"proj='{name}'", - titlesize=5.6, - titlepad=1.5, - grid=True, - gridalpha=0.25, - labels=False, - ) - fig.colorbar( - mesh, - loc="b", - length=0.5, - width="2.5mm", - label="anomaly", - labelsize=5.6, - ticklabelsize=5, - ) - save(fig, "geo_projections.png") - - -def features(): - """ - The cartographic features ``format`` can switch on, and gridline labels. - """ - # Height is left to the layout solver: pinning both dimensions clips the - # gridline labels, which have nowhere to go. - fig, ax = uplt.subplots(proj="cyl", refwidth="72mm") - ax.format( - land=True, - ocean=True, - coast=True, - borders=True, - rivers=True, - landcolor="gray3", - oceancolor="denim", - coastlinewidth=0.3, - lonlim=(-15, 40), - latlim=(33, 62), - lonlabels="b", - latlabels="l", - labelsize=5.5, - gridlabelsize=5.5, - grid=True, - gridalpha=0.3, - title="", - titlesize=5.6, - ) - save(fig, "geo_features.png") - - -def main(): - try: - import cartopy # noqa: F401 - except ImportError: - print(" (cartopy missing, skipping the map figures)") - return - use_style() - projections() - features() - - -if __name__ == "__main__": - main() diff --git a/tools/cheatsheet/parts/guides.py b/tools/cheatsheet/parts/guides.py deleted file mode 100644 index 527e26056..000000000 --- a/tools/cheatsheet/parts/guides.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -""" -Colorbar, legend, and statistical-indicator figures. -""" - -from __future__ import annotations - -import numpy as np - -import ultraplot as uplt - -from common import save, use_style - - -def guides(): - """ - Outer guides on three sides plus an inset legend, all on one axes. - - Each outer guide takes its own gridspec slot, which is why the map itself - stays exactly as wide as it started. - """ - state = np.random.default_rng(51423) - y, x = np.mgrid[0:40, 0:40] - field = np.sin(x / 6) * np.cos(y / 7) + state.normal(0, 0.08, (40, 40)) - - fig, ax = uplt.subplots(figwidth="104mm", figheight="50mm") - mesh = ax.pcolormesh(field, cmap="batlow", levels=9) - lines = ax.plot( - np.linspace(0, 39, 40), - np.column_stack( - [ - 12 + 8 * np.sin(np.linspace(0, 6, 40)), - 26 + 6 * np.cos(np.linspace(0, 6, 40)), - ] - ), - labels=["first", "second"], - lw=1.4, - cycle=("white", "gray2"), - ) - ax.colorbar( - mesh, loc="r", label="loc='r'", width="3mm", labelsize=5.5, ticklabelsize=5 - ) - ax.colorbar( - mesh, - loc="b", - label="loc='b'", - width="3mm", - length=0.7, - labelsize=5.5, - ticklabelsize=5, - ) - ax.legend( - lines, - loc="t", - ncols=2, - frame=False, - fontsize=5.5, - title="loc='t'", - titlefontsize=5.5, - ) - ax.legend( - lines, - loc="ul", - ncols=1, - fontsize=5.2, - title="loc='ul'", - titlefontsize=5.2, - framealpha=0.85, - ) - ax.format(xticks=[], yticks=[], grid=False) - save(fig, "guides.png") - - -def semantic(): - """ - The three legends that describe an encoding rather than an artist. - """ - state = np.random.default_rng(7) - size = state.uniform(8, 120, 90) - value = state.uniform(0, 1, 90) - fig, ax = uplt.subplots(figwidth="104mm", figheight="44mm") - ax.scatter( - state.normal(size=90), - state.normal(size=90), - s=size, - c=value, - cmap="viko", - alpha=0.75, - lw=0, - ) - ax.numlegend( - levels=[0, 0.25, 0.5, 0.75, 1.0], - cmap="viko", - fmt="{:.2f}", - loc="r", - ncols=1, - title="numlegend", - fontsize=5.2, - titlefontsize=5.4, - frame=False, - ) - ax.sizelegend( - [10, 60, 120], - labels=["S", "M", "L"], - loc="b", - ncols=3, - title="sizelegend", - fontsize=5.2, - titlefontsize=5.4, - frame=False, - ) - ax.format(xticks=[], yticks=[], grid=False) - save(fig, "semantic.png") - - -def statistics(): - """ - One dataset of raw samples, four ways of showing its spread. - """ - state = np.random.default_rng(51423) - x = np.linspace(0, 10, 20) - runs = np.sin(x)[None, :] + state.normal(0, 0.35, (120, x.size)) - - fig, axs = uplt.subplots( - ncols=4, - figwidth="120mm", - figheight="30mm", - wspace="3mm", - share=True, - ) - axs[0].plot(x, runs, mean=True, bars=True, barcolor="gray7", barlw=0.6, lw=1.4) - axs[1].plot(x, runs, mean=True, boxes=True, boxcolor="gray7", boxlw=2.0, lw=1.4) - axs[2].plot(x, runs, mean=True, shadestd=1, lw=1.4) - axs[3].plot(x, runs, mean=True, shadestd=1, fadepctile=(5, 95), lw=1.4) - for ax, label in zip( - axs, - ("bars=True", "boxes=True", "shadestd=1", "shade + fadepctile"), - ): - ax.format( - title=label, - titlesize=5.4, - titleloc="l", - titlepad=1.5, - xticks=[], - yticks=[], - grid=False, - ) - save(fig, "statistics.png") - - -def main(): - use_style() - guides() - semantic() - statistics() - - -if __name__ == "__main__": - main() diff --git a/tools/cheatsheet/parts/icons.py b/tools/cheatsheet/parts/icons.py index e74d5e19b..b9e84b6aa 100644 --- a/tools/cheatsheet/parts/icons.py +++ b/tools/cheatsheet/parts/icons.py @@ -100,7 +100,7 @@ def icon_parametric(ax): theta * np.sin(theta), theta, cmap=ICON_SEQUENTIAL, - lw=2.6, + lw=3.6, ) @@ -130,11 +130,11 @@ def icon_bar_negpos(ax): def icon_lollipop(ax): - ax.lollipop(CATEGORIES, VALUES, marker="o", markersize=5, color=ICON_LINE) + ax.lollipop(CATEGORIES, VALUES, marker="o", markersize=9, lw=2, color=ICON_LINE) def icon_lollipoph(ax): - ax.lollipoph(CATEGORIES, VALUES, marker="o", markersize=5, color=ICON_LINE) + ax.lollipoph(CATEGORIES, VALUES, marker="o", markersize=9, lw=2, color=ICON_LINE) def icon_pie(ax): @@ -344,10 +344,12 @@ def icon_curved_quiver(ax): v, color=np.hypot(x, y), cmap=ICON_SEQUENTIAL, - density=7, + density=5, grains=7, - linewidth=0.6, - arrowsize=0.6, + linewidth=1.4, + arrowsize=1.2, + arrow_at_end=True, + scale = 3, ) @@ -361,8 +363,8 @@ def icon_graph(ax): nx.karate_club_graph(), layout="spring", layout_kw={"seed": 4}, - node_kw={"node_size": 18, "node_color": ICON_LINE, "linewidths": 0}, - edge_kw={"alpha": 0.35, "width": 0.6}, + node_kw={"node_size": 35, "node_color": ICON_LINE, "linewidths": 0}, + edge_kw={"alpha": 0.55, "width": 1.1}, label_kw={"font_size": 0}, ) @@ -413,7 +415,7 @@ def icon_radar(ax): index=["one", "two"], ) with without_new_text(ax): - ax.radar_chart(frame, vmin=0, vmax=5, fill=True, marker_size=2) + ax.radar_chart(frame, vmin=0, vmax=5, fill=True, marker_size=5) def icon_phylogeny(ax): @@ -696,50 +698,12 @@ def icon_map_track(ax): } -#: The commands the cheatsheet shows: two rows of fifteen, chosen to span the -#: kinds of plot rather than to be complete. The poster carries all of them. -FEATURED = ( - "plot", - "scatter", - "step", - "stem", - "bar", - "barh", - "area", - "hist", - "box", - "violin", - "parametric", - "lollipop", - "ridgeline", - "beeswarm", - "errorbars", - "pcolormesh", - "contour", - "contourf", - "imshow", - "heatmap", - "hexbin", - "tripcolor", - "quiver", - "streamplot", - "curved_quiver", - "graph", - "sankey", - "chord_diagram", - "radar_chart", - "taylor", - "proj='robin'", - "scatter on a map", -) - - 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 Typst and Sphinx both have to reference. + which belong in a path that draw.io and Sphinx both have to reference. """ name = name.strip() for old, new in ( @@ -757,27 +721,6 @@ def slug(name): return name.strip("-") -def write_manifest(): - """ - Emit the registry as Typst data, so the pages are built from this list. - """ - import os - - path = os.path.join(ASSETS, "icons.typ") - with open(path, "w") as handle: - handle.write("// Generated by parts/icons.py — do not edit.\n") - handle.write("#let commands = (\n") - for name, (_, _, kind, mpl, group) in ICONS.items(): - mpl = f'"{mpl}"' if mpl else "none" - handle.write( - f' (name: "{name.strip()}", file: "{slug(name)}", ' - f'kind: "{kind}", mpl: {mpl}, group: "{group}", ' - f"featured: {str(name in FEATURED).lower()}),\n" - ) - handle.write(")\n") - print(" assets/icons.typ") - - def main(): use_style(fontsize=5) failures = [] @@ -807,8 +750,7 @@ def main(): ax.margins(ICON_MARGIN) else: ax.format(grid=False, labelsize=0, ticklabelsize=0, title="") - save(fig, f"icons/{slug(name)}.png", dpi=220) - write_manifest() + save(fig, f"icons/{slug(name)}.svg", dpi=220) if failures: print("icon failures:") for failure in failures: diff --git a/tools/cheatsheet/parts/layout.py b/tools/cheatsheet/parts/layout.py deleted file mode 100644 index 9e3f42e77..000000000 --- a/tools/cheatsheet/parts/layout.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -""" -Layout figures: axis sharing, mosaic grids, title and panel-letter placement. -""" - -from __future__ import annotations - -import numpy as np - -import ultraplot as uplt - -from common import ACCENT, INK_FAINT, RULE, SUNK, bare, save, use_style - - -def sharing(): - """ - The same four panels with sharing off and on. - - Sharing is a figure-level setting, so this is two figures rather than one: - the Typst page sets them side by side. It opens the sheet because it is the - feature that changes what a multi-panel figure looks like before you have - formatted anything. - """ - state = np.random.default_rng(51423) - x = np.linspace(0, 10, 120) - series = [ - np.sin(x + shift) * scale - for shift, scale in zip(range(4), (1.0, 0.8, 1.2, 0.9)) - ] - - for share, name in ((False, "sharing_off.png"), (True, "sharing_on.png")): - fig, axs = uplt.subplots( - nrows=2, - ncols=2, - figwidth="60mm", - figheight="42mm", - share=share, - span=share, - ) - for index, ax in enumerate(axs): - ax.plot(x, series[index] + state.normal(0, 0.03, x.size), lw=1) - axs.format( - xlim=(0, 10), - ylim=(-1.35, 1.35), - xlabel="time (s)", - ylabel="signal (mV)", - labelsize=5.5, - ticklabelsize=4.8, - grid=False, - ) - save(fig, name) - - -def mosaic(): - """ - A layout array rendered as the grid it produces. - """ - fig, axs = uplt.subplots( - [[1, 1, 2], [3, 4, 2]], - figwidth="60mm", - figheight="32mm", - hspace="2mm", - wspace="2mm", - ) - 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=9, - color=INK_FAINT, - family="monospace", - ) - save(fig, "mosaic.png") - - -def titles(): - """ - Every title and panel-letter slot, filled with its own keyword. - """ - fig, ax = uplt.subplots(figwidth="60mm", figheight="32mm") - bare(ax, facecolor=SUNK, edgecolor=RULE) - ax.format( - abc="a.", - abcloc="ul", - abcsize=7, - title="title", - titlesize=6.5, - ltitle="ltitle", - rtitle="rtitle", - titlepad=2, - ) - for label, (px, py, ha, va) in { - "ultitle": (0.035, 0.93, "left", "top"), - "urtitle": (0.965, 0.93, "right", "top"), - "lltitle": (0.035, 0.07, "left", "bottom"), - "lrtitle": (0.965, 0.07, "right", "bottom"), - }.items(): - ax.text( - px, - py, - label, - transform=ax.transAxes, - ha=ha, - va=va, - fontsize=6, - family="monospace", - color=INK_FAINT, - ) - ax.text( - 0.5, - 0.45, - "abc='a.' abcloc='ul'", - transform=ax.transAxes, - ha="center", - va="center", - fontsize=6, - family="monospace", - color=ACCENT, - ) - save(fig, "titles.png") - - -def panels(): - """ - An axes with outer panels and an inset, showing what each slot costs. - """ - fig, ax = uplt.subplots(figwidth="66mm", figheight="36mm") - state = np.random.default_rng(1) - data = state.normal(size=(300, 2)) - ax.scatter(data[:, 0], data[:, 1], s=3, alpha=0.5, color=ACCENT) - right = ax.panel_axes("r", width="7mm") - top = ax.panel_axes("t", width="7mm") - right.histh(data[:, 1], bins=18, color=ACCENT, alpha=0.6, lw=0) - top.hist(data[:, 0], bins=18, color=ACCENT, alpha=0.6, lw=0) - inset = ax.inset_axes([0.03, 0.03, 0.3, 0.3], zoom=False) - inset.scatter(data[:, 0], data[:, 1], s=1, alpha=0.5, color=ACCENT) - for child in (right, top, inset): - bare(child) - ax.format(xlabel="x", ylabel="y", labelsize=5.5, ticklabelsize=4.8, grid=False) - save(fig, "panels.png") - - -def main(): - use_style() - sharing() - mosaic() - titles() - panels() - - -if __name__ == "__main__": - main() diff --git a/tools/cheatsheet/poster.typ b/tools/cheatsheet/poster.typ deleted file mode 100644 index 78d8fe6a3..000000000 --- a/tools/cheatsheet/poster.typ +++ /dev/null @@ -1,136 +0,0 @@ -// UltraPlot plot-type poster — every command, one picture each. -// -// A companion to cheatsheet.typ, sharing its assets and its palette. Where the -// cheatsheet has to earn its space with code, this is only the small plots: -// bigger, grouped by what the command is for, and captioned with the call. - -#import "assets/palette.typ": batlow, rails -#import "assets/icons.typ": commands - -#let paper = rgb("#f2f4f7") -#let panelbg = rgb("#ffffff") -#let ink = rgb("#0f151d") -#let inksoft = rgb("#4a5663") -#let inkfaint = rgb("#8593a1") -#let rule = rgb("#dbe1e8") -#let accent = rgb("#3b638c") -#let badgecolor = rgb("#a8414f") - -#set page( - paper: "a3", - flipped: false, - margin: (x: 11mm, top: 10mm, bottom: 9mm), - fill: paper, - footer: context [ - #set text(size: 7pt, fill: inkfaint) - #grid(columns: (1fr, auto), align: (left + horizon, right + horizon), - [Every picture is the output of the command it names, drawn by - #raw("tools/cheatsheet/parts/icons.py") · ultraplot.readthedocs.io], - [#counter(page).display()], - ) - ], - footer-descent: 5mm, -) -#set text(font: ("IBM Plex Sans", "DejaVu Sans"), size: 8pt, fill: ink) -#set par(leading: 0.5em) -#show raw: set text(font: ("IBM Plex Mono", "DejaVu Sans Mono"), size: 7.6pt) - -// How far a command is from matplotlib, as a colour. Written on one line: a -// multi-line if/else chain in markup mode does not bind as one expression. -#let tone-of(kind) = if kind == "new" { badgecolor } else if kind == "better" { accent } else { ink } -#let rail-of(kind) = if kind == "new" { badgecolor } else if kind == "better" { accent } else { rule } - -#let exclusive-badge = box( - fill: badgecolor, - inset: (x: 2.6pt, y: 0.9pt), - radius: (bottom-left: 2pt), - text(size: 4.8pt, font: "IBM Plex Sans", fill: white, weight: 600, tracking: 0.05em, "EXCLUSIVE"), -) - -// One thumbnail. An UltraPlot-exclusive command gets a full outline and a -// corner badge; the rest carry a section rail only. -#let tile(entry) = block(width: 100%, breakable: false)[ - #box( - fill: panelbg, - stroke: if entry.kind == "new" { 1.2pt + badgecolor } else if entry.kind == "better" { (top: 2pt + accent, rest: 0.5pt + rule) } else { 0.5pt + rule }, - radius: 2pt, - inset: 0pt, - clip: true, - width: 100%, - )[ - #image("assets/icons/" + entry.file + ".png", width: 100%) - #if entry.kind == "new" { place(top + right, exclusive-badge) } - ] - #v(2.5pt) - #let parts = entry.name.split("(") - #align(center, text(size: 5.9pt, font: "IBM Plex Mono", fill: tone-of(entry.kind), - if parts.len() > 1 [ - #parts.at(0) \ #text(size: 5.4pt)[(#parts.at(1)] - ] else [ - #entry.name - ], - )) - #if entry.mpl != none [ - #v(1pt) - #align(center, text(size: 5pt, fill: inkfaint, style: "italic", entry.mpl)) - ] -] - -#let section(title, blurb, group, columns: 13) = { - let items = commands.filter(entry => entry.group == group) - block(width: 100%, breakable: false, above: 11pt, below: 2pt)[ - #grid(columns: (auto, 1fr), column-gutter: 7pt, align: (left + bottom, left + bottom), - text(size: 11pt, weight: 700, tracking: 0.03em, upper(title)), - text(size: 7pt, fill: inkfaint, blurb), - ) - #v(2.5pt) - #line(length: 100%, stroke: 0.7pt + rule) - #v(5pt) - #grid( - columns: (1fr,) * columns, - column-gutter: 2.4mm, - row-gutter: 3mm, - align: center + top, - ..items.map(tile), - ) - ] -} - -// ------------------------------------------------------------- masthead -#block(width: 100%, below: 8pt)[ - #grid(columns: (auto, 1fr), column-gutter: 14mm, align: (left + bottom, left + bottom), - [ - #text(size: 34pt, weight: 700, tracking: -0.02em, "UltraPlot") - #v(-11pt) - #text(size: 10pt, weight: 600, fill: accent, tracking: 4pt, "PLOT TYPES") - ], - [ - #text(size: 8pt, fill: inksoft)[ - Every command UltraPlot can draw, one picture each — and each picture is - that command's own output, at 26 mm. Assumes `import ultraplot as uplt`, - then `fig, ax = uplt.subplots()`. - ] - #v(4pt) - #text(size: 7.2pt)[ - #box(width: 8pt, height: 2.5pt, fill: rule, baseline: -1pt) #h(2pt) - #text(fill: inksoft)[matplotlib has the command] #h(9pt) - #box(width: 8pt, height: 2.5pt, fill: accent, baseline: -1pt) #h(2pt) - #text(fill: inksoft)[matplotlib can, but you assemble it] #h(9pt) - #box(width: 8pt, height: 2.5pt, fill: badgecolor, baseline: -1pt) #h(2pt) - #text(fill: inksoft)[UltraPlot exclusive] - ] - ], - ) - #v(6pt) - #rect(width: 100%, height: 4pt, stroke: none, radius: 1pt, - fill: gradient.linear(..batlow)) -] - -#section("Relational", "how one variable relates to another", "relational") -#section("Distributions", "the shape and spread of a sample", "distribution") -#section("Fields", "a value over a two-dimensional grid", "field") -#section("Vectors", "direction and magnitude on a grid", "vector") -#section("Networks and diagrams", "relationships that are not a grid", "network") -#section("Maps", "a projection by name, with anything drawn on top in lon/lat", "maps") -#section("What one keyword does", "the same command, changed by a single argument", "keyword") -#section("Swapped axes", "the siblings that put the categories on the other axis", "swapped") 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/cheatsheet/ultraplot_cheatsheet.svg b/tools/cheatsheet/ultraplot_cheatsheet.svg new file mode 100644 index 000000000..9cb4d29a6 --- /dev/null +++ b/tools/cheatsheet/ultraplot_cheatsheet.svg @@ -0,0 +1,2 @@ + +UltraPlotWHAT ULTRAPLOT ADDSA companion to the Matplotlib cheatsheetimport ultraplot as upltfig, ax = uplt.subplots()Layout, guides and plotting conveniences — with room for the details that make a multi-panel figure work.ADVANCED AXIS SHARINGshare=FalseIndependent axesshare='limits'Limits per row / columnshare='all'Limits across all panels'labels': share axis labels • 'limits': also link limits • True: also hide inner tick labelsuplt.subplots(nrows=2, ncols=2, sharex='all', sharey='limits', span=True, sharexticklabels=False)axs.share_labels(axis='both') # centre labels across this gridspanx / spany: spanning labels • sharexlimits / shareylabels: individual overridesSUBPLOTS, LABELS & ANNOTATIONSsubplots([[]])refwidth='55mm'axs[:, 1]span=Trueabc='a.'toplabels=urtitle=axs.format()panel_axes('r')inset_axes()dualx(f)curvedtext()Slice and format a grid in one call; use physical units for axes, panels and spacing.COLORBARS: OUTSIDE, STACKED OR INSETloc='r'repeated loc='b'loc='ll'ax.pcolormesh(Z, levels=7, colorbar='r')ax.colorbar(m, loc='b', width='3mm', length=.7)fig.colorbar(m, loc='b', col=1) # align to a figure columnOuter guides take layout slots; repeated guides queue on the same side.Sides: l r t b • Insets: ul ur ll lr • levels= / values= set colour intervals.Control width in physical units and length as a fraction of the available span.LEGENDS FOR DATA ENCODINGScatlegend()Categoriesax.catlegend(names, colors=colors, markers=markers)sizelegend()Marker areasax.sizelegend( [12, 60, 150],labels=['S','M','L'])numlegend()Numeric keysax.numlegend( levels=[0, .5, 1], cmap='batlow')entrylegend()Custom entriesax.entrylegend([ {'label': 'Model', 'line': True}])ax.plot(Y, labels=names, legend='b') ax.geolegend()Also on fig; add=False returns handles and labels for combined legends.BUNDLED COLORMAPSSequentialfirebatlowthermalDivergingromavikbalanceCyclicphaseromaOvikOcmap='batlow' # any plotuplt.show_cmaps() # all mapsMORE PLOT TYPES & USEFUL VARIANTSbeeswarmridgelinelollipopparametriccurved_quivergraphsankeyribbonchord_diagramradar_chartphylogenytaylorbar(stack=True)bar(negpos=True)area(stack=True)area(negpos=True)Polar: chord_diagram, radar_chart, phylogeny • Taylor: proj='taylor' • Transposed variants: plotx, scatterx, …GEOGRAPHY & MAP FORMATTINGproj='robin' + colour levels + guideproj='merc' + lon/lat formattingfig, ax = uplt.subplots(proj='merc')ax.pcolormesh(lon, lat, Z, cmap='roma', colorbar='b')ax.format(land=True, ocean=True, coast=True, borders=True, rivers=True, lonlabels='b', latlabels='l', lonlim=(-15, 40), latlim=(30, 63))Bundled colormaps: batlow, roma, … • uplt.show_cmaps()ultraplot.readthedocs.io • Built-in conveniences beyond Matplotlib’s core API; many can also be assembled manually in Matplotlib.Companion to matplotlib.org/cheatsheets • All plots rendered with UltraPlot • Editable draw.io text and layout; embedded SVG plots \ No newline at end of file From 2cc619749ee0d6293e56bd1a84d6cd29a9447ad4 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 9 Sep 2026 17:25:02 +1000 Subject: [PATCH 3/3] rm the svg --- tools/cheatsheet/ultraplot_cheatsheet.svg | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 tools/cheatsheet/ultraplot_cheatsheet.svg diff --git a/tools/cheatsheet/ultraplot_cheatsheet.svg b/tools/cheatsheet/ultraplot_cheatsheet.svg deleted file mode 100644 index 9cb4d29a6..000000000 --- a/tools/cheatsheet/ultraplot_cheatsheet.svg +++ /dev/null @@ -1,2 +0,0 @@ - -UltraPlotWHAT ULTRAPLOT ADDSA companion to the Matplotlib cheatsheetimport ultraplot as upltfig, ax = uplt.subplots()Layout, guides and plotting conveniences — with room for the details that make a multi-panel figure work.ADVANCED AXIS SHARINGshare=FalseIndependent axesshare='limits'Limits per row / columnshare='all'Limits across all panels'labels': share axis labels • 'limits': also link limits • True: also hide inner tick labelsuplt.subplots(nrows=2, ncols=2, sharex='all', sharey='limits', span=True, sharexticklabels=False)axs.share_labels(axis='both') # centre labels across this gridspanx / spany: spanning labels • sharexlimits / shareylabels: individual overridesSUBPLOTS, LABELS & ANNOTATIONSsubplots([[]])refwidth='55mm'axs[:, 1]span=Trueabc='a.'toplabels=urtitle=axs.format()panel_axes('r')inset_axes()dualx(f)curvedtext()Slice and format a grid in one call; use physical units for axes, panels and spacing.COLORBARS: OUTSIDE, STACKED OR INSETloc='r'repeated loc='b'loc='ll'ax.pcolormesh(Z, levels=7, colorbar='r')ax.colorbar(m, loc='b', width='3mm', length=.7)fig.colorbar(m, loc='b', col=1) # align to a figure columnOuter guides take layout slots; repeated guides queue on the same side.Sides: l r t b • Insets: ul ur ll lr • levels= / values= set colour intervals.Control width in physical units and length as a fraction of the available span.LEGENDS FOR DATA ENCODINGScatlegend()Categoriesax.catlegend(names, colors=colors, markers=markers)sizelegend()Marker areasax.sizelegend( [12, 60, 150],labels=['S','M','L'])numlegend()Numeric keysax.numlegend( levels=[0, .5, 1], cmap='batlow')entrylegend()Custom entriesax.entrylegend([ {'label': 'Model', 'line': True}])ax.plot(Y, labels=names, legend='b') ax.geolegend()Also on fig; add=False returns handles and labels for combined legends.BUNDLED COLORMAPSSequentialfirebatlowthermalDivergingromavikbalanceCyclicphaseromaOvikOcmap='batlow' # any plotuplt.show_cmaps() # all mapsMORE PLOT TYPES & USEFUL VARIANTSbeeswarmridgelinelollipopparametriccurved_quivergraphsankeyribbonchord_diagramradar_chartphylogenytaylorbar(stack=True)bar(negpos=True)area(stack=True)area(negpos=True)Polar: chord_diagram, radar_chart, phylogeny • Taylor: proj='taylor' • Transposed variants: plotx, scatterx, …GEOGRAPHY & MAP FORMATTINGproj='robin' + colour levels + guideproj='merc' + lon/lat formattingfig, ax = uplt.subplots(proj='merc')ax.pcolormesh(lon, lat, Z, cmap='roma', colorbar='b')ax.format(land=True, ocean=True, coast=True, borders=True, rivers=True, lonlabels='b', latlabels='l', lonlim=(-15, 40), latlim=(30, 63))Bundled colormaps: batlow, roma, … • uplt.show_cmaps()ultraplot.readthedocs.io • Built-in conveniences beyond Matplotlib’s core API; many can also be assembled manually in Matplotlib.Companion to matplotlib.org/cheatsheets • All plots rendered with UltraPlot • Editable draw.io text and layout; embedded SVG plots \ No newline at end of file