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