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")