--- name: gribouille description: > Generate correct, idiomatic gribouille charts for Typst. Trigger on: "chart", "plot", "scatter", "histogram", "bar chart", "boxplot", "heatmap", "time series", or any request to visualise data in Typst using a Grammar of Graphics approach. version: 0.2.0 min-binary-version: "4.0.0" allowed-tools: - Read - Write - Edit - Bash - AskUserQuestion --- # /gribouille Generate correct, idiomatic [gribouille](https://m.canouil.dev/gribouille) charts for Typst. Gribouille implements Wilkinson's Grammar of Graphics — the same mental model as ggplot2 and plotnine — compiled natively in Typst. ``` /gribouille scatter penguins flipper-len vs body-mass by species /gribouille bar chart of mpg highway economy by class, faceted by cyl /gribouille histogram of my-data.csv column "score" /gribouille compose two panels: weight vs mpg and horsepower vs mpg ``` --- ## Step 0 · Context detection Determine two things silently before asking anything. **A. Import mode:** | Signal | Import line | |---|---| | User mentions Typst Universe / `@preview` | `#import "@preview/gribouille:0.0.1": *` | | User has a local gribouille clone / `lib.typ` path | `#import "./path/to/lib.typ": *` | | Ambiguous | Ask: "Are you using gribouille from Typst Universe (`@preview`) or a local clone?" | **B. Embedding context:** | Signal | Action | |---|---| | User is inside a typst-author document | Generate only the `#plot(...)` block; add Step 9 handoff note | | User wants a standalone `.typ` file | Prepend `#set page(width: auto, height: auto, margin: 0.5cm)` | | Unclear | Default to standalone | --- ## Step 1 · Intent extraction (silent checklist) Before writing code, confirm these four dimensions. Ask only if 2+ are missing — one compact question at most, never a four-item form. | Dimension | What to extract | Example | |---|---|---| | **Chart type** | What visual form? | scatter, bar, histogram, boxplot, line, heatmap | | **Data** | Inline / built-in / external file? | `penguins`, a CSV path, or an array literal | | **Variables** | Which columns → which aesthetics? | x=flipper-len, y=body-mass, colour=species | | **Facets** | Split into panels? By what variable? | one panel per island | If chart type and data are both clear from context, skip the question entirely. --- ## Step 2 · Select chart type First-match table. Apply the first row that fits the user's description. | User says / data shape | Geom combination | Notes | |---|---|---| | "scatter", "relationship", two continuous vars | `geom-point(size: 2pt)` | Add `geom-smooth(method: "lm")` if trend desired | | "scatter + fit / regression / smoother" | `geom-point(size: 2pt, alpha: 0.65)` + `geom-smooth(method: "lm", se: true, alpha: 0.2)` | v1 supports `method: "lm"` only — not loess or glm | | "scatter + convex hull / group outlines" | above + `geom-mark(method: "hull", expand: 5pt, alpha: 0.25)` | | | "scatter + error bars" | `geom-point()` + `geom-errorbar(width: 0.3)` with `ymin`/`ymax` in mapping | | | "line chart / time series / trend over x" | `geom-line()` | Sort data by x first; use `geom-area()` if area-under-line matters | | "area chart" | `geom-area(alpha: 0.7)` | Requires ordered x | | "bar chart (counts)" | `geom-bar()` | Uses stat-count by default; map `x` only | | "bar chart (values / col chart)" | `geom-col()` | Data already has y values; map both `x` and `y` | | "stacked bar" | `geom-bar(position: "stack")` | Add `fill` aesthetic | | "dodged / grouped bar" | `geom-bar(position: "dodge")` | Add `fill` aesthetic | | "filled / proportional bar (100% stacked)" | `geom-bar(position: "fill")` | y becomes proportion 0–1 | | "histogram / distribution of continuous var" | `geom-histogram(bins: 12)` | Tune `bins` or use `binwidth` instead | | "overlapping distributions / frequency polygon" | `geom-freqpoly(bins: 12)` | Line version of histogram; good for comparing groups | | "boxplot / box-and-whisker" | `geom-boxplot()` | Add `geom-jitter(width: 0.2, alpha: 0.3)` for raw data overlay | | "heatmap / tile map" | `geom-tile()` | Requires x, y, fill aesthetics | | "2D histogram / bin2d" | `geom-bin-2d()` | Bins both x and y | | "hex bin (dense scatter)" | `geom-hex()` | Alternative to scatter for large datasets | | "contour lines" | `geom-contour()` | Requires x, y, z | | "filled contours" | `geom-contour-filled()` | Requires x, y, z, fill | | "error bars" | `geom-errorbar(width: 0.3)` with `ymin`/`ymax` in mapping | | | "horizontal error bars" | `geom-errorbarh(height: 0.3)` with `xmin`/`xmax` in mapping | | | "ribbon / confidence band" | `geom-ribbon(alpha: 0.2)` with `ymin`/`ymax` in mapping | Pair with `geom-line()` | | "rug plot (marginal ticks)" | `geom-rug(sides: "bl")` | Stack with another geom | | "path (connected in data order)" | `geom-path()` | Unlike `geom-line()`, does not sort by x | | "step function" | `geom-step(direction: "hv")` | Options: "hv", "vh", "mid" | | "segments / connectors" | `geom-segment(stroke: 1pt)` | `geom-curve()` for curved connectors | | "text labels on data points" | `geom-text(mapping: aes(label: "col"))` | Combine with `geom-point()` | | "labelled boxes (callouts)" | `geom-label(mapping: aes(label: "col"))` | Adds background box around text | | "dot plot" | `geom-dotplot()` | | | "Q-Q plot" | `geom-qq()` + `geom-qq-line()` | | | "quantile regression" | `geom-quantile()` | | | "spoke / wind vectors" | `geom-spoke()` | Requires x, y, angle, radius in mapping | | "count overplotting" | `geom-count()` | Size encodes count | | "function curve y=f(x)" | `geom-function(fun: x => calc.sin(x), n: 101)` | | | "horizontal reference line" | `geom-hline(yintercept: 0)` | | | "vertical reference line" | `geom-vline(xintercept: 0)` | | | "diagonal / slope reference" | `geom-abline(slope: 1, intercept: 0)` | | | "shaded rectangle region" | `geom-rect()` with `xmin`/`xmax`/`ymin`/`ymax` in mapping | | | "polygon" | `geom-polygon()` | | | "confidence ellipse" | `geom-ellipse()` | Or use `geom-mark(method: "ellipse")` | | "faceted" | Any geom above + `facet: facet-wrap("col")` or `facet: facet-grid(rows: "r", columns: "c")` | | Read `references/geom-table.md` for full parameter reference on any geom. --- ## Step 3 · Wire the data Choose the pattern that matches the data source. **Pattern A — Built-in dataset (no extra code needed):** ```typst // penguins, mpg, and economics are exported by gribouille #plot( data: penguins, // or: mpg, economics ... ) ``` **Pattern B — Inline array literal:** ```typst #let my-data = ( (x: 1.0, y: 2.1, group: "a"), (x: 2.0, y: 3.0, group: "a"), (x: 3.5, y: 1.8, group: "b"), ) #plot( data: my-data, ... ) ``` **Pattern C — External CSV (column headers become dictionary keys):** ```typst #let raw = csv("data.csv", row-type: dictionary) // Numeric values arrive as strings from csv(); cast as needed: #let my-data = raw.map(r => ( x: float(r.x), y: float(r.y), group: r.group, )) #plot( data: my-data, ... ) ``` **Key rule — always quote column names in `aes()`:** ```typst // CORRECT mapping: aes(x: "flipper-len", y: "body-mass") // WRONG — causes a compile error mapping: aes(x: flipper-len, y: body-mass) ``` --- ## Step 4 · Build the plot block Canonical skeleton. Include only the arguments that are actually needed — omit optional sections when defaults suffice. ```typst #import "@preview/gribouille:0.0.1": * #set page(width: auto, height: auto, margin: 0.5cm) // standalone only; remove in typst-author docs #plot( data: , mapping: aes( x: "", y: "", // add aesthetics as needed: colour: "", fill: "", shape: "", size: "", alpha: "", ), layers: ( geom-point(size: 2pt, alpha: 0.85), // stack additional geoms here in draw order (bottom to top) ), // scales: only include when overriding defaults scales: ( scale-y-continuous(labels: format-comma()), // for large numbers (>9999) // scale-x-log10(), // scale-colour-viridis-d(), ), labs: labs( title: "Chart Title", subtitle: "Optional subtitle", x: "X-Axis Label", y: "Y-Axis Label", colour: "Legend Title", fill: "Legend Title", caption: "Data source.", ), theme: theme-minimal(), // alternatives: theme-classic(), theme-void() width: 12cm, height: 9cm, ) ``` **Scale auto-selection — apply these rules silently before emitting code:** | Aesthetic + data type | Scale to emit | |---|---| | x/y continuous, values ≤9999 | Omit — default is fine | | x/y continuous, values >9999 | `scale-x/y-continuous(labels: format-comma())` | | x/y log scale requested | `scale-x/y-log10()` | | x/y date values | `scale-x/y-date(date-format: "[year]-[month repr:numerical]")` | | colour/fill discrete (categorical) | Omit — gribouille auto-trains; add `scale-colour-discrete(palette: ...)` only for custom colours | | colour/fill continuous | `scale-colour-continuous()` or `scale-colour-gradient(low: rgb(...), high: rgb(...))` | | colour viridis | `scale-colour-viridis-c()` (continuous) or `scale-colour-viridis-d()` (discrete) | | colour ColorBrewer | `scale-colour-brewer(palette: "Blues")` | | colour colourblind-safe | `scale-colour-okabe-ito()` | | alpha mapped to data | `scale-alpha-continuous(range: (0.1, 1))` | Read `references/scale-table.md` for full parameter reference. **Categorical column rule — use `as-factor()` when a column holds numeric-looking strings used for grouping:** ```typst // cyl column contains "4", "6", "8" as strings mapping: aes(x: "displ", y: "hwy", colour: as-factor("cyl")) // ↑ forces discrete treatment ``` **Typst markup in labs — use the `typst()` helper, not raw strings:** ```typst labs( title: typst("*Bold title* and #text(fill: blue)[blue text]"), ) ``` **Theme customisation — verified parameter names from source:** ```typst theme: theme-minimal( text: element-text(family: "Source Sans Pro", size: 10pt), // family:, NOT font: plot-title: element-text(size: 14pt, weight: "bold"), axis-text: element-text(size: 8pt), axis-line: element-line(thickness: 0.5pt), // thickness:, NOT linewidth: tick-length: 0.08cm, tick-labels: true, ) ``` `element-text()` accepts: `size`, `weight`, `colour`, `angle`, `family`, `margin` `element-line()` accepts: `colour`, `thickness` `element-rect()` accepts: `fill`, `stroke` --- ## Step 5 · Faceting Add a `facet:` argument to split the chart into small multiples. **One variable → `facet-wrap`:** ```typst facet: facet-wrap("island", ncolumn: 3), // Options: nrow:, ncolumn:, scales: "fixed"|"free"|"free_x"|"free_y" // labeller: label-value() (default) | label-both() // axes: "margins" (default) | "all_x" | "all_y" | "all" ``` **Two variables → `facet-grid`:** ```typst facet: facet-grid(rows: "sex", columns: "island"), // v1 supports scales: "fixed" only // Either rows: or columns: may be omitted (but not both) ``` --- ## Step 6 · Multi-panel composition Use `compose()` when the user wants multiple **independent** plots arranged together. Use `facet-wrap/grid` when it's the same chart split by a grouping variable. ```typst #let p1 = plot( data: d, mapping: aes(x: "wt", y: "mpg", colour: as-factor("cyl")), layers: (geom-point(size: 2pt),), width: 6cm, height: 4cm, defer: true, // REQUIRED for compose() ) #let p2 = plot( data: d, mapping: aes(x: "hp", y: "mpg", colour: as-factor("cyl")), layers: (geom-point(size: 2pt),), width: 6cm, height: 4cm, defer: true, ) #compose( p1, p2, layout: "grid", // "grid" or "stack" columns: 2, collect: auto, // auto hoists shared legends; none = keep per-panel guides-placement: "right", // "right" | "left" | "top" | "bottom" ) ``` --- ## Step 7 · Guides / legend control ```typst guides: guides( colour: guide-legend(position: "bottom", direction: "horizontal"), fill: guide-none(), shape: guide-legend(nrow: 1), ), ``` `guide-legend()` params: `title, nrow, ncolumn, reverse, position, direction, order, byrow`. `position` accepts: `"top"`, `"right"`, `"bottom"`, `"left"`, `"none"`, a Typst alignment like `top + right`, or a dict `(dx:, dy:)`. --- ## Step 8 · Accessibility Always emit an alt text string. Cache the plot in a `let` binding and call `get-alt-text()`: ```typst #let p = plot( data: penguins, mapping: aes(x: "flipper-len", y: "body-mass", colour: "species"), layers: (geom-point(size: 2pt),), labs: labs(title: "Penguin Scatter", x: "Flipper Length (mm)", y: "Body Mass (g)"), theme: theme-minimal(), width: 12cm, height: 9cm, ) #figure( p, caption: [Flipper length versus body mass for three penguin species.], alt: get-alt-text(p), ) ``` Tell the user to review and refine the generated alt text for their specific audience. --- ## Step 9 · Typst-author handoff When the chart is destined for a full typst-author document (not a standalone file): 1. Generate the `#plot(...)` block without `#set page(...)`. 2. Tell the user: > Paste the `#plot(...)` block inside a `#figure()` in your document: > > ```typst > #let p = plot( > // ... your plot arguments ... > width: 12cm, height: 9cm, > ) > > #figure( > p, > caption: [Your caption here.], > alt: get-alt-text(p), > ) > ``` > > Remove `#set page(...)` if you added it during standalone testing — the enclosing typst-author document controls page geometry. --- ## Step 10 · Anti-patterns Never emit these. Each has a correct alternative. | Wrong | Correct | Why | |---|---|---| | `aes(x: col-name)` | `aes(x: "col-name")` | Column names must be quoted strings; unquoted is a Typst identifier | | `aes(colour: "cyl")` when cyl holds `"4"/"6"/"8"` | `aes(colour: as-factor("cyl"))` | Numeric-looking strings need `as-factor()` for correct discrete training | | `element-text(font: "MyFont")` | `element-text(family: "MyFont")` | The param is `family:`, not `font:` | | `element-line(linewidth: 0.5pt)` | `element-line(thickness: 0.5pt)` | The param is `thickness:`, not `linewidth:` | | `scale-y-continuous()` when y values are small | Omit the scale | Unnecessary boilerplate; defaults are fine | | `#grid(columns: 2)[#p1][#p2]` for multi-panel | `#compose(p1, p2, layout: "grid", columns: 2)` | Typst `grid()` bypasses gribouille's legend hoisting | | `labs(title: "*Bold*")` | `labs(title: typst("*Bold*"))` | Raw strings in labs are not parsed as Typst markup | | `plot(..., data: csv("f.csv"))` | `#let d = csv("f.csv", row-type: dictionary)` then `plot(data: d, ...)` | `csv()` must use `row-type: dictionary` and be called outside `plot()` | | `geom-histogram()` without an `x` mapping | `mapping: aes(x: "col")` always | Histogram requires a single `x` aesthetic | | Omitting `width:` and `height:` | Always include `width: 12cm, height: 9cm` | Gribouille requires explicit dimensions; there is no default | | `geom-smooth(method: "loess")` | `geom-smooth(method: "lm")` | v1 only supports `method: "lm"` — loess and glm are not available | | `plot(..., defer: false)` inside `compose()` | `plot(..., defer: true)` | `compose()` only works with deferred specs | | `scale-size-radius()` | `scale-radius()` | The export is `scale-radius`, not `scale-size-radius` | | `scale-size-area(max.size: 6pt)` | `scale-size-area(range: (1pt, 12pt))` | The param is `range:`, not `max.size:` | | `n.breaks:` in any scale | `n-breaks:` | Gribouille uses hyphens (Typst convention), not dots | --- ## Quick reference **Three built-in datasets:** | Symbol | Description | Key columns | |---|---|---| | `penguins` | Palmer Archipelago penguins | `flipper-len`, `body-mass`, `species`, `island`, `sex` | | `mpg` | Fuel economy (ggplot2 mpg) | `displ`, `hwy`, `cty`, `class`, `cyl`, `drv` | | `economics` | US economic time series | `date`, `unemploy`, `pop`, `psavert`, `uempmed` | **Utility functions:** | Function | Purpose | |---|---| | `as-factor("col")` | Force column to discrete treatment | | `as-numeric("col")` | Force column to continuous | | `typst("markup")` | Typst markup in labs fields | | `after-scale(fn)` | Late-binding: evaluated after scale training | | `after-stat(fn)` | Late-binding: evaluated after stat transform | | `get-alt-text(p)` | Generate alt text from a plot result | | `format-comma()` | Number formatter: 1,234,567 | | `format-percent()` | Number formatter: 12.3% | | `format-scientific()` | Number formatter: 1.23×10⁴ | | `format-currency()` | Number formatter: $1,234 | | `format-number(digits: N)` | Fixed decimal places | | `format-wrap(width: N)` | Word-wrap labels at N characters | **Three theme presets:** | Theme | Character | |---|---| | `theme-minimal()` | No background panel, minimal chrome — default | | `theme-classic()` | Classic R style with axis lines | | `theme-void()` | No axes, no grid, just data |