From 1e262e6c20d7b8b9452b1dfab4d1ef8a547bc79e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 10 Oct 2026 22:24:54 +0200 Subject: [PATCH] Documentation site: Hugo + Hextra, built like go-notebook/docs and go-ndarray/docs The pages describe go-plot/plot v0.1.0, published under /docs/0.1/ (and latest): getting started, the API and why it has matplotlib's shape (the notebook census), every plot type, what is exact against matplotlib and how it is checked, TeX labels, performance, and the differences. The text comes from go-plot/plot's README, docs/design.md and docs/perf.md. Every figure is drawn by go-plot: figures/ is a Go module requiring go-plot v0.1.0, one file per figure, and the plotfig shortcode shows the image and then reads the file that drew it. The new `figures` CI job draws them all again and fails on any difference (byte-identical on darwin/arm64 and amd64), or if figures/go.mod and hugo.yaml name different releases. The external-link job skips the site's own URLs and this repository's files: neither exists before the first deploy / merge. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 252 ++++++++++++++++++ .gitignore | 8 + .gitmodules | 6 + LICENSE | 3 +- README.md | 92 ++++++- assets/css/custom.css | 40 +++ branding | 1 + content/en/_index.md | 55 ++++ content/en/api.md | 145 +++++++++++ content/en/differences.md | 91 +++++++ content/en/exactness.md | 116 +++++++++ content/en/getting-started.md | 98 +++++++ content/en/performance.md | 85 ++++++ content/en/plot-types.md | 100 ++++++++ content/en/tex.md | 57 +++++ figures/bars.go | 26 ++ figures/contour.go | 27 ++ figures/errorbar.go | 31 +++ figures/go.mod | 17 ++ figures/go.sum | 18 ++ figures/grid.go | 30 +++ figures/hist.go | 32 +++ figures/imshow.go | 30 +++ figures/lines.go | 29 +++ figures/loglog.go | 34 +++ figures/main.go | 59 +++++ figures/quickstart.go | 26 ++ figures/scatter.go | 29 +++ figures/tex.go | 35 +++ figures/twinx.go | 28 ++ hugo.yaml | 159 ++++++++++++ i18n/en.yaml | 5 + layouts/_partials/custom/content-begin.html | 30 +++ layouts/_partials/custom/version-select.html | 118 +++++++++ layouts/_partials/favicons.html | 8 + layouts/_shortcodes/plotfig.html | 12 + renovate.json | 4 + scripts/publish-version.sh | 86 +++++++ static/images/figures/bars.svg | 93 +++++++ static/images/figures/contour.svg | 103 ++++++++ static/images/figures/errorbar.svg | 100 ++++++++ static/images/figures/grid.svg | 128 ++++++++++ static/images/figures/hist.svg | 145 +++++++++++ static/images/figures/imshow.svg | 67 +++++ static/images/figures/lines.svg | 233 +++++++++++++++++ static/images/figures/loglog.svg | 123 +++++++++ static/images/figures/quickstart.svg | 256 +++++++++++++++++++ static/images/figures/scatter.svg | 183 +++++++++++++ static/images/figures/tex.svg | 66 +++++ static/images/figures/twinx.svg | 73 ++++++ themes/hextra | 1 + 51 files changed, 3591 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 assets/css/custom.css create mode 160000 branding create mode 100644 content/en/_index.md create mode 100644 content/en/api.md create mode 100644 content/en/differences.md create mode 100644 content/en/exactness.md create mode 100644 content/en/getting-started.md create mode 100644 content/en/performance.md create mode 100644 content/en/plot-types.md create mode 100644 content/en/tex.md create mode 100644 figures/bars.go create mode 100644 figures/contour.go create mode 100644 figures/errorbar.go create mode 100644 figures/go.mod create mode 100644 figures/go.sum create mode 100644 figures/grid.go create mode 100644 figures/hist.go create mode 100644 figures/imshow.go create mode 100644 figures/lines.go create mode 100644 figures/loglog.go create mode 100644 figures/main.go create mode 100644 figures/quickstart.go create mode 100644 figures/scatter.go create mode 100644 figures/tex.go create mode 100644 figures/twinx.go create mode 100644 hugo.yaml create mode 100644 i18n/en.yaml create mode 100644 layouts/_partials/custom/content-begin.html create mode 100644 layouts/_partials/custom/version-select.html create mode 100644 layouts/_partials/favicons.html create mode 100644 layouts/_shortcodes/plotfig.html create mode 100644 renovate.json create mode 100755 scripts/publish-version.sh create mode 100644 static/images/figures/bars.svg create mode 100644 static/images/figures/contour.svg create mode 100644 static/images/figures/errorbar.svg create mode 100644 static/images/figures/grid.svg create mode 100644 static/images/figures/hist.svg create mode 100644 static/images/figures/imshow.svg create mode 100644 static/images/figures/lines.svg create mode 100644 static/images/figures/loglog.svg create mode 100644 static/images/figures/quickstart.svg create mode 100644 static/images/figures/scatter.svg create mode 100644 static/images/figures/tex.svg create mode 100644 static/images/figures/twinx.svg create mode 160000 themes/hextra diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..568060c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,252 @@ +name: docs + +# Every documentation version is a separate Hugo build, published under +# https://go-plot.github.io/docs// on the gh-pages branch, where +# is the minor version of go-plot/plot the pages describe +# (params.plot.version in hugo.yaml: v0.1.2 -> 0.1), or "main" while no +# release is tagged. main is that version's source: each push to main replaces /docs//, and +# /docs/latest/ follows the newest. Pull requests build and check, and publish +# nothing. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# The build needs nothing beyond the source. Write access to the repository +# is granted to the deploy job alone, below, so a pull request build cannot +# push to gh-pages. +permissions: + contents: read + +env: + HUGO_VERSION: "0.167.0" + # sha256 of hugo_${HUGO_VERSION}_linux-amd64.tar.gz, from the release's checksums.txt. + HUGO_SHA256: "4d84519b9f619e6d4c3fb45a50157abeabeb724f859c60605f44c23def6e1169" + DOCS_URL: "https://go-plot.github.io/docs" + +jobs: + build: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # themes/hextra (the tannevaled/hextra fork, pinned to a release tag) + # and branding (go-plot/brand) are submodules. + submodules: recursive + # The whole history: each page's creation and modification come from git. + fetch-depth: 0 + + - name: Install Hugo ${{ env.HUGO_VERSION }} + run: | + curl -fsSL -o /tmp/hugo.tar.gz \ + "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/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz" + echo "${HUGO_SHA256} /tmp/hugo.tar.gz" | sha256sum -c - + sudo tar -xzf /tmp/hugo.tar.gz -C /usr/local/bin hugo + hugo version + + # The version label is plot's minor version, read from hugo.yaml + # ("main" until the first release), and it is the last segment of + # baseURL: the version selector reads it there. + - name: Version + id: version + run: | + plot=$(hugo config --format json | jq -r '.params.plot.version') + if [ "${plot}" = main ]; then + version=main + else + version=$(printf '%s\n' "${plot}" | sed -nE 's/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/\1.\2/p') + fi + if [ -z "${version}" ]; then + echo "::error::params.plot.version in hugo.yaml is \"${plot}\", not vMAJOR.MINOR.PATCH or main" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "plot ${plot}: documentation version ${version}" + + # Each page's creation (first commit, followed across renames) and its + # author, for the page history at its foot (params.page.history). + - name: Page history + run: | + mkdir -p data + sh themes/hextra/scripts/page-history.sh > data/pagehistory.json + + # --panicOnWarning: a deprecated setting or a template warning fails the + # build rather than scrolling by. A broken {{< relref >}} is an error. + - name: Build + env: + HUGO_ENVIRONMENT: production + run: >- + hugo --gc --minify --panicOnWarning --printPathWarnings + --baseURL "${DOCS_URL}/${{ steps.version.outputs.version }}/" + + # Internal links and anchors, offline, with the site laid out under + # /docs// as it is served. + - name: Lay out the site for the link check + run: | + mkdir -p _check/docs + cp -R public "_check/docs/${{ steps.version.outputs.version }}" + + - name: Check links + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline --no-progress --include-fragments + --index-files index.html + --root-dir ${{ github.workspace }}/_check + '_check/docs/**/*.html' + fail: true + + - uses: actions/upload-artifact@v7 + with: + name: site + path: public + retention-days: 7 + + # Every figure on the site is drawn by go-plot from the code the page shows + # (figures/.go, read by the plotfig shortcode). This job draws them + # again and fails if any image differs from the committed one, so a figure + # cannot outlive the code or the go-plot version that drew it. It also + # fails if figures/go.mod requires another go-plot than the release the + # pages say they describe (params.plot.version in hugo.yaml). + figures: + name: every figure is what its code draws + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: figures/go.mod + cache-dependency-path: figures/go.sum + - name: figures/go.mod requires the documented release + run: | + set -euo pipefail + documented=$(sed -nE 's/^ version: "(v[0-9.]+|main)"$/\1/p' hugo.yaml) + required=$(cd figures && go list -m -f '{{.Version}}' github.com/go-plot/plot) + echo "hugo.yaml documents ${documented}; figures/go.mod requires ${required}" + if [ "${documented}" != main ] && [ "${documented}" != "${required}" ]; then + echo "::error::the pages describe go-plot ${documented}, the figures are drawn by ${required}" + exit 1 + fi + - name: Draw every figure + working-directory: figures + run: go run . + - name: The committed images are the ones drawn + run: | + git add -N static/images/figures + if ! git diff --exit-code --stat -- static/images/figures; then + echo "::error::a figure differs from what its code draws: run 'cd figures && go run .' and commit the images" + exit 1 + fi + + # The offline check above proves the site's own links and anchors. An + # https:// URL is text to it: this job fetches each one the pages name. + links: + name: every external link resolves + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Check + run: | + set -uo pipefail + # The character class matters: an extractor stopping only at ) and " + # carries a closing > or < into the request. + urls=$(grep -rhoE 'https?://[^)">< [:space:]]+' content/ README.md | sed -E 's/[.,`]+$//' | sort -u) + test -n "$urls" || { echo "::error::no links found; are the pages still there?"; exit 1; } + echo "checking $(printf '%s\n' "$urls" | wc -l) distinct URLs" + fail=0 + while read -r u; do + case "$u" in + # Renders whatever it is asked for; proves nothing. + *img.shields.io*) continue ;; + # Documented placeholders, not hosts. + *example.com*|*example.org*|*example.net*|*localhost*|*127.0.0.1*|*.invalid*) continue ;; + # This site's own pages: they exist only once this workflow + # has deployed them, and the offline check above already + # proves every link inside the site. + *go-plot.github.io/docs*) continue ;; + # This repository's own files (figures/): on a pull request + # that adds them, they are not on main yet. + *github.com/go-plot/docs/*) continue ;; + esac + # Three attempts: one is a flake detector rather than a link + # checker, and a lane that fails at random gets ignored. + code=000 + for _ in 1 2 3; do + code=$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 25 "$u" 2>/dev/null) || true + code=${code:-000} + case "$code" in 2*|3*) break ;; esac + sleep 3 + done + case "$code" in + 2*|3*) printf ' ok %s %s\n' "$code" "$u" ;; + *) printf ' FAIL %s %s\n' "$code" "$u"; fail=1 ;; + esac + done <<< "$urls" + test "$fail" -eq 0 || { echo "::error::at least one link does not resolve"; exit 1; } + + deploy: + # Only main publishes. A pull request stops after build above: it has no + # business pushing to gh-pages. + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: build + # Never two deploys at once: both would push gh-pages and the second would + # be rejected. cancel-in-progress stays false -- killing a publish half way + # through is worse than making it wait. + concurrency: + group: gh-pages-deploy + cancel-in-progress: false + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # The publishing script, from the commit being published. + - uses: actions/checkout@v7 + with: + path: source + + # The published site. Only the version being built is replaced; every + # other version stays as it is. The first deploy finds no gh-pages + # branch and starts it empty (orphan): nothing else ever creates it. + - uses: actions/checkout@v7 + with: + path: gh-pages + fetch-depth: 0 + + - name: Switch to gh-pages + working-directory: gh-pages + run: | + if git ls-remote --exit-code --heads origin gh-pages >/dev/null; then + git fetch -q origin gh-pages + git checkout -q -B gh-pages FETCH_HEAD + else + echo "no gh-pages branch yet: starting one" + git checkout -q --orphan gh-pages + git rm -rqf . + git clean -fdxq + fi + + - uses: actions/download-artifact@v8 + with: + name: site + path: site + + - name: Publish ${{ needs.build.outputs.version }} + run: source/scripts/publish-version.sh "${{ needs.build.outputs.version }}" site gh-pages + + - name: Push gh-pages + working-directory: gh-pages + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "nothing changed" + exit 0 + fi + git commit -q -m "Deployed ${GITHUB_SHA::7} to ${{ needs.build.outputs.version }} with Hugo ${HUGO_VERSION}" + git push origin HEAD:gh-pages diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cd05fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/public/ +/resources/ +/_check/ +.hugo_build.lock +.DS_Store + +# Written by themes/hextra/scripts/page-history.sh before each build (CI and local). +/data/pagehistory.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4f968b0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "themes/hextra"] + path = themes/hextra + url = https://github.com/tannevaled/hextra.git +[submodule "branding"] + path = branding + url = https://github.com/go-plot/brand.git diff --git a/LICENSE b/LICENSE index 177cd24..c8aae8d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ BSD 3-Clause License -Copyright (c) 2026, go-plot +Copyright (c) 2026, the go-plot/docs authors +All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 43bd7d3..1f09433 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ # docs -Documentation for go-plot (Hugo + Hextra), served at go-plot.github.io/docs/ + +[![docs](https://github.com/go-plot/docs/actions/workflows/docs.yml/badge.svg)](https://github.com/go-plot/docs/actions/workflows/docs.yml) +[![License](https://img.shields.io/badge/license-BSD--3--Clause-1f77b4?style=flat-square)](LICENSE) + +**The go-plot documentation site**, published at +. + +A [Hugo](https://gohugo.io/) site with the [tannevaled/hextra](https://github.com/tannevaled/hextra) +fork of the [Hextra](https://github.com/imfing/hextra) theme, built the same way +as the [go-ndarray documentation](https://github.com/go-ndarray/docs), with +go-plot's branding. It documents +[`go-plot/plot`](https://github.com/go-plot/plot). + +## Versions + +A version of the documentation is **the minor version of +[`go-plot/plot`](https://github.com/go-plot/plot) it describes**, or `main` +while no release is tagged: + +| Source | Published under | +| --- | --- | +| branch `main`, with `params.plot.version` in `hugo.yaml` | `https://go-plot.github.io/docs//`, or `/docs/main/` while it says `main` | +| the newest release (else `main`) | also `https://go-plot.github.io/docs/latest/` | + +`https://go-plot.github.io/docs/` redirects to `latest/`. Each push to `main` +replaces the directory of the version it describes, and nothing else. The +pages describe **v0.1.0**, published under `0.1/`. + +To document a new release, set `params.plot.version` to its tag, require the +same version in `figures/go.mod`, redraw the figures, and update the pages. + +## Figures + +Every figure on the site is drawn by go-plot. `figures/` is a Go module, one +file per figure; `go run .` there writes `static/images/figures/.svg`. +The `plotfig` shortcode (`layouts/_shortcodes/plotfig.html`) shows an image +and then **reads the file that drew it**, so the code on the page is the code +that ran. The `figures` job of the CI draws them all again and fails if an +image differs from the committed one, or if `figures/go.mod` requires another +go-plot than the version the pages describe. + +```sh +cd figures && go run . +``` + +## Layout + +| Path | What | +| --- | --- | +| `content/en/` | the pages; `_index.md` is the home, `weight` orders the sidebar | +| `i18n/en.yaml` | the site's strings, beside the theme's | +| `hugo.yaml` | `baseURL`, the go-plot version described, the navbar (version › theme › search › GitHub), the brand mounts | +| `figures/` | the Go module that draws every figure, one file each | +| `layouts/_shortcodes/plotfig.html` | a figure and the code that drew it | +| `static/images/figures/` | the figures, as `figures/` writes them | +| `layouts/_partials/custom/version-select.html` | the version selector (reads `versions.json`), from go-ndarray/docs | +| `layouts/_partials/custom/content-begin.html` | the outdated-version banner (reads `versions.json`) | +| `layouts/_partials/favicons.html` | the brand's favicons | +| `assets/css/custom.css` | the brand blue as Hextra's primary colour, the table headers, a white card under figures | +| `themes/hextra` | submodule: [tannevaled/hextra](https://github.com/tannevaled/hextra), pinned to a release tag | +| `branding` | submodule: [go-plot/brand](https://github.com/go-plot/brand) (the mark, its PNGs and ICO) | +| `scripts/publish-version.sh` | puts one build into a `gh-pages` checkout and rewrites `versions.json`, `latest` and the root redirect | + +## Working locally + +```sh +git clone --recurse-submodules https://github.com/go-plot/docs.git +cd docs +mkdir -p data && sh themes/hextra/scripts/page-history.sh > data/pagehistory.json +hugo server --baseURL http://localhost:1313/docs/0.1/ +``` + +then open . Hugo 0.146 or later; no Python, +no Node. Go 1.27.2 or later only to redraw the figures. + +## Publication + +`.github/workflows/docs.yml`: + +| Job | When | What | +| --- | --- | --- | +| `build` | pull requests, `main` | builds with `--baseURL …/docs//`, checks internal links and anchors (lychee, offline), uploads the site | +| `links` | pull requests, `main` | fetches every external URL the pages name | +| `figures` | pull requests, `main` | draws every figure again and compares it with the committed image | +| `deploy` | `main` | `scripts/publish-version.sh` into `gh-pages` (started empty on the first deploy), then a commit and a push | + +GitHub Pages serves the `gh-pages` branch. + +## Licence + +BSD-3-Clause. diff --git a/assets/css/custom.css b/assets/css/custom.css new file mode 100644 index 0000000..7ed8118 --- /dev/null +++ b/assets/css/custom.css @@ -0,0 +1,40 @@ +/* go-plot branding on Hextra (github.com/go-plot/brand). + * + * The mark's blue gradient, #5AAEE6 -> #1F77B4 (branding/svg/color/ + * go-plot.svg), which ends on matplotlib's tab:blue (C0). Hextra derives its + * whole primary scale (links, active sidebar entry, buttons) from these three + * variables. + * + * Light theme: #1F77B4 = hsl(205 71% 41%), the gradient's dark end + * (4.82:1 on white). + * Dark theme: #5AAEE6 = hsl(204 74% 63%), its light end. */ +:root { + --primary-hue: 205deg; + --primary-saturation: 71%; + --primary-lightness: 41%; +} +.dark { + --primary-hue: 204deg; + --primary-saturation: 74%; + --primary-lightness: 63%; +} + +/* Tables: their headers stand out in the brand + * colour and their text is a size smaller than the prose. In the dark theme + * the header keeps the deep blue: white on #5AAEE6 cannot be read. */ +.content table { font-size: 0.875em; } +.content table th { background: #1F77B4; color: #fff; } + +/* Figures drawn by go-plot have a white background, as matplotlib's do: in the + * dark theme they sit on a white card rather than being inverted. */ +.content img.figure { background: #fff; border-radius: 0.5rem; padding: 0.5rem; } + +/* The version selector's list hangs from its button's right edge, so that it + * stays on screen at the end of the navbar. */ +.fs-version-select .hextra-nav-menu-items { left: auto !important; right: 0; transform: none !important; } + +/* The outdated-version banner (layouts/_partials/custom/content-begin.html). */ +.nd-outdated { margin-top: 1rem; padding: 0.75rem 1rem; border-radius: 0.5rem; + border: 1px solid #f59e0b; background: #fffbeb; color: #78350f; } +.dark .nd-outdated { background: #451a03; color: #fde68a; border-color: #b45309; } +.nd-outdated a { text-decoration: underline; font-weight: 600; } diff --git a/branding b/branding new file mode 160000 index 0000000..0cd4981 --- /dev/null +++ b/branding @@ -0,0 +1 @@ +Subproject commit 0cd4981d85fe4033b2b168761f63f28a31f2c496 diff --git a/content/en/_index.md b/content/en/_index.md new file mode 100644 index 0000000..ef72cda --- /dev/null +++ b/content/en/_index.md @@ -0,0 +1,55 @@ +--- +title: "go-plot documentation" +linkTitle: "Home" +type: docs +cascade: + type: docs +description: "Pure-Go (CGO=0) plotting for go-ndarray: matplotlib-class figures to SVG and PNG, with real TeX in the labels." +--- + +**go-plot draws matplotlib's figures in pure Go.** Its object model and its +defaults are matplotlib's — figure size, fonts, colour cycle, margins, tick +placement and tick labels — so a figure looks the way a matplotlib user +expects, and the parts that can be equal are **tested equal to matplotlib**: +tick locations and labels on 408 real axes, eight colormaps entry for entry, +and the layout of 30 figures. + +What Go adds: one static binary, `js/wasm`, no Python, no cgo, and TeX labels +set by a real TeX math typesetter, [go-tex/math](https://github.com/go-tex/math). + +A sine curve drawn as a line and as markers, with a TeX label and a legend + +These pages describe [go-plot/plot](https://github.com/go-plot/plot) +**v0.1.0**. Every figure in them was drawn by that version, from the code shown +beside it. + +## Install + +```sh +go get github.com/go-plot/plot@v0.1.0 +``` + +The module needs Go 1.27.2 or later. It is pure Go (`CGO_ENABLED=0`) and builds +for the six 64-bit Linux targets, 386, arm, `js/wasm` and `wasip1/wasm`. + +## The pages + +| Page | What it says | +| --- | --- | +| [Getting started]({{< relref "getting-started" >}}) | a first figure, saved as PNG and SVG, and shown in a notebook | +| [The API]({{< relref "api" >}}) | `Figure`, `Subplots` and `Axes`, options for keyword arguments, data as `any`, and why it has matplotlib's shape | +| [Plot types]({{< relref "plot-types" >}}) | every plot type of v0, each with the code that drew it | +| [Exactness]({{< relref "exactness" >}}) | ticks, colours and layout: what equals matplotlib, and how it is checked | +| [TeX labels]({{< relref "tex" >}}) | math in titles, labels, legends and text, set by a TeX typesetter | +| [Performance]({{< relref "performance" >}}) | go-plot against matplotlib 3.11.2, and how it was measured | +| [Differences]({{< relref "differences" >}}) | what does not equal matplotlib, and what is not in v0 | + +## Built on + +[go-widgets/painter](https://github.com/go-widgets/painter) (compositing), +[go-gfx/gfx](https://github.com/go-gfx/gfx) (anti-aliased coverage), +[go-opentype](https://github.com/go-opentype) (DejaVu Sans, glyph outlines, +kerning), [go-tex/math](https://github.com/go-tex/math) (TeX) and +[go-ndarray](https://github.com/go-ndarray/ndarray) (data). +[go-notebook](https://github.com/go-notebook/notebook) shows a figure through +`Figure.Display`. diff --git a/content/en/api.md b/content/en/api.md new file mode 100644 index 0000000..4df3c12 --- /dev/null +++ b/content/en/api.md @@ -0,0 +1,145 @@ +--- +title: "The API" +linkTitle: "API" +weight: 20 +description: "Figure, Subplots and Axes: matplotlib's object model with Go's argument style, and why it has that shape." +tags: [api] +--- + +## Shaped by what notebooks call + +go-plot is the plotting library of a Go notebook ecosystem built on +[go-ndarray](https://github.com/go-ndarray/ndarray). Its API was chosen from a +measurement, not a preference. + +A census of **339 scientific notebooks** (measured for this ecosystem) ranks +plotting first among the things that keep them from moving to Go, and gives the +share of notebooks using each call: + +| call | notebooks | go-plot | +| --- | --- | --- | +| `pyplot.subplots` | 62 % | `plot.Subplots` | +| `Axes.plot` | 56 % | `Axes.Plot` | +| `pyplot.show` | 52 % | `Figure.Display`, called by the notebook | +| `Axes.set_xlabel` | 47 % | `Axes.SetXLabel` | +| `Axes.legend` | 46 % | `Axes.Legend`, with matplotlib's location names and `"best"` | +| `Axes.set_ylabel` | 45 % | `Axes.SetYLabel` | +| `Axes.set_title` | 37 % | `Axes.SetTitle` | +| `pyplot.figure` | 27 % | `plot.NewFigure` | +| `tight_layout` | 27 % | `Figure.TightLayout` | +| module-level `pyplot.plot`/`xlabel`/`ylabel` | about 22 % | not provided (below) | +| `Axes.set_ylim` | 21 % | `Axes.SetYLim` | +| `Axes.axhline` | 20 % | `Axes.Axhline` | + +Most notebooks write `fig, ax = plt.subplots(); ax.plot(...)`. So go-plot is +the **object-oriented API**, and every one of those operations exists under the +same name. + +**The module-level, state-machine functions are deliberately not provided.** +`plt.plot` and `plt.xlabel` act on a hidden "current" Axes. They are the +minority form, and hidden global state is what a Go notebook kernel running +cells concurrently should not have. + +## Figure, Subplots, Axes + +```go +fig, axs := plot.Subplots(2, 3) // a 2 x 3 grid: axs is a [][]*plot.Axes +ax := axs[1][0] // second row, first column + +fig := plot.NewFigure(plot.FigSize(6.4, 4.8), plot.DPI(100)) +grid := fig.Subplots(1, 2) // the same, on an existing figure +inset := fig.AddAxes(0.6, 0.6, 0.25, 0.25) // left, bottom, width, height, in figure fractions +``` + +- `plot.Subplots(rows, cols, ...FigOption)` returns the figure and a + `[][]*Axes`, always two-dimensional (matplotlib's `squeeze=False`). +- Plot types are `Axes` methods named as in matplotlib: `Plot`, `Scatter`, + `Bar`, `Barh`, `Hist`, `FillBetween`, `ErrorBar`, `Imshow`, `Contour`, + `Contourf`, `Axhline`, `Axvline`, `Text`, `Annotate`, `Legend`, `SetXLim`, + `SetYScale`, `TwinX`... See [plot types]({{< relref "plot-types" >}}). +- `Figure.TightLayout()` is matplotlib's `tight_layout`, ported. It reports + `false`, changing nothing, when the decorations cannot fit, as matplotlib + then warns and does nothing. + +## Keyword arguments become options + +matplotlib's keyword arguments become **functional options**: + +```go +ax.Plot(x, y, plot.Color("C1"), plot.LineStyle("--"), plot.Label("data")) +``` + +is `ax.plot(x, y, color="C1", linestyle="--", label="data")`. Go has no +keywords, and a struct per method would multiply types without adding safety. +Each method documents the options it honours; an option a method does not use +is ignored. + +| option | matplotlib keyword | +| --- | --- | +| `Color`, `Colors`, `FaceColor`, `EdgeColor`, `Alpha` | `color`, a list of colours, `facecolor`, `edgecolor`, `alpha` | +| `LineStyle`, `LineWidth`, `Marker`, `MarkerSize`, `MarkerEdgeWidth` | `linestyle`, `linewidth`, `marker`, `markersize`, `markeredgewidth` | +| `Label`, `ZOrder`, `Clip` | `label`, `zorder`, `clip_on` | +| `Width`, `Bottom`, `Align` | `bar`'s `width`, `bottom`, `align` | +| `Bins`, `BinEdges`, `Density` | `hist`'s `bins` (a count, or edges), `density` | +| `XErr`, `YErr`, `CapSize`, `ErrorColor` | `errorbar`'s `xerr`, `yerr`, `capsize`, `ecolor` | +| `Where` | `fill_between`'s `where` | +| `Sizes`, `CValues` | `scatter`'s `s` and `c` | +| `Cmap`, `Vmin`, `Vmax`, `LogNorm` | `cmap`, `vmin`, `vmax`, `norm=LogNorm()` | +| `Extent`, `Origin`, `Aspect` | `imshow`'s `extent`, `origin`, `aspect` | +| `Levels`, `NLevels` | `contour`'s `levels` (a list, or a count) | +| `FontSize`, `HA`, `VA`, `Rotation`, `Arrow` | `fontsize`, `ha`, `va`, `rotation`, `arrowprops=dict(arrowstyle="->")` | +| `Loc` | `legend`'s `loc` | + +## Data is `any` + +Every data argument accepts `[]float64`, `[]float32`, `[]int`, `[]int32`, +`[]int64`, a scalar, or a `*ndarray.Array` of any of ndarray's thirteen dtypes +(converted with `AsType(Float64)`, flattened in row-major order; 2-D arrays for +`Imshow` and contours, 3-D `(rows, cols, 3|4)` arrays as RGB(A) images, `uint8` +taken as bytes). + +A type that cannot be plotted **panics**, as does a length mismatch. Both are +programming errors, exactly as an index out of range is, and returning an +error from every `Plot` call would make every notebook cell three lines longer +for a failure that is never handled. + +## Mappables and colorbars + +`Imshow`, `Scatter` with `CValues`, `Contour` and `Contourf` return a +`*Mappable` (a colormap and a norm). `Figure.Colorbar(m, ax)` draws it, taking +its space from `ax` exactly as `fig.colorbar(m, ax=ax)` does, and returns the +colorbar's `Axes`, whose label can be set: + +```go +m := ax.Imshow(z, plot.Cmap("viridis")) +fig.Colorbar(m, ax).SetYLabel("height (m)") +``` + +## Output + +| method | writes | +| --- | --- | +| `SavePNG(w io.Writer) error` | an anti-aliased raster | +| `SaveSVG(w io.Writer) error` | vector SVG: paths, clip paths, images embedded as PNG, as matplotlib's SVG backend does with `svg.fonttype = "path"`; text is drawn as outlines, so no font is needed to show it | +| `Display() map[string][]byte` | the notebook contract: `image/svg+xml` and `image/png` | + +## Text is a seam + +`Figure.Text` is a `text.Renderer` (`Layout(s, size)`, `LineMetrics(size)`). +The default sets plain text in DejaVu Sans (matplotlib's default font, from +go-opentype/fonts) and TeX between dollar signs with +[go-tex/math](https://github.com/go-tex/math). Replacing the renderer changes +the fonts of the whole figure. See [TeX labels]({{< relref "tex" >}}). + +## Packages + +| package | what it does | +| --- | --- | +| `plot` | `Figure`, `Axes` and every plot type; layout, autoscaling, legend, colorbar | +| `ticker` | tick locations and labels: AutoLocator (MaxNLocator), ScalarFormatter, LogLocator, LogFormatterSciNotation | +| `colors` | colour parsing, the tab10 cycle, eight colormaps, `Normalize` and `LogNorm` | +| `contour` | marching squares: iso-lines and filled bands on a rectangular grid | +| `render` | the drawing seam: a `Renderer` interface, a raster back-end and an SVG back-end | +| `text` | strings to glyph outlines: DejaVu Sans for text, go-tex/math for TeX | + +The full reference is on [pkg.go.dev](https://pkg.go.dev/github.com/go-plot/plot). diff --git a/content/en/differences.md b/content/en/differences.md new file mode 100644 index 0000000..cf5397b --- /dev/null +++ b/content/en/differences.md @@ -0,0 +1,91 @@ +--- +title: "Differences from matplotlib" +linkTitle: "Differences" +weight: 70 +description: "What does not equal matplotlib, and what is not in v0." +tags: [matplotlib] +--- + +What is equal, and how it is checked, is on [Exactness]({{< relref "exactness" >}}). +This page is the rest. + +## What does not equal matplotlib + +### Text + +Glyphs are filled outlines (go-gfx coverage), not FreeType's hinted +rasterisation with matplotlib's text antialiasing, so **the pixels of text +differ**, and a string's box is its advance where matplotlib's is FreeType's +hinted box (up to a few pixels wider on long labels). Positions are +matplotlib's (`Text._get_layout`: line metrics from the OS/2 table, alignment, +rotation, anchoring), and like Agg the pen is put on a whole pixel. Kerning +comes from DejaVu's GPOS/kern tables through go-opentype. + +### Math text is TeX, not mathtext + +`$...$` is typeset by go-tex/math with STIX Two Math — real TeX layout +(fractions, radicals, big operators, limits, matrices, `\left...\right`), which +matplotlib's mathtext only approximates. The exception is `\mathdefault{...}`, +what matplotlib's tick formatters emit: it is set in DejaVu Sans with +mathtext's own superscript rules, so tick labels match matplotlib's look and +position. See [TeX labels]({{< relref "tex" >}}). + +### Strokes + +Anti-aliasing is go-gfx's, not Agg's; widths, caps, joins and dashes are +matplotlib's. Pixel snapping follows matplotlib's rule (odd widths at pixel +centres, even at edges) for paths made of horizontal and vertical segments. +Strokes of more than 512 segments are drawn in pieces and get round ends (see +[performance]({{< relref "performance" >}})). + +### Images + +Images are resampled nearest-neighbour when enlarged and box-averaged when +reduced; matplotlib's default (`interpolation="auto"`, antialiased resampling +in data space) differs when an image is shown smaller than its resolution, or +less than three times enlarged. + +### Contours + +Contours are marching squares with the saddle resolved by the cell mean, not +contourpy's `serial` algorithm (`corner_mask`, chunking): levels and colours +are matplotlib's, line vertices differ slightly. Filled bands are drawn per +grid cell. + +### Legend "best" + +`"best"` counts the vertices, segments, boxes and scatter points under each of +the nine candidate positions and takes the first with none, else the least; +matplotlib's badness also weights offsets and is computed in display space with +a pad. Usually the same place; not always. + +### Annotate + +`Annotate` draws `arrowstyle="->"` only, shrunk 2 points at both ends. + +### SVG + +The SVG carries the same geometry but not matplotlib's document structure, ids +or metadata. + +### No module-level pyplot + +`plt.plot`, `plt.xlabel` and the other functions acting on a hidden current +Axes are not provided, by design: see [the API]({{< relref "api" >}}). + +## Not in v0 + +- suptitle, and `TightLayout` with explicit pads or `rect`; +- symlog, logit and date axes; +- minor ticks on linear axes (off by default in matplotlib too) and + `AutoMinorLocator`; +- `constrained_layout`; +- `subplots` sharing; +- hatching; +- boxplot, violin, pie, quiver, stem, step, `pcolormesh`, 3-D; +- colorbar `extend` and discrete norms; +- image interpolation other than nearest; +- `Text` bounding boxes; +- legend columns, titles and `bbox_to_anchor`; +- native and browser windows ([go-widgets/window](https://github.com/go-widgets/window)); +- PDF output. diff --git a/content/en/exactness.md b/content/en/exactness.md new file mode 100644 index 0000000..aedc8c6 --- /dev/null +++ b/content/en/exactness.md @@ -0,0 +1,116 @@ +--- +title: "Exactness" +weight: 40 +description: "Ticks, colours and layout: what equals matplotlib, and how each claim is checked." +tags: [matplotlib, tests] +--- + +go-plot's model and defaults are matplotlib's so that a figure can be **checked +against matplotlib's rendering of the same figure**, not only look like it. + +All fixtures are generated by scripts in +[`tools/`](https://github.com/go-plot/plot/tree/main/tools) from matplotlib +itself (3.11.2), committed, `//go:embed`-ded in the tests, and compared +**exactly unless stated**. + +## Ticks + +| what | how it is checked | +| --- | --- | +| Tick locations, labels, offset text on linear and log axes | `ticker/testdata/ticks.json`: **408 axes** of real figures (4 sizes, 37 linear and 14 log ranges, x and y): every location bit for bit, every label and offset character for character, minor log ticks and their labels included. | +| Locator and formatter parameters a default figure never uses | `ticker/testdata/units.json`: 45 MaxNLocator (nbins, steps, integer, symmetric, prune, min_n_ticks), 42 LogLocator (base 2, e, 10; subs list/auto/all; numticks), 72 ScalarFormatter (math text, no offset, fixed power limits, no scientific, ASCII minus, one or no location), 25 LogFormatterSciNotation, `nonsingular`, and `%g`. Exact, except base-e log locations, compared to 1e-15: NumPy's `e**n` and Go's `math.Pow(e, n)` differ in the last bit. | + +## Colours + +| what | how it is checked | +| --- | --- | +| Colormaps | viridis, plasma, inferno, magma, cividis, gray, coolwarm, RdBu (and `_r`): the 256-entry tables are generated from matplotlib, and an independent fixture of **1008 samples per map** (including the ends and out-of-range values) is compared as floats and as bytes; the test also checks that all 256 entries were sampled. | +| Named colours and the cycle | all 148 CSS4, 10 `tab:`, 8 single-letter colours, `C0`–`C9`, grey levels, short and long hex: exact. | + +## Layout + +| what | how it is checked | +| --- | --- | +| Layout | `testdata/layout.json`: 15 kinds of figure at two sizes — line, bar, barh, hist, imshow, imshow + colorbar, scatter, log-log, fill + errorbar, contourf, 2x2 grid, twinx, offsets, axhline/axvline, inverted and singular limits: every Axes position (after aspect and colorbar) and view limits (after margins and sticky edges) to 1e-9 relative, and every tick label and offset text exactly. | +| `TightLayout` | matplotlib's `_auto_adjust_subplotpars`, ported; `testdata/layout.json` holds matplotlib's positions after `fig.tight_layout()` for 6 kinds of figure at two sizes (single Axes, labelled, 2x2 labelled, twinx with two labels, offsets, log-log). Every Axes edge is within **0.005 of the figure** (worst measured 0.0049, 2.4 pixels): the algorithm is the same, the measured width of text is not — matplotlib's is FreeType's hinted box, every glyph a whole number of pixels; go-plot's is the string's advance. | +| Defaults | figure 6.4 x 4.8 in at 100 dpi, subplot parameters, font sizes, tick sizes and pads, line widths, marker sizes, dash patterns (scaled by line width), patch and grid colours, legend geometry, colorbar fraction/pad/aspect: transcribed from matplotlibrc and checked by the rendering comparison below. | + +## Algorithms + +| what | how it is checked | +| --- | --- | +| Histogram binning | NumPy's algorithm (equal bins by arithmetic, corrected against the edges; explicit edges by `searchsorted`). | +| Contour levels | `ContourSet._autolev` (MaxNLocator(n+1, min_n_ticks=1), trimmed). | +| Dense lines | matplotlib's `PathSimplifier` (threshold 1/9 pixel), ported from `path_converters.h`. | + +## Rendering comparison + +`tools/compare` draws twelve reference figures with go-plot and with +matplotlib — same data, size and dpi — and measures each pair with **one +detector applied to both images**: the axes frames (columns and rows of long +dark runs), the tick marks (dark for the three pixels just outside each bottom +and left spine, within the frame), and the pixels of the first cycle colour +inside the frames. Tolerances: frame and ticks within 1 pixel, C0 pixel count +within 10 %, overlap of C0 pixels (each side dilated by 1 pixel) at least 0.90. + +| figure | frames | ticks (go / mpl) | tick error | C0 pixels (go / mpl) | overlap | mean abs diff (0–255) | +|---|---|---|---|---|---|---| +| line | equal | 15 / 15 | 0 px | 1695 / 1736 | 1.000 | 1.01 | +| scatter + colorbar | equal | 11 / 11 | 0 px | – | – | 0.53 | +| bars (1x2) | equal | 26 / 26 | 0 px | 76024 / 76034 | 1.000 | 0.67 | +| hist | equal | 17 / 17 | 0 px | 49515 / 49515 | 1.000 | 0.63 | +| imshow + colorbar | equal | 16 / 16 | 0 px | 38429 / 38683 | 1.000 | 0.77 | +| contourf + contour + colorbar | equal | 16 / 16 | 0 px | – | – | 0.75 | +| log-log with grid | equal | 11 / 11 | 0 px | 770 / 775 | 1.000 | 0.64 | +| errorbar, fill, axhline/axvline, text, annotate | equal | 14 / 14 | 0 px | 1688 / 1558 | 0.994 | 1.03 | +| twinx | equal | 11 / 11 | 0 px | 881 / 891 | 1.000 | 0.94 | +| 2x2 grid, offsets | equal | 41 / 41 | 0 px | 4991 / 4944 | 0.997 | 1.79 | +| int32 ndarray with markers | equal | 14 / 14 | 0 px | 2380 / 2465 | 1.000 | 0.32 | +| 1000² imshow (perf figure) | equal | 10 / 10 | 0 px | 17426 / 17629 | 1.000 | 1.59 | +| **2x2 labelled, `TightLayout`** | **±2 px** | 14 / 14 | **2 px** | 713 / 700 | 0.970 | 6.71 | +| **1e6-point line (perf figure)** | equal | 13 / 13 | 0 px | **12032 / 15405** | 1.000 | 2.07 | + +In twelve of the fourteen, every frame lands on the same pixel rows and +columns and every tick on the same pixel. The images were also looked at, side +by side. + +Two fail the tolerances, and are **reported rather than loosened**: + +- **`TightLayout`**: frames and ticks within 2 pixels, not 1, for the reason + given above — the width of tick labels, which tight layout makes room for, + is measured differently. Side by side the figures are not distinguishable by + eye. +- **The dense line**: go-plot paints 22 % fewer *saturated* C0 pixels. Every + go-plot pixel is within one pixel of a matplotlib one (overlap 1.000); the + difference is how dark the edges of a stroke that crosses itself thousands of + times come out — Agg's scanline accumulates such a stroke differently from + go-gfx's coverage sweep. It is not the simplification: drawn without it, + go-plot's count changes by 0.3 %, while matplotlib's own simplified and + unsimplified renderings differ by 9 %. + +## Negative controls + +A comparison that cannot fail proves nothing. Two deliberately perturbed +go-plot figures are compared with matplotlib's `line`, and **both must fail**: + +- `line_shifted`, the x limits widened by 0.1 data units at the left: tick + error 4 px, overlap 0.436; +- `line_1px`, the view slid by 0.03 data units, 1.35 pixels: tick error 2 px, + overlap 0.779. + +Both are caught; the smaller one by the tick detector and by the overlap, so a +one-pixel slip of the data does not pass. + +## Reproduce + +```sh +git clone https://github.com/go-plot/plot && cd plot +go test ./... # includes the matplotlib fixtures +uv run --with matplotlib --with numpy python tools/gen_ticks.py # regenerate a fixture +cd tools/compare && go run . -out DIR && uv run --with matplotlib --with numpy python figures.py DIR \ + && uv run --with matplotlib --with numpy python compare.py DIR line=line ... +``` + +Write rendered images to a directory outside the repository. + +What does **not** equal matplotlib is on [Differences]({{< relref "differences" >}}). diff --git a/content/en/getting-started.md b/content/en/getting-started.md new file mode 100644 index 0000000..86e2df4 --- /dev/null +++ b/content/en/getting-started.md @@ -0,0 +1,98 @@ +--- +title: "Getting started" +weight: 10 +description: "A first figure, saved as PNG and SVG, and shown in a notebook." +tags: [start] +--- + +## Install + +```sh +go get github.com/go-plot/plot@v0.1.0 +``` + +Go 1.27.2 or later. Nothing else: no Python, no C library, no font to install. +The fonts (DejaVu Sans for text, STIX Two Math for TeX) are compiled in. + +## A first figure + +This is the example of go-plot's README. It plots a sine as a line, then the +same points as markers, labels the x axis with some TeX, adds a legend, and +writes a PNG to standard output: + +```go +package main + +import ( + "math" + "os" + + "github.com/go-plot/plot" +) + +func main() { + x := make([]float64, 200) + y := make([]float64, 200) + for i := range x { + x[i] = float64(i) / 20 + y[i] = math.Sin(x[i]) + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.Plot(x, y, plot.Label(`$\sin t$`)) + ax.Plot(x, y, plot.Marker("o"), plot.LineStyle("None"), plot.MarkerSize(2)) + ax.SetXLabel(`time $t$ (s)`) + ax.Legend() + fig.SavePNG(os.Stdout) +} +``` + +```sh +go run . > sine.png +``` + +The same figure, saved with `SaveSVG` instead: + +{{< plotfig "quickstart" "A sine curve drawn as a line and as markers, with a TeX label and a legend" >}} + +Read it as matplotlib: + +| go-plot | matplotlib | +| --- | --- | +| `fig, axs := plot.Subplots(1, 1)` | `fig, axs = plt.subplots(1, 1, squeeze=False)` | +| `ax := axs[0][0]` | `ax = axs[0][0]` | +| `ax.Plot(x, y, plot.Label(...))` | `ax.plot(x, y, label=...)` | +| `plot.Marker("o"), plot.LineStyle("None"), plot.MarkerSize(2)` | `marker="o", linestyle="None", markersize=2` | +| `ax.SetXLabel(...)` | `ax.set_xlabel(...)` | +| `ax.Legend()` | `ax.legend()` | +| `fig.SavePNG(w)`, `fig.SaveSVG(w)` | `fig.savefig(f, format="png")`, `format="svg"` | + +The figure is matplotlib's default: 6.4 × 4.8 inches at 100 dpi, so 640 × 480 +pixels, with the `tab10` colour cycle (the line is `C0`, the markers `C1`). +`plot.FigSize(w, h)` and `plot.DPI(d)` change it: + +```go +fig, axs := plot.Subplots(2, 2, plot.FigSize(8, 6), plot.DPI(150)) +``` + +## Data + +Data arguments take `[]float64`, `[]float32`, `[]int`, `[]int32`, `[]int64`, +a scalar, or a `*ndarray.Array` of any of [go-ndarray](https://github.com/go-ndarray/ndarray)'s +dtypes. A type that cannot be plotted, or `x` and `y` of different lengths, +**panics**: both are programming errors, as an index out of range is. See +[the API]({{< relref "api#data-is-any" >}}). + +## In a notebook + +`fig.Display()` returns the figure in every format a notebook front end may +show, keyed by MIME type: `image/svg+xml` and `image/png`. It is the display +contract of the [go-notebook](https://github.com/go-notebook/notebook) kernel, +so a cell whose last expression is a figure shows it; it is the notebook's +`plt.show()`. + +## Next + +- [The API]({{< relref "api" >}}): the object model and why it has this shape. +- [Plot types]({{< relref "plot-types" >}}): every plot type, with the code + that drew it. diff --git a/content/en/performance.md b/content/en/performance.md new file mode 100644 index 0000000..a146e69 --- /dev/null +++ b/content/en/performance.md @@ -0,0 +1,85 @@ +--- +title: "Performance" +weight: 60 +description: "go-plot v0 against matplotlib 3.11.2: two figures, PNG and SVG, and how they were measured." +tags: [performance] +--- + +Two figures, each saved as PNG and as SVG, at matplotlib's default size +(6.4 x 4.8 in, 100 dpi): + +- **line**: `Plot` of a 1,000,000-point random walk; +- **imshow**: `Imshow` of a 1000 x 1000 array (`sin(i/37)·cos(j/23)`), viridis. + +## Results + +| case | go-plot median (s) | matplotlib median (s) | matplotlib / go-plot | go-plot bytes | matplotlib bytes | +|---|---|---|---|---|---| +| line, 1e6 points, PNG | 0.055 | 0.065 | 1.2x | 40,600 | 28,055 | +| line, 1e6 points, SVG | 0.019 | 0.053 | 2.8x | 218,801 | 224,082 | +| imshow 1000², PNG | 0.025 | 0.042 | 1.7x | 154,860 | 157,539 | +| imshow 1000², SVG | 0.020 | 0.042 | 2.1x | 207,581 | 198,367 | + +Spread (min / max over the 9 runs): + +| case | go-plot | matplotlib | +|---|---|---| +| line PNG | 0.052 / 0.085 | 0.061 / 0.107 | +| line SVG | 0.018 / 0.024 | 0.050 / 0.063 | +| imshow PNG | 0.023 / 0.034 | 0.040 / 0.058 | +| imshow SVG | 0.019 / 0.034 | 0.041 / 0.049 | + +go-plot's PNGs are larger because it encodes with `png.BestSpeed`; matplotlib +uses zlib's default level. + +## Method + +`tools/compare/perf.py` (driving `tools/compare/perf/main.go`): + +- both sides build the same data, with the same generator, **before** the clock + starts; the clock covers creating the figure, plotting, and writing the file + to memory; +- every measurement is a **fresh process** on both sides (so neither keeps a + warm glyph or path cache the other lacks), and the two are **interleaved** + case by case, run by run, so that load changes on the host hit both alike; +- 9 runs per case; the tables give **medians**, with the spread. + +What is *not* in the numbers: Python's interpreter start and +`import matplotlib.pyplot` (about half a second on this machine), and the Go +process start (a few milliseconds). A notebook keeps both warm, so the +in-process time is the comparable one. + +Host: Apple M4 Max, 16 cores, macOS; Go 1.27.2; Python 3.12.12, matplotlib +3.11.2, NumPy 2.5.3, Agg backend. **The host was shared and loaded**: load +average 22.6 before the run and 21.7 after it, on 16 cores. Absolute times are +therefore pessimistic for both; the interleaving is what keeps the ratio fair. +Measured 2026-10-10. + +## Where the time goes, and what changed to get here + +The first measurement of the line PNG was **0.124 s, half matplotlib's speed**. +Profiling (`go test -bench -cpuprofile`) showed three costs, each now fixed: + +1. the data limits of the million points were recomputed per axis per draw + (0.67 s of 2.2 s sampled): they are now taken once, when the artist is + added; +2. the coverage sweep of go-gfx's rasteriser sorts each scanline's crossings + by insertion, quadratic in the crossings, and a dense polyline has thousands + per scanline: strokes of more than 512 segments are now rasterised in + 512-segment pieces (matplotlib has the same knob, `agg.path.chunksize`, off + by default), whose seams are round joins, so the only visible change is that + the line's own two ends are round rather than projecting; +3. STIX Two Math (for TeX labels) was parsed on every figure: it is now parsed + only when a label contains TeX. + +Like matplotlib, go-plot simplifies dense lines before drawing them +(matplotlib's `PathSimplifier`, ported): the 1e6-point walk is drawn as 11,738 +vertices. That is why the SVG of a million points is about 220 kB on both +sides. + +## Reproduce + +```sh +git clone https://github.com/go-plot/plot && cd plot/tools/compare +uv run --with matplotlib --with numpy python perf.py 9 +``` diff --git a/content/en/plot-types.md b/content/en/plot-types.md new file mode 100644 index 0000000..35090d9 --- /dev/null +++ b/content/en/plot-types.md @@ -0,0 +1,100 @@ +--- +title: "Plot types" +weight: 30 +description: "Every plot type of v0, each with the code that drew it." +tags: [api, gallery] +--- + +Each figure on this page was drawn by go-plot v0.1.0 from the code below it. +The code is not a copy: the page reads it from +[`figures/`](https://github.com/go-plot/docs/tree/main/figures) in this +site's repository, where `go run .` draws every image, and the CI draws them +again and fails if any differs from the one shown. `linspace` is a helper in +[`figures/main.go`](https://github.com/go-plot/docs/blob/main/figures/main.go), +`numpy.linspace`'s equivalent. + +| what | go-plot | matplotlib | +| --- | --- | --- | +| lines and markers | `Plot` (17 markers, 4 line styles) | `ax.plot` | +| points | `Scatter` (size and colour per point, colormaps) | `ax.scatter` | +| bars | `Bar`, `Barh` | `ax.bar`, `ax.barh` | +| histograms | `Hist` | `ax.hist` | +| bands, error bars | `FillBetween`, `ErrorBar` | `ax.fill_between`, `ax.errorbar` | +| reference lines | `Axhline`, `Axvline` | `ax.axhline`, `ax.axvline` | +| images | `Imshow` (2-D data or RGB(A)) | `ax.imshow` | +| contours | `Contour`, `Contourf` | `ax.contour`, `ax.contourf` | +| text | `Text`, `Annotate` | `ax.text`, `ax.annotate` | +| scales, axes | `SetXScale("log")`, `TwinX`, `SetXTicks`, `SetAspect` | `ax.set_xscale`, `ax.twinx`, `ax.set_xticks`, `ax.set_aspect` | +| colour scale | `Figure.Colorbar` | `fig.colorbar` | + +## Lines and markers + +`Plot` draws a line in the next colour of the cycle. `LineStyle` takes `"-"`, +`"--"`, `"-."`, `":"` (and `"None"`); `Marker` takes matplotlib's marker codes. + +{{< plotfig "lines" "Four phase-shifted sines, each with a different line style and marker" >}} + +## Scatter + +With `CValues`, colours come from a colormap and `Scatter` returns a +`*Mappable` for `Colorbar`. `Sizes` gives each point its area in points². + +{{< plotfig "scatter" "An Archimedean spiral of points whose size and colour grow with the angle, with a plasma colorbar" >}} + +## Bars + +`Bar` draws bars 0.8 wide, centred on x, from 0; `Width` and an x offset put +two series side by side. `Barh` is the horizontal form, and `Colors` gives one +colour per bar. + +{{< plotfig "bars" "Grouped vertical bars for five weekdays, and horizontal bars in five colours" >}} + +## Histograms + +`Hist` bins with NumPy's algorithm: 10 equal bins over the data range by +default, `Bins(n)` for n, `BinEdges` for explicit edges, `Density` to +normalise. + +{{< plotfig "hist" "Two overlapping normalised histograms of normal samples and the standard normal density" >}} + +## Error bars, bands and annotations + +{{< plotfig "errorbar" "Points with error bars over a translucent band, a dashed horizontal baseline and an arrow annotation" >}} + +## Images + +`Imshow` colours 2-D data through a colormap (viridis by default), with pixel +centres at integer coordinates and row 0 at the top; a 3-D array of shape +`(rows, cols, 3 or 4)` is shown as RGB(A). Here the data is a go-ndarray +array. + +{{< plotfig "imshow" "A 120 by 160 array of a product of sines shown with viridis and a colorbar" >}} + +## Contours + +`Contour` draws iso-lines and `Contourf` fills the bands between them, at +levels chosen as matplotlib chooses them (`ContourSet._autolev`). x and y give +the column and row coordinates. + +{{< plotfig "contour" "Filled contours of a two-dimensional function in RdBu_r with black contour lines and a colorbar" >}} + +## Log scales + +`SetXScale("log")` and `SetYScale("log")` use matplotlib's `LogLocator` and +`LogFormatterSciNotation`, so the decades and their labels are matplotlib's. + +{{< plotfig "loglog" "n, n log n and n squared on log-log axes with a grid" >}} + +## Two y axes + +`TwinX` returns a new Axes sharing the x axis, with its y axis on the right and +its own colour cycle. + +{{< plotfig "twinx" "Temperature on the left axis and humidity on the right axis over 24 hours" >}} + +## A grid, with TightLayout + +`Subplots(rows, cols)` lays out a grid with matplotlib's subplot parameters; +`TightLayout` then moves the Axes so that titles and labels do not overlap. + +{{< plotfig "grid" "Four damped sines in a 2 by 2 grid with titles and labels, after TightLayout" >}} diff --git a/content/en/tex.md b/content/en/tex.md new file mode 100644 index 0000000..33c138f --- /dev/null +++ b/content/en/tex.md @@ -0,0 +1,57 @@ +--- +title: "TeX labels" +weight: 50 +description: "Math in titles, labels, legends and text, set by a TeX typesetter rather than approximated." +tags: [text, tex] +--- + +Any string go-plot draws — a title, an axis label, a legend entry, a colorbar +label, `Text`, `Annotate` — may contain **TeX math between dollar signs**: + +```go +ax.SetXLabel(`time $t$ (s)`) +ax.Plot(x, y, plot.Label(`$e^{-t/4}\cos 2t$`)) +``` + +Use Go's raw strings (backquotes), so that backslashes reach TeX unescaped. + +## A TeX typesetter, not mathtext + +matplotlib sets `$...$` with **mathtext**, its own approximation of TeX. +go-plot sets it with [go-tex/math](https://github.com/go-tex/math), a real TeX +math typesetter using the OpenType MATH table of **STIX Two Math**: fractions, +radicals, big operators, limits, matrices and `\left...\right` are laid out as +TeX lays them out, which mathtext only approximates. go-tex/math's output is +parsed back into paths and scaled to the label's size, so TeX is drawn as +outlines in both PNG and SVG and needs no font installed. + +{{< plotfig "tex" "The standard normal density with a TeX legend entry, a shaded area between -1 and 1, and an integral written as text" >}} + +## Tick labels keep matplotlib's look + +The one exception is `\mathdefault{...}`, which is what matplotlib's tick +formatters emit (`10^{3}`, `2\times10^{1}`). It is set in **DejaVu Sans** with +mathtext's own superscript rules (shift = max(sup1 · x-height, depth + +x-height/4), scripts at 0.7 size), so tick labels match matplotlib's look and +position. The decades on the [log-log figure]({{< relref "plot-types#log-scales" >}}) +are set this way. + +## Plain text + +Text outside dollar signs is set in **DejaVu Sans**, matplotlib's default +font, from go-opentype/fonts, with kerning from DejaVu's GPOS and kern tables. +Glyphs are drawn as filled outlines, so rotated labels and SVG output need no +font. Positions follow matplotlib's `Text._get_layout` (line metrics from the +OS/2 table, alignment, rotation, anchoring), and like Agg the pen is put on a +whole pixel. How the pixels of text differ from matplotlib's is on +[Differences]({{< relref "differences#text" >}}). + +## Cost + +STIX Two Math is parsed only when a label contains TeX: a figure without any +does not pay for it ([performance]({{< relref "performance" >}})). + +## Replacing the renderer + +`Figure.Text` is a `text.Renderer` with two methods, `Layout(s, size)` and +`LineMetrics(size)`. Setting another one changes the fonts of the whole figure. diff --git a/figures/bars.go b/figures/bars.go new file mode 100644 index 0000000..a131def --- /dev/null +++ b/figures/bars.go @@ -0,0 +1,26 @@ +package main + +import "github.com/go-plot/plot" + +func init() { register("bars", bars) } + +func bars() *plot.Figure { + x := []float64{0, 1, 2, 3, 4} + a := []float64{3, 7, 5, 9, 4} + b := []float64{2, 4, 6, 3, 5} + fig, axs := plot.Subplots(1, 2, plot.FigSize(8, 3.6)) + left, right := axs[0][0], axs[0][1] + left.Bar(x, a, plot.Width(0.4), plot.Label("a")) + shifted := make([]float64, len(x)) + for i, v := range x { + shifted[i] = v + 0.4 + } + left.Bar(shifted, b, plot.Width(0.4), plot.Label("b")) + left.SetXTicks([]float64{0.2, 1.2, 2.2, 3.2, 4.2}, "Mon", "Tue", "Wed", "Thu", "Fri") + left.SetTitle("Bar") + left.Legend() + right.Barh(x, a, plot.Colors("C0", "C1", "C2", "C3", "C4")) + right.SetTitle("Barh, one colour per bar") + fig.TightLayout() + return fig +} diff --git a/figures/contour.go b/figures/contour.go new file mode 100644 index 0000000..3166fe8 --- /dev/null +++ b/figures/contour.go @@ -0,0 +1,27 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("contour", contour) } + +func contour() *plot.Figure { + x, y := linspace(-3, 3, 61), linspace(-2, 2, 41) + z := make([][]float64, len(y)) + for i, v := range y { + z[i] = make([]float64, len(x)) + for j, u := range x { + z[i][j] = (1-u/2+math.Pow(u, 5)+math.Pow(v, 3))*math.Exp(-u*u-v*v) + } + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + m := ax.Contourf(x, y, z, plot.NLevels(10), plot.Cmap("RdBu_r")) + ax.Contour(x, y, z, plot.NLevels(10), plot.Color("k"), plot.LineWidth(0.5)) + fig.Colorbar(m, ax) + ax.SetTitle("Contourf and Contour") + return fig +} diff --git a/figures/errorbar.go b/figures/errorbar.go new file mode 100644 index 0000000..f448e21 --- /dev/null +++ b/figures/errorbar.go @@ -0,0 +1,31 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("errorbar", errorbar) } + +func errorbar() *plot.Figure { + x := linspace(0, 10, 11) + y, e := make([]float64, len(x)), make([]float64, len(x)) + for i, v := range x { + y[i] = 2 + math.Sin(v/2) + e[i] = 0.1 + 0.03*v + } + lo, hi := make([]float64, len(x)), make([]float64, len(x)) + for i := range x { + lo[i], hi[i] = y[i]-2*e[i], y[i]+2*e[i] + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.FillBetween(x, lo, hi, plot.Alpha(0.2), plot.Label(`$\pm 2\sigma$`)) + ax.ErrorBar(x, y, plot.YErr(e), plot.CapSize(3), plot.Marker("o"), plot.Label(`measured $\pm\sigma$`)) + ax.Axhline(2, plot.Color("gray"), plot.LineStyle("--"), plot.LineWidth(1)) + ax.Annotate("baseline", 8, 2, 6.5, 1.3, plot.Arrow()) + ax.SetTitle("ErrorBar, FillBetween, Axhline, Annotate") + ax.Legend(plot.Loc("upper left")) + return fig +} diff --git a/figures/go.mod b/figures/go.mod new file mode 100644 index 0000000..78d3d42 --- /dev/null +++ b/figures/go.mod @@ -0,0 +1,17 @@ +module github.com/go-plot/docs/figures + +go 1.27.2 + +require ( + github.com/go-ndarray/ndarray v0.9.2-0.20261009190427-3217f2808257 + github.com/go-plot/plot v0.1.0 +) + +require ( + github.com/andybalholm/brotli v1.2.6 // indirect + github.com/go-gfx/gfx v0.35.0 // indirect + github.com/go-opentype/fonts v0.12.0 // indirect + github.com/go-opentype/opentype v0.15.0 // indirect + github.com/go-tex/math v0.50.0 // indirect + github.com/go-widgets/painter v0.15.0 // indirect +) diff --git a/figures/go.sum b/figures/go.sum new file mode 100644 index 0000000..8e70d19 --- /dev/null +++ b/figures/go.sum @@ -0,0 +1,18 @@ +github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= +github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/go-gfx/gfx v0.35.0 h1:AaXMxDMxNB1hROxzx+3EoO06KxsrQUvnw/10olmSgVo= +github.com/go-gfx/gfx v0.35.0/go.mod h1:qIZS9u2+VnBk5CX7o4f/fQ5uD8c+mPFTtAPE85s8V90= +github.com/go-ndarray/ndarray v0.9.2-0.20261009190427-3217f2808257 h1:NBqYUKrewACfJJtJJDpI9swDqRV0SzsgFBZbyLt/l00= +github.com/go-ndarray/ndarray v0.9.2-0.20261009190427-3217f2808257/go.mod h1:URXtNNKKw05MdWCqSCOmiw4zE38HQl95PztFSwgeVdk= +github.com/go-opentype/fonts v0.12.0 h1:gjYYkbDZBK3JHpraakgGNnXdkGq8oADerhDYjC22ILQ= +github.com/go-opentype/fonts v0.12.0/go.mod h1:Kiep7ojxxaR2ALFeB/akcFHQyl1gG5OLIobp8EzaZSA= +github.com/go-opentype/opentype v0.15.0 h1:ioE3eCbNZavlk/w073J3uX5FKjqhgOHQw+wZZdJ+gE4= +github.com/go-opentype/opentype v0.15.0/go.mod h1:ZB31jTJl2mhPESXYMuUW+p36UrOabw/2+6mtyh/vNnI= +github.com/go-plot/plot v0.1.0 h1:Zjr7TRE/BGjeqh33lHLBMIsmpf4fHzmgnR5WBzjKUBA= +github.com/go-plot/plot v0.1.0/go.mod h1:XecnrP78pyClISxwrNlSThaYlEqkmOtTs00N1WQRV/o= +github.com/go-tex/math v0.50.0 h1:YHJ3LYzS45zBoPKGBkZW4+LUQkOeHP30YWLsfXGZCpo= +github.com/go-tex/math v0.50.0/go.mod h1:cPm5juijQkhFj1MCByLF8tOfZ+UswAGUPBh+Wy6+vbw= +github.com/go-widgets/painter v0.15.0 h1:0oeaRDTPcIe5/b0wHtYbAUsnReoidkSapr8y+iZJwDk= +github.com/go-widgets/painter v0.15.0/go.mod h1:FSLNdrtzfDF3md+vtaORyS4nrOKLW6eLaCT5BuO9EWg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= diff --git a/figures/grid.go b/figures/grid.go new file mode 100644 index 0000000..1ae16b2 --- /dev/null +++ b/figures/grid.go @@ -0,0 +1,30 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("grid", grid) } + +func grid() *plot.Figure { + x := linspace(0, 1, 200) + fig, axs := plot.Subplots(2, 2) + for r := range 2 { + for c := range 2 { + k := float64(1 + 2*r + c) + y := make([]float64, len(x)) + for i, v := range x { + y[i] = math.Sin(2*math.Pi*k*v) * math.Exp(-k*v) + } + ax := axs[r][c] + ax.Plot(x, y, plot.Color("C"+string(rune('0'+2*r+c)))) + ax.SetTitle(`$k = ` + string(rune('0'+int(k))) + `$`) + ax.SetXLabel("x") + ax.SetYLabel(`$\sin(2\pi k x)\,e^{-kx}$`) + } + } + fig.TightLayout() + return fig +} diff --git a/figures/hist.go b/figures/hist.go new file mode 100644 index 0000000..b741c66 --- /dev/null +++ b/figures/hist.go @@ -0,0 +1,32 @@ +package main + +import ( + "math" + "math/rand/v2" + + "github.com/go-plot/plot" +) + +func init() { register("hist", hist) } + +func hist() *plot.Figure { + rng := rand.New(rand.NewPCG(1, 2)) + a, b := make([]float64, 5000), make([]float64, 5000) + for i := range a { + a[i] = rng.NormFloat64() + b[i] = 1.5 + 0.6*rng.NormFloat64() + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.Hist(a, plot.Bins(40), plot.Density(), plot.Alpha(0.6), plot.Label(`$\mathcal{N}(0, 1)$`)) + ax.Hist(b, plot.Bins(40), plot.Density(), plot.Alpha(0.6), plot.Label(`$\mathcal{N}(1.5, 0.6^2)$`)) + x := linspace(-4, 4, 200) + pdf := make([]float64, len(x)) + for i, v := range x { + pdf[i] = math.Exp(-v*v/2) / math.Sqrt(2*math.Pi) + } + ax.Plot(x, pdf, plot.Color("k"), plot.LineWidth(1), plot.Label("density of the first")) + ax.SetTitle("Hist with 40 bins, normalised") + ax.Legend() + return fig +} diff --git a/figures/imshow.go b/figures/imshow.go new file mode 100644 index 0000000..5ddf2b5 --- /dev/null +++ b/figures/imshow.go @@ -0,0 +1,30 @@ +package main + +import ( + "math" + + "github.com/go-ndarray/ndarray" + "github.com/go-plot/plot" +) + +func init() { register("imshow", imshow) } + +func imshow() *plot.Figure { + const rows, cols = 120, 160 + data := make([]float64, rows*cols) + for i := range rows { + for j := range cols { + data[i*cols+j] = math.Sin(float64(i)/11) * math.Cos(float64(j)/17) + } + } + z, err := ndarray.FromSlice(data, rows, cols) + if err != nil { + panic(err) + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + m := ax.Imshow(z, plot.Cmap("viridis")) + fig.Colorbar(m, ax) + ax.SetTitle(`Imshow of a $120 \times 160$ ndarray`) + return fig +} diff --git a/figures/lines.go b/figures/lines.go new file mode 100644 index 0000000..f46fcda --- /dev/null +++ b/figures/lines.go @@ -0,0 +1,29 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("lines", lines) } + +func lines() *plot.Figure { + x := linspace(0, 2*math.Pi, 40) + styles := []string{"-", "--", "-.", ":"} + markers := []string{"o", "s", "^", "D"} + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + for k := range styles { + y := make([]float64, len(x)) + for i, v := range x { + y[i] = math.Sin(v + float64(k)*math.Pi/4) + } + ax.Plot(x, y, plot.LineStyle(styles[k]), plot.Marker(markers[k]), + plot.MarkerSize(4), plot.Label(`style "`+styles[k]+`", marker "`+markers[k]+`"`)) + } + ax.SetTitle("Line styles and markers, colours from the cycle") + ax.SetYLim(-2, 1.2) + ax.Legend(plot.Loc("lower center"), plot.FontSize(8)) + return fig +} diff --git a/figures/loglog.go b/figures/loglog.go new file mode 100644 index 0000000..58dbb9d --- /dev/null +++ b/figures/loglog.go @@ -0,0 +1,34 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("loglog", loglog) } + +func loglog() *plot.Figure { + n := linspace(1, 6, 26) + x := make([]float64, len(n)) + for i, e := range n { + x[i] = math.Pow(10, e) + } + lin, nlogn, sq := make([]float64, len(x)), make([]float64, len(x)), make([]float64, len(x)) + for i, v := range x { + lin[i], nlogn[i], sq[i] = v, v*math.Log2(v), v*v + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.Plot(x, lin, plot.Label(`$n$`)) + ax.Plot(x, nlogn, plot.Label(`$n \log_2 n$`)) + ax.Plot(x, sq, plot.Label(`$n^2$`)) + ax.SetXScale("log") + ax.SetYScale("log") + ax.Grid(true) + ax.SetXLabel(`$n$`) + ax.SetYLabel("operations") + ax.SetTitle("Log scales: LogLocator and LogFormatterSciNotation") + ax.Legend() + return fig +} diff --git a/figures/main.go b/figures/main.go new file mode 100644 index 0000000..07860f5 --- /dev/null +++ b/figures/main.go @@ -0,0 +1,59 @@ +// Command figures draws every figure the documentation shows, with go-plot +// at the version go.mod names, into ../static/images/figures/.svg. +// Each figure is one file here, and the pages include that file verbatim, so +// the code shown is the code that drew the picture beside it. +// +// cd figures && go run . +// +// The CI runs it and fails if any image differs from the committed one. +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/go-plot/plot" +) + +var figures = map[string]func() *plot.Figure{} + +func register(name string, f func() *plot.Figure) { figures[name] = f } + +func main() { + out := filepath.Join("..", "static", "images", "figures") + if len(os.Args) > 1 { + out = os.Args[1] + } + if err := os.MkdirAll(out, 0o755); err != nil { + panic(err) + } + names := make([]string, 0, len(figures)) + for n := range figures { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + f, err := os.Create(filepath.Join(out, n+".svg")) + if err != nil { + panic(err) + } + if err := figures[n]().SaveSVG(f); err != nil { + panic(err) + } + if err := f.Close(); err != nil { + panic(err) + } + fmt.Println(n + ".svg") + } +} + +// linspace returns n evenly spaced values from lo to hi, as numpy.linspace. +func linspace(lo, hi float64, n int) []float64 { + v := make([]float64, n) + for i := range v { + v[i] = lo + (hi-lo)*float64(i)/float64(n-1) + } + return v +} diff --git a/figures/quickstart.go b/figures/quickstart.go new file mode 100644 index 0000000..57bc1e7 --- /dev/null +++ b/figures/quickstart.go @@ -0,0 +1,26 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("quickstart", quickstart) } + +// The README's example, with SaveSVG in place of SavePNG. +func quickstart() *plot.Figure { + x := make([]float64, 200) + y := make([]float64, 200) + for i := range x { + x[i] = float64(i) / 20 + y[i] = math.Sin(x[i]) + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.Plot(x, y, plot.Label(`$\sin t$`)) + ax.Plot(x, y, plot.Marker("o"), plot.LineStyle("None"), plot.MarkerSize(2)) + ax.SetXLabel(`time $t$ (s)`) + ax.Legend() + return fig +} diff --git a/figures/scatter.go b/figures/scatter.go new file mode 100644 index 0000000..3a81f15 --- /dev/null +++ b/figures/scatter.go @@ -0,0 +1,29 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("scatter", scatter) } + +func scatter() *plot.Figure { + n := 120 + x, y := make([]float64, n), make([]float64, n) + c, s := make([]float64, n), make([]float64, n) + for i := range n { + t := float64(i) / float64(n) * 6 * math.Pi + r := 1 + t/4 + x[i], y[i] = r*math.Cos(t), r*math.Sin(t) + c[i] = t + s[i] = 10 + 4*t + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + m := ax.Scatter(x, y, plot.CValues(c), plot.Sizes(s), plot.Cmap("plasma"), plot.Alpha(0.8)) + ax.SetAspect(1) + ax.SetTitle("Scatter: size and colour per point") + fig.Colorbar(m, ax).SetYLabel(`angle $\theta$ (rad)`) + return fig +} diff --git a/figures/tex.go b/figures/tex.go new file mode 100644 index 0000000..fe783dd --- /dev/null +++ b/figures/tex.go @@ -0,0 +1,35 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("tex", tex) } + +func tex() *plot.Figure { + x := linspace(-4, 4, 400) + y := make([]float64, len(x)) + for i, v := range x { + y[i] = math.Exp(-v*v/2) / math.Sqrt(2*math.Pi) + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.Plot(x, y, plot.Label(`$\varphi(x) = \frac{1}{\sqrt{2\pi}}\, e^{-x^2/2}$`)) + ax.FillBetween(x, y, nil, plot.Where(between(x, -1, 1)), plot.Alpha(0.3)) + ax.Text(1.4, 0.25, `$\int_{-1}^{1} \varphi(x)\,dx \approx 0.683$`, plot.FontSize(12)) + ax.SetTitle(`The standard normal density $\varphi$`) + ax.SetXLabel(`$x$`) + ax.SetYLabel(`$\varphi(x)$`) + ax.Legend(plot.Loc("upper left")) + return fig +} + +func between(x []float64, lo, hi float64) []bool { + m := make([]bool, len(x)) + for i, v := range x { + m[i] = v >= lo && v <= hi + } + return m +} diff --git a/figures/twinx.go b/figures/twinx.go new file mode 100644 index 0000000..de2ea5b --- /dev/null +++ b/figures/twinx.go @@ -0,0 +1,28 @@ +package main + +import ( + "math" + + "github.com/go-plot/plot" +) + +func init() { register("twinx", twinx) } + +func twinx() *plot.Figure { + t := linspace(0, 24, 97) + temp, hum := make([]float64, len(t)), make([]float64, len(t)) + for i, v := range t { + temp[i] = 14 + 6*math.Sin((v-9)*math.Pi/12) + hum[i] = 70 - 15*math.Sin((v-9)*math.Pi/12) + } + fig, axs := plot.Subplots(1, 1) + ax := axs[0][0] + ax.Plot(t, temp, plot.Color("C3")) + ax.SetXLabel("hour") + ax.SetYLabel("temperature (°C)") + right := ax.TwinX() + right.Plot(t, hum, plot.Color("C0"), plot.LineStyle("--")) + right.SetYLabel("relative humidity (%)") + ax.SetTitle("TwinX: two y axes on one x axis") + return fig +} diff --git a/hugo.yaml b/hugo.yaml new file mode 100644 index 0000000..2c47ca1 --- /dev/null +++ b/hugo.yaml @@ -0,0 +1,159 @@ +# go-plot documentation (Hugo + Hextra), built like go-ndarray/docs and +# go-fileshare/docs. +# +# Each documentation version is a separate build under +# https://go-plot.github.io/docs//, where is the minor +# version of go-plot/plot the pages describe (0.1, 0.2, ...), or "main" while no +# release is tagged. The CI sets it through --baseURL, from +# params.plot.version below. This default is the same build. +baseURL: "https://go-plot.github.io/docs/0.1/" +title: "go-plot" +# themes/hextra is the tannevaled/hextra fork, pinned at a release tag: +# upstream Hextra plus opt-in features (partial menu items, combos, +# related-sites menu, page subtitle and history, darkreaderLock). +theme: hextra + +# English is the default language and stays at the root of each version. +# Another language would live under its code, with content//. +defaultContentLanguage: en +defaultContentLanguageInSubdir: false + +languages: + en: + label: "English" + contentDir: content/en + weight: 1 + +enableRobotsTXT: true +# Git history dates every page: "Last updated on" at the bottom of each page. +enableGitInfo: true + +markup: + goldmark: + renderer: + unsafe: true + parser: + attribute: + title: true + highlight: + noClasses: false + +module: + hugoVersion: + extended: false + min: 0.146.0 + # The marks and favicons come from the go-plot brand repository + # (submodule branding/), published under /images/brand/. Only the org mark's + # colour formats are mounted. + mounts: + - source: static + target: static + - source: assets + target: assets + - source: branding/svg/color + target: static/images/brand/svg + files: ["go-plot.svg"] + - source: branding/png/color + target: static/images/brand/png + files: ["*/go-plot.png"] + - source: branding/ico + target: static/images/brand/ico + files: ["go-plot.ico"] + +menu: + main: + # Navbar, left to right: version, theme, search, sources. + - name: Version + weight: 3 + params: + type: partial + partial: custom/version-select.html + - name: Theme + weight: 4 + params: + type: theme-toggle + combo: true + - name: Search + weight: 5 + params: + type: search + - name: GitHub + url: https://github.com/go-plot/plot + weight: 6 + params: + icon: github + +params: + description: "Pure-Go (CGO=0) plotting for go-ndarray: matplotlib-class figures to SVG and PNG, with real TeX in the labels." + + # The go-plot/plot release these pages describe. Its minor version is the + # documentation's version label (v0.1.2 -> 0.1): the CI reads it here. + # The figures/ module requires the same version: the CI checks they agree. + plot: + version: "v0.1.0" + versions: + # The version selector shows "main" with the development icon. + development: "main" + + navbar: + displayTitle: true + displayLogo: true + logo: + # The blue mark reads on both themes; the name is the site title. + path: images/brand/svg/go-plot.svg + dark: images/brand/svg/go-plot.svg + width: 32 + height: 32 + # Related sites, opened from the chevron beside the title (the logo itself + # leads to the documentation's home). + menu: + - name: go-plot + url: https://go-plot.github.io/ + logo: images/brand/svg/go-plot.svg + + theme: + default: system + # The theme toggle is in the navbar (menu above), not at the foot of the sidebar. + displayToggle: false + # Dark Reader would darken the light theme itself; with the lock, the dark + # theme is our own. + darkreaderLock: true + + footer: + enable: true + displayCopyright: true + displayPoweredBy: false + + displayUpdatedDate: true + dateFormat: "January 2, 2006" + + editURL: + enable: true + base: "/go-plot/docs/edit/main/content" + + toc: + displayTags: true + + page: + width: normal + # The front matter description as a subtitle under the title. + displayDescription: true + # "Created on ... by ... · Modified on ... by ..." at the foot of each page: + # creation from data/pagehistory.json, which the build writes with + # themes/hextra/scripts/page-history.sh (it needs the full git history); + # modification from git (enableGitInfo). + history: true + contextMenu: + enable: false + + highlight: + copy: + enable: true + display: hover + + search: + enable: true + type: flexsearch + flexsearch: + index: content + tokenize: forward diff --git a/i18n/en.yaml b/i18n/en.yaml new file mode 100644 index 0000000..306d748 --- /dev/null +++ b/i18n/en.yaml @@ -0,0 +1,5 @@ +# Site strings beside Hextra's own (themes/hextra/i18n/en.yaml). +copyright: "© 2026 the go-plot authors · BSD-3-Clause" +version: "Version" +outdated: "You are reading the documentation of an outdated version, whose text may no longer be accurate." +outdatedLink: "Go to the latest version." diff --git a/layouts/_partials/custom/content-begin.html b/layouts/_partials/custom/content-begin.html new file mode 100644 index 0000000..9dd6dee --- /dev/null +++ b/layouts/_partials/custom/content-begin.html @@ -0,0 +1,30 @@ +{{- /* Outdated-version banner, as the MkDocs site had (overrides/main.html). + + A build cannot know whether a newer version was published after it, so the + banner is hidden and the browser reveals it when /versions.json + gives the alias "latest" to another version than this one. The link goes to + the docs root, which redirects to latest/; it is set with the banner, so + the offline link check (where only this version exists) never sees it. */ -}} +{{- $basePath := strings.TrimSuffix "/" (urls.Parse site.BaseURL).Path -}} +{{- $current := path.Base $basePath -}} +{{- $docsRoot := printf "%s/" (strings.TrimSuffix "/" (path.Dir $basePath)) -}} + + diff --git a/layouts/_partials/custom/version-select.html b/layouts/_partials/custom/version-select.html new file mode 100644 index 0000000..6d08a2a --- /dev/null +++ b/layouts/_partials/custom/version-select.html @@ -0,0 +1,118 @@ +{{- /* Navbar version selector, as in go-fileshare/docs and claimward/docs. + + Each documentation version is a separate build published under + //, where is the minor version of go-plot/plot the + pages describe (0.1, ...), or "main" until the first release, and its + baseURL ends with that version. A build cannot know the versions published + after it, so the server renders only the current one; in the browser the + list is filled from /versions.json, which the deploy job rewrites + in the format mike writes (version, title, aliases, properties). + + The site is ready for several languages: English at the root of a version, + the others under their code (/fr/...). Each option opens, in that + version, the first of these that exists: the current page in the current + language; the same page in English; the home in the current language; the + version's home. A page that a version does not have therefore never ends on + a 404. The language prefix is read from the home's permalink, not guessed + from the URL. + + Built on Hextra's own navbar dropdown (assets/js/core/nav-menu.js), so its + keyboard handling applies. */ -}} +{{- $page := . -}} +{{- $basePath := strings.TrimSuffix "/" (urls.Parse site.BaseURL).Path -}} +{{- $current := path.Base $basePath -}} +{{- $docsRoot := printf "%s/" (strings.TrimSuffix "/" (path.Dir $basePath)) -}} +{{- $versionRoot := printf "%s%s/" $docsRoot $current -}} +{{- $rest := strings.TrimPrefix $versionRoot $page.RelPermalink -}} +{{- /* "fr/" in French, "" in English (the default language, at the root). */ -}} +{{- $langPrefix := strings.TrimPrefix $versionRoot site.Home.RelPermalink -}} +{{- /* The same page in the default language: same file names in every content//. */ -}} +{{- $restDefault := cond (ne $langPrefix "") (strings.TrimPrefix $langPrefix $rest) $rest -}} +{{- $dev := site.Params.versions.development | default "dev" -}} +{{- $itemClass := "hx:text-gray-600 hx:hover:text-gray-800 hx:dark:text-gray-400 hx:dark:hover:text-gray-200 hx:relative hx:cursor-pointer hx:whitespace-nowrap hx:rounded-sm hx:py-1.5 hx:transition-colors hx:ltr:pl-3 hx:ltr:pr-9 hx:rtl:pr-3 hx:rtl:pl-9 hx:flex hx:items-center hx:gap-1 hx:hover:bg-gray-100 hx:dark:hover:bg-neutral-800" -}} +{{- $iconAttrs := `height="1em" class="hx:inline-block"` -}} +
+ + + + + +
+ diff --git a/layouts/_partials/favicons.html b/layouts/_partials/favicons.html new file mode 100644 index 0000000..cca18da --- /dev/null +++ b/layouts/_partials/favicons.html @@ -0,0 +1,8 @@ +{{- /* The favicons are the go-plot brand's org mark (submodule branding/, + mounted under /images/brand/ by hugo.yaml) rather than files copied into + static/. Replaces Hextra's favicons.html, which expects them at the root. */ -}} + + + + + diff --git a/layouts/_shortcodes/plotfig.html b/layouts/_shortcodes/plotfig.html new file mode 100644 index 0000000..b866f8f --- /dev/null +++ b/layouts/_shortcodes/plotfig.html @@ -0,0 +1,12 @@ +{{- /* A figure and the code that drew it. + + {{< plotfig "name" "alt text" >}} shows static/images/figures/.svg, + written by `go run .` in figures/, then figures/.go verbatim. The + page never carries a copy of the code: it is read from the file the image + was drawn by, so the two cannot drift apart. The CI redraws every figure + and fails if one differs from the committed image. */ -}} +{{- $name := .Get 0 -}} +{{- $alt := .Get 1 | default $name -}} +{{- $src := readFile (printf "figures/%s.go" $name) -}} +{{ $alt }} +{{ printf "```go\n%s```\n" $src | .Page.RenderString (dict "display" "block") }} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..2d29aff --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>go-plot/.github"] +} diff --git a/scripts/publish-version.sh b/scripts/publish-version.sh new file mode 100755 index 0000000..1917086 --- /dev/null +++ b/scripts/publish-version.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Publish one documentation build into a checkout of the gh-pages branch. +# +# scripts/publish-version.sh +# +# is the minor version of go-plot/plot the build describes, MAJOR.MINOR +# (0.1), or "main" while no release is tagged. The build replaces +# // and nothing else: every other version already +# published is kept as it is. Then, from the directories present: +# +# versions.json rewritten in mike's format ({version, title, aliases}), +# newest release first, "main" last; the version selector +# reads it; +# latest a symbolic link to the newest release, or to "main" when +# no release has been published yet; +# index.html the root, redirecting to latest/ (as mike made it). +# +# Needs jq and rsync. Commits nothing: the workflow does. +set -eu + +die() { echo "publish-version.sh: $*" >&2; exit 1; } + +[ $# -eq 3 ] || die "usage: $0 " +version=$1 +site=$2 +pages=$3 +label='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + +[ "$version" = main ] || printf '%s\n' "$version" | grep -Eq "$label" || die "version must be MAJOR.MINOR (0.1) or main, not \"$version\"" +[ -f "$site/index.html" ] || die "$site/index.html is missing: build the site first" +[ -d "$pages" ] || die "$pages is not a directory" +command -v jq >/dev/null || die "jq is required" +command -v rsync >/dev/null || die "rsync is required" + +# The version's own directory, replaced whole. A real directory: if it was a +# link (an alias), the link goes, not what it pointed to. +[ -L "$pages/$version" ] && rm "$pages/$version" +mkdir -p "$pages/$version" +rsync -a --delete "$site/" "$pages/$version/" + +# The versions present, newest first (0.10 before 0.9: compared as numbers), +# then "main". latest is the newest release, else main. +versions=$(cd "$pages" && for d in *; do + [ -d "$d" ] && [ ! -L "$d" ] && printf '%s\n' "$d" | grep -Eq "$label" && printf '%s\n' "$d" +done | sort -t. -k1,1nr -k2,2nr) +[ -d "$pages/main" ] && [ ! -L "$pages/main" ] && versions=$(printf '%s\n%s\n' "$versions" main | sed '/^$/d') +latest=$(printf '%s\n' "$versions" | sed -n 1p) + +# versions.json: the newest carries the alias "latest". +for v in $versions; do + if [ "$v" = "$latest" ]; then + jq -n --arg v "$v" '{version: $v, title: $v, aliases: ["latest"]}' + elif [ "$v" = main ]; then + jq -n --arg v "$v" '{version: $v, title: $v, aliases: [], properties: {development: true}}' + else + jq -n --arg v "$v" '{version: $v, title: $v, aliases: []}' + fi +done | jq -s . >"$pages/versions.json.tmp" +mv "$pages/versions.json.tmp" "$pages/versions.json" + +# latest -> the newest version, and the root redirect. +rm -rf "$pages/latest" +ln -s "$latest" "$pages/latest" +cat >"$pages/index.html" <<'EOF' + + + + + Redirecting + + + + + Redirecting to latest/... + + +EOF +touch "$pages/.nojekyll" + +echo "published $version; latest is $latest; versions: $(jq -r '[.[].version] | join(", ")' "$pages/versions.json")" diff --git a/static/images/figures/bars.svg b/static/images/figures/bars.svg new file mode 100644 index 0000000..755dcab --- /dev/null +++ b/static/images/figures/bars.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/contour.svg b/static/images/figures/contour.svg new file mode 100644 index 0000000..73494fe --- /dev/null +++ b/static/images/figures/contour.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/errorbar.svg b/static/images/figures/errorbar.svg new file mode 100644 index 0000000..e4364ae --- /dev/null +++ b/static/images/figures/errorbar.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/grid.svg b/static/images/figures/grid.svg new file mode 100644 index 0000000..fa374b1 --- /dev/null +++ b/static/images/figures/grid.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/hist.svg b/static/images/figures/hist.svg new file mode 100644 index 0000000..efe6733 --- /dev/null +++ b/static/images/figures/hist.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/imshow.svg b/static/images/figures/imshow.svg new file mode 100644 index 0000000..cee29fd --- /dev/null +++ b/static/images/figures/imshow.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/lines.svg b/static/images/figures/lines.svg new file mode 100644 index 0000000..3757d46 --- /dev/null +++ b/static/images/figures/lines.svg @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/loglog.svg b/static/images/figures/loglog.svg new file mode 100644 index 0000000..8376964 --- /dev/null +++ b/static/images/figures/loglog.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/quickstart.svg b/static/images/figures/quickstart.svg new file mode 100644 index 0000000..c656516 --- /dev/null +++ b/static/images/figures/quickstart.svg @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/scatter.svg b/static/images/figures/scatter.svg new file mode 100644 index 0000000..092f20f --- /dev/null +++ b/static/images/figures/scatter.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/tex.svg b/static/images/figures/tex.svg new file mode 100644 index 0000000..a964515 --- /dev/null +++ b/static/images/figures/tex.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/figures/twinx.svg b/static/images/figures/twinx.svg new file mode 100644 index 0000000..bed3a0a --- /dev/null +++ b/static/images/figures/twinx.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/hextra b/themes/hextra new file mode 160000 index 0000000..1d8daa4 --- /dev/null +++ b/themes/hextra @@ -0,0 +1 @@ +Subproject commit 1d8daa42d0406250fa00b7e803db2ab104a7fcab