diff --git a/packages/preview/simple-plot/0.8.0/LICENSE b/packages/preview/simple-plot/0.8.0/LICENSE new file mode 100644 index 0000000000..7081613f31 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Nathan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/simple-plot/0.8.0/README.md b/packages/preview/simple-plot/0.8.0/README.md new file mode 100644 index 0000000000..92ef821ab4 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/README.md @@ -0,0 +1,443 @@ +# simple-plot + +A simple, pgfplots-like function plotting library for Typst. Create beautiful mathematical plots with minimal code. + +> **Note:** This package is built on top of [CeTZ](https://github.com/cetz-package/cetz) v0.4.2. + +## Manual + +A full manual is available in [docs/manual.pdf](https://github.com/nathan-ed/typst-package-simple-plot/blob/2a2a4eab8d9e21103ffe792037298151ed490f49/docs/manual.pdf). + +## Gallery + +Click on an image to see the source code. + +| | | | +|:---:|:---:|:---:| +| [![Parabola plot with labeled vertex and roots](gallery/parabola.png)](gallery/parabola.typ) | [![Trigonometric functions sin and cos with grid](gallery/trig-functions.png)](gallery/trig-functions.typ) | [![Scatter plot with data points](gallery/scatter.png)](gallery/scatter.typ) | +| Parabola | Trigonometric Functions | Scatter Plot | +| [![Exponential and logarithmic function plots](gallery/exponential.png)](gallery/exponential.typ) | [![Data points with polynomial model fit curve](gallery/data-fit.png)](gallery/data-fit.typ) | [![Available marker types: circle, square, triangle, diamond](gallery/markers.png)](gallery/markers.typ) | +| Exponential & Logarithmic | Data with Model Fit | Marker Types | +| [![Extended axes with custom tick labels and unit](gallery/extended-axes.png)](gallery/extended-axes.typ) | [![Area fills, hatching, and Riemann sum overlays](gallery/area-features-1.png)](gallery/area-features.typ) | [![3D-style volume of revolution with disk cross-sections](gallery/revolution-1.png)](gallery/revolution.typ) | +| Extended Axes | Area Fills & Riemann Sums | Volume of Revolution | +| [![All five Riemann sum methods with annotation features](gallery/riemann-features-1.png)](gallery/riemann-features.typ) | | | +| Riemann Sum Features | | | + +## Features + +- **Simple API** — Plot functions with just a few lines of code +- **Multiple plot types** — Functions, scatter plots, line plots with markers +- **Customizable axes** — Position, labels, ticks, and tick labels; default $x$/$y$ labels at arrow tips (tkz-fct style) +- **Stealth arrows** — Elegant axis arrowheads matching LaTeX/pgfplots style +- **Axis extension** — Extend axes beyond plot area for cleaner appearance +- **Grid support** — Major and minor grids with custom styling +- **14 marker types** — Circles, squares, triangles, diamonds, stars, and more +- **Origin label control** — Toggle origin '0' label display +- **Global defaults** — Set defaults for all plots in your document +- **Riemann sums** — Left/right/midpoint/lower/upper rectangles; endpoint dots with labels and arrows; Δx bracket; $x_i$ subdivision labels +- **Volume of revolution** — 3D-style solids: arbitrary axis (horizontal, shifted, or oblique), end caps, disk cross-sections, optional coordinate axes +- **Full styling** — Customize colors, strokes, backgrounds, and more + +## Quick Start + +```typst +#import "@local/simple-plot:0.8.0": plot + +#plot( + xmin: -3, xmax: 3, + ymin: -1, ymax: 9, + show-grid: true, + (fn: x => calc.pow(x, 2), stroke: blue + 1.5pt), +) +``` + +Axis labels default to $x$ and $y$ — no need to set them explicitly. + +## Basic Usage + +### Plotting Functions + +```typst +#import "@local/simple-plot:0.8.0": plot + +// Single function +#plot( + xmin: -5, xmax: 5, + ymin: -5, ymax: 5, + show-grid: "major", + (fn: x => calc.sin(x), stroke: blue + 1.5pt), +) + +// Multiple functions +#plot( + xmin: -2 * calc.pi, xmax: 2 * calc.pi, + ymin: -1.5, ymax: 1.5, + (fn: x => calc.sin(x), stroke: blue + 1.2pt, label: $sin(x)$), + (fn: x => calc.cos(x), stroke: red + 1.2pt, label: $cos(x)$), +) +``` + +### Scatter Plots + +```typst +#import "@local/simple-plot:0.8.0": plot, scatter + +#plot( + xmin: 0, xmax: 10, + ymin: 0, ymax: 10, + show-grid: true, + scatter( + ((1, 2), (2, 3.5), (3, 2.8), (4, 5.2), (5, 4.8)), + mark: "*", + mark-fill: blue, + ), +) +``` + +### Line Plots with Markers + +```typst +#import "@local/simple-plot:0.8.0": plot, line-plot + +#plot( + xmin: 0, xmax: 10, + ymin: 0, ymax: 12, + axis-x-pos: "bottom", + axis-y-pos: "left", + line-plot( + ((0, 0), (1, 0.5), (2, 1.8), (3, 4.2), (4, 5.1)), + stroke: blue + 1.2pt, + mark: "*", + mark-fill: blue, + ), +) +``` + +### Function Labels with Positioning + +Control the placement of function labels using `label-pos` and `label-side`: + +```typst +#plot( + xmin: -5, xmax: 5, + ymin: -3, ymax: 5, + show-grid: true, + (fn: x => 0.2 * calc.pow(x, 2) - 2, + stroke: blue + 1.5pt, + label: $f(x)$, + label-pos: 0.9, // position along curve (0–1) + label-side: "below-right" // placement relative to point + ), + (fn: x => -0.5 * x + 1, + stroke: red + 1.5pt, + label: $g(x)$, + label-pos: 0.2, + label-side: "above-right" + ), +) +``` + +**Available `label-side` options:** `"above"`, `"below"`, `"left"`, `"right"`, `"above-left"`, `"above-right"`, `"below-left"`, `"below-right"` + +## Mathematical Functions + +| Function | Typst syntax | +|----------|--------------| +| Power $x^n$ | `calc.pow(x, n)` | +| Square root | `calc.sqrt(x)` | +| Absolute value | `calc.abs(x)` | +| Sine, Cosine, Tangent | `calc.sin(x)`, `calc.cos(x)`, `calc.tan(x)` | +| Exponential $e^x$ | `calc.exp(x)` | +| Natural log | `calc.ln(x)` | +| Log base b | `calc.log(x, base: b)` | + +> **Important:** Use decimal notation (e.g. `2.0` not `2`) inside lambda functions to avoid type errors. + +## Parameters Reference + +### Plot Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `xmin`, `xmax` | float | -5, 5 | X-axis range | +| `ymin`, `ymax` | float | -5, 5 | Y-axis range | +| `width`, `height` | float | 6, 6 | Plot size in cm | +| `scale` | float | 1 | Scale factor for the entire plot | +| `xlabel`, `ylabel` | content | `$x$`, `$y$` | Axis labels (default: tkz-fct style at arrow tips) | +| `show-grid` | bool/str | false | `true`, `false`, `"major"`, `"minor"`, `"both"` | +| `minor-grid-step` | int | 5 | Minor grid subdivisions per major tick | +| `grid-label-break` | bool | true | Gap in grid lines around tick labels | +| `axis-x-pos` | float/str | 0 | X-axis position: value, `"bottom"`, `"center"` | +| `axis-y-pos` | float/str | 0 | Y-axis position: value, `"left"`, `"center"` | +| `axis-x-extend` | float/array | (0, 0.5) | Extend X-axis beyond grid: value or `(left, right)` | +| `axis-y-extend` | float/array | (0, 0.5) | Extend Y-axis beyond grid: value or `(bottom, top)` | +| `show-origin` | bool | true | Show "0" label at origin | +| `unit-label-only` | bool | false | Show only "1" on axes for minimal style | + +### Axis Label Placement + +Labels default to tkz-fct style: $x$ sits below-right of the arrowhead, $y$ sits to the left. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `xlabel-pos` | str/array | `"end"` | `"end"`, `"center"`, or `(x, y)` in data coords | +| `ylabel-pos` | str/array | `"end"` | same | +| `xlabel-anchor` | str | `"north-west"` | CeTZ anchor — top-left of text at position | +| `ylabel-anchor` | str | `"east"` | CeTZ anchor — right edge of text at position | +| `xlabel-offset` | array | `(0.0, -0.03)` | Offset `(x, y)` in cm from arrow tip | +| `ylabel-offset` | array | `(-0.05, 0.0)` | Offset `(x, y)` in cm from arrow tip | + +### Tick Configuration + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `xtick`, `ytick` | auto/none/array | auto | Tick positions | +| `xtick-step`, `ytick-step` | auto/float | 1 | Step between ticks | +| `xtick-label-step`, `ytick-label-step` | int | 1 | Show label every N ticks | +| `xtick-labels`, `ytick-labels` | auto/array | auto | Custom tick labels | + +### Function Specification + +```typst +( + fn: x => ..., // Required: the function + stroke: blue + 1.2pt, // Line style + domain: (min, max), // Restrict domain + samples: 100, // Sample points + label: $f(x)$, // Label content + label-pos: 0.8, // Position along curve (0–1) + label-side: "above", // "above", "below", "left", "right", "above-left", … + mark: "o", // Marker type + mark-size: 0.1, + mark-fill: white, + mark-stroke: blue, + mark-interval: 10, // Show marker every N points +) +``` + +## Riemann Sums + +```typst +#import "@local/simple-plot:0.8.0": plot, riemann-sum + +#plot( + xmin: 0, xmax: 3, ymin: 0, ymax: 5, + riemann-sum( + x => calc.pow(x, 2), + domain: (0.0, 3.0), + n: 6, + method: "left", + color: blue.lighten(75%), + show-points: true, // dots at evaluation points with arrows + show-dx: true, // Δx bracket under one rectangle + show-xi: true, // x₀, x₁, …, x₆ labels along axis + ), + (fn: x => calc.pow(x, 2), stroke: blue + 1.5pt), +) +``` + +### `riemann-sum` Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `fn` | function | — | Function to integrate | +| `domain` | array | plot range | `(a, b)` | +| `n` | int | 4 | Number of rectangles | +| `method` | str | `"right"` | `"left"`, `"right"`, `"mid"`, `"lower"`, `"upper"` | +| `baseline` | float | 0 | Y-level of rectangle bases | +| `color` | color | `luma(220)` | Rectangle fill | +| `stroke` | stroke | `luma(80) + 0.6pt` | Rectangle border | +| `hatch` | str/none | none | Hatch pattern: `"ne"`, `"nw"`, `"h"`, `"v"`, `"cross"`, `"grid"` | +| `hatch-spacing` | length | `5pt` | Spacing between hatch lines | +| `hatch-stroke` | stroke | `luma(80) + 0.5pt` | Stroke for hatch lines | +| `samples` | int | 20 | Samples per subinterval for `"lower"`/`"upper"` | +| `show-points` | bool | false | Draw dot at each evaluation point | +| `point-color` | color | `rgb("#c94a00")` | Dot fill color | +| `point-size` | float | 0.07 | Dot radius in cm | +| `point-label` | content/auto/none | `auto` | Label with arrows to dots; `auto` = method name | +| `point-label-pos` | array/auto | `auto` | `(x, y)` in data coords; `auto` = upper-right of dots | +| `show-dx` | bool | false | Draw Δx dimension bracket under one rectangle | +| `dx-rect` | int/auto | `auto` | Rectangle index to annotate (0-based); `auto` = middle | +| `dx-label` | content | `$Delta x$` | Bracket label | +| `show-xi` | bool | false | Draw $x_0, x_1, \ldots, x_n$ at subdivision points | +| `xi-labels` | array/auto | `auto` | Custom labels array; `auto` = subscripted $x_i$ | +| `xi-show-values` | bool | false | Show numeric x-values instead of $x_i$ notation | + +**Methods:** +- `"left"` / `"right"` / `"mid"` — evaluation at left endpoint, right endpoint, or midpoint +- `"lower"` / `"upper"` — true infimum/supremum (sampled within each subinterval); works for any function shape including U-curves + +## Volume of Revolution + +```typst +#import "@local/simple-plot:0.8.0": volume-of-revolution + +#volume-of-revolution( + x => calc.sqrt(x), + domain: (0.0, 4.0), + n-disks: 5, + width: 8.0, + height: 4.0, + show-yaxis: true, + label-a: $0$, + label-b: $4$, + label-f: $f(x)=sqrt(x)$, +) +``` + +`solid-of-revolution` is an alias for backward compatibility. + +### `volume-of-revolution` Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `fn` | function | — | Profile function $y = f(x) > 0$ | +| `domain` | array | `(0, 4)` | `(a, b)` — interval of revolution | +| `n-disks` | int | 4 | Intermediate disk cross-sections to show | +| `width`, `height` | float | 5, 3.5 | Canvas size in cm | +| `samples` | int | 60 | Profile sampling points | +| `axis-y` | float | 0 | Y-value of revolution axis (default: x-axis) | +| `axis-slope` | float | 0 | Slope $m$ of revolution axis: $y = mx + \text{axis\_y}$ | +| `show-axis` | bool | true | Draw the revolution axis arrow | +| `show-yaxis` | bool | false | Draw a coordinate y-axis (`show-y-axis` is the canonical spelling) | +| `y-axis-x` / `yaxis-x` | auto/float | `auto` | X position for the y-axis; `auto` = left of volume | +| `y-axis-offset` | float | 0.45 | Gap between the y-axis and the volume when `y-axis-x: auto` | +| `y-axis-extend` | array | `(0.35, 0.45)` | Y-axis padding `(below, above)` the volume | +| `show-radius-marker` | bool | false | Draw a vertical radius dimension marker at `yaxis-x` | +| `show-back` | bool | true | Draw the back face and bottom profile | +| `show-labels` | bool | true | Show $a$, $b$, $f$ labels | +| `profile-stroke` | stroke | `blue + 1.5pt` | Top profile curve | +| `disk-color` | color | `luma(218)` | Solid body fill | +| `disk-stroke` | stroke | `luma(90) + 0.6pt` | Disk edge stroke | +| `axis-stroke` | stroke | `black + 0.7pt` | Revolution axis stroke | +| `label-a`, `label-b` | content | `$a$`, `$b$` | Domain endpoint labels | +| `label-f` | content | `$f$` | Function label | +| `label-y` | content | `$y$` | Y-axis label | + +## Marker Types + +| Type | Description | Type | Description | +|------|-------------|------|-------------| +| `"o"` | Hollow circle | `"*"` | Filled circle | +| `"square"` | Hollow square | `"square*"` | Filled square | +| `"triangle"` | Hollow triangle | `"triangle*"` | Filled triangle | +| `"diamond"` | Hollow diamond | `"diamond*"` | Filled diamond | +| `"star"` | Hollow star | `"star*"` | Filled star | +| `"+"` | Plus sign | `"x"` | Cross | +| `"|"` | Vertical bar | `"-"` | Horizontal bar | +| `"none"` | No marker | | | + +## Custom Styling + +```typst +#plot( + style: ( + axis: ( + stroke: black + 1pt, + arrow: (symbol: "stealth", fill: black, scale: 0.55), + ), + grid: ( + major: (stroke: luma(200) + 0.5pt), + minor: (stroke: luma(230) + 0.3pt), + ), + ticks: ( + length: 0.1, + stroke: black + 0.6pt, + label-offset: 0.15, + label-size: 10pt, + ), + ), + // ... +) +``` + +## Setting Global Defaults + +```typst +#import "@local/simple-plot:0.8.0": set-plot-defaults, reset-plot-defaults + +#set-plot-defaults(width: 6, height: 4, show-grid: "major") + +// All subsequent plots use these defaults +#plot(xmin: -2, xmax: 2, ymin: 0, ymax: 4, + (fn: x => calc.pow(x, 2))) + +#reset-plot-defaults() +``` + +## Comparison with Other Plotting Libraries + +**simple-plot** is designed for mathematical function plotting with a focus on simplicity and ease of use. Choose simple-plot when you need to: + +- **Plot mathematical functions** quickly with minimal boilerplate code +- **Create publication-quality plots** for math, physics, or engineering documents +- **Use a familiar API** similar to pgfplots/matplotlib for straightforward plotting tasks + +**Alternatives:** +- **[cetz-plot](https://typst.app/universe/package/cetz-plot/)**: Better for business charts and general data visualization (pie, bar, pyramid charts). +- **[lilaq](https://typst.app/universe/package/lilaq/)**: More powerful for complex scientific visualizations (colormesh, contour, quiver) but steeper learning curve. + +## Dependencies + +- [CeTZ](https://github.com/cetz-package/cetz) (v0.4.2+) + +## License + +MIT License — see LICENSE file for details. + +## Changelog + +### [0.8.0] - 2026-05-21 + +#### Added +- **`riemann-sum`: complete feature documentation** — full API table with all 23 parameters: `method` (`"left"`, `"right"`, `"mid"`, `"lower"`, `"upper"`), hatch controls (`hatch`, `hatch-spacing`, `hatch-stroke`), annotation flags (`show-points`, `point-color`, `point-size`, `point-label`, `point-label-pos`, `show-dx`, `dx-rect`, `dx-label`, `show-xi`, `xi-labels`, `xi-show-values`), and `samples` +- **`volume-of-revolution`: complete feature documentation** — full API table with all parameters including `axis-y`, `axis-slope`, `show-y-axis`, `yaxis-x`, `y-axis-offset`, `y-axis-extend`, `show-radius-marker`, `show-back`, `show-labels`, label params; `solid-of-revolution` alias documented +- **Gallery: `riemann-features.typ`** — 8-demo showcase of all Riemann sum features side by side + +### [0.7.0] - 2026-05-21 + +#### Fixed +- Riemann sum xi-label x-shift on y-axis increased from 0.18 to 0.35 to prevent overlap with axis line + +### [0.6.0] - 2026-05-21 + +#### Added +- **`volume-of-revolution`** — renamed from `solid-of-revolution` (alias kept); supports arbitrary revolution axis via `axis-y` and `axis-slope`; new `show-yaxis`, `show-back`, `label-y` parameters; ellipses rendered via polygon points for smoother output +- **Riemann sum: `"lower"`/`"upper"` methods** — true infimum/supremum by sampling within each subinterval; works for U-curves and any shape +- **Riemann sum: endpoint dots** — `show-points`, `point-color`, `point-size`, `point-label`, `point-label-pos`; draws dots at evaluation points with a text label and stealth arrows +- **Riemann sum: Δx bracket** — `show-dx`, `dx-rect`, `dx-label`; dimension bracket with tick marks under a chosen rectangle +- **Riemann sum: subdivision labels** — `show-xi`, `xi-labels`; draws $x_0, x_1, \ldots, x_n$ at subdivision points +- **Default axis labels** — `xlabel` and `ylabel` now default to `$x$` and `$y$` (tkz-fct style) +- **Axis label placement** — new defaults: `x` below-right of arrowhead (`"north-west"` anchor), `y` to the left (`"east"` anchor); matches standard math textbook style + +#### Fixed +- Axis arrow style changed from bare `"stealth"` string (inherited large global scale) to explicit `(symbol: "stealth", fill: black, scale: 0.55)` — arrows are now elegantly sized +- Global mark scale removed from canvas `set-style` to avoid oversizing user-defined marks + +### [0.3.0] - 2026-02-18 + +#### Changed +- **Grid label break**: gap-based grid drawing replaces white-box overlay; works on any background color + +#### Fixed +- Origin label no longer duplicates when `show-origin: true` and axes cross at origin + +### [0.2.6] - 2026-02-04 + +#### Added +- Grid label breaks, integer tick defaults, `xtick-label-step`/`ytick-label-step`, `unit-label-only`, axis arrows extending beyond grid + +### [0.2.5] - 2026-01-27 + +#### Fixed +- `label-pos` now respects explicit function domains + +### [0.2.0] - 2026-01-15 + +#### Added +- `axis-x-extend`, `axis-y-extend`, `show-origin`, `label-side`, Liang-Barsky clipping + +### [0.1.0] - 2026-01-13 + +#### Added +- Initial release: function plotting, scatter/line plots, 14 marker types, customizable axes, grid, ticks, labels, global defaults diff --git a/packages/preview/simple-plot/0.8.0/gallery/area-features-1.png b/packages/preview/simple-plot/0.8.0/gallery/area-features-1.png new file mode 100644 index 0000000000..2f1ce5595b Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/area-features-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/area-features-2.png b/packages/preview/simple-plot/0.8.0/gallery/area-features-2.png new file mode 100644 index 0000000000..322afba1e4 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/area-features-2.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/area-features-3.png b/packages/preview/simple-plot/0.8.0/gallery/area-features-3.png new file mode 100644 index 0000000000..b36ee3098b Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/area-features-3.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/area-features-4.png b/packages/preview/simple-plot/0.8.0/gallery/area-features-4.png new file mode 100644 index 0000000000..ae41d2d3eb Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/area-features-4.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/area-features.png b/packages/preview/simple-plot/0.8.0/gallery/area-features.png new file mode 100644 index 0000000000..2bde0464c5 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/area-features.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/area-features.typ b/packages/preview/simple-plot/0.8.0/gallery/area-features.typ new file mode 100644 index 0000000000..7668809d7d --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/area-features.typ @@ -0,0 +1,287 @@ +// +// area-features.typ — Test / gallery for all new area-fill features in simple-plot 0.4.0 +// +#import "@preview/simple-plot:0.8.0": plot, fill-area, area-between, note, vline, hline, riemann-sum + +#set page(margin: 1.5cm, width: 21cm) +#set text(font: "New Computer Modern", size: 10pt) + +#let demo(title, body) = { + v(0.8em) + block[ + *#title* + #v(0.3em) + #body + ] +} + += simple-plot 0.4.0 — Area-fill feature gallery + +// ── 1. fill: below a curve ─────────────────────────────────────────────────── +#demo("1 · `fill:` — solid fill below a curve to baseline")[ + #grid(columns: (1fr, 1fr), gutter: 1em)[ + _Fill sin(x) above x-axis (blue):_ + #align(center)[ + #plot( + xmin: -0.3, xmax: 7, ymin: -1.4, ymax: 1.4, + width: 7, height: 4, + axis-x-pos: "center", axis-y-pos: 0, + xtick: (1, 2, 3, 4, 5, 6), ytick: (-1, 1), + fill-area(x => calc.sin(x), domain: (0.0, calc.pi), + color: blue.lighten(75%)), + (fn: x => calc.sin(x), domain: (-0.2, 6.8), stroke: blue + 1.2pt, + label: $sin(x)$, label-pos: 0.15, label-anchor: "south"), + ) + ] + ][ + _Fill between curve and baseline y=0.5 (orange):_ + #align(center)[ + #plot( + xmin: -0.3, xmax: 4, ymin: -0.3, ymax: 2.5, + width: 7, height: 4, + axis-x-pos: "center", axis-y-pos: 0, + xtick: (1, 2, 3), ytick: (1, 2), + fill-area(x => calc.sqrt(x), baseline: 0.5, domain: (0.25, 3.5), + color: orange.lighten(70%)), + (hline: 0.5, stroke: stroke(paint: luma(100), thickness: 0.6pt, dash: "dashed")), + (fn: x => calc.sqrt(x), domain: (0.01, 3.8), stroke: rgb("#e05") + 1.2pt, + label: $sqrt(x)$, label-pos: 0.9, label-anchor: "south-west"), + ) + ] + ] +] + +// ── 2. fill-between: solid color ───────────────────────────────────────────── +#demo("2 · `fill-between:` — solid color between two curves")[ + #align(center)[ + #plot( + xmin: -1, xmax: 3, ymin: -3, ymax: 9, + width: 10, height: 5.5, + axis-x-pos: "center", axis-y-pos: "center", + xtick: (-0, 1, 2), ytick: (-2, -1, 1, 2, 3, 4, 5, 6, 7, 8), + area-between(x => 1.5 * calc.exp(x), x => calc.exp(x) - 2.0, + domain: (0.0, 1.0), color: green.lighten(70%)), + (fn: x => 1.5 * calc.exp(x), domain: (-0.8, 1.8), stroke: blue + 1.2pt, + label: $f(x) = 3/2 e^x$, label-pos: 0.75, label-anchor: "south-west"), + (fn: x => calc.exp(x) - 2.0, domain: (-0.8, 1.8), stroke: red + 1.2pt, + label: $g(x) = e^x - 2$, label-pos: 0.7, label-anchor: "north-east"), + (vline: 0.0, stroke: stroke(paint: luma(120), thickness: 0.6pt, dash: "dotted")), + (vline: 1.0, stroke: stroke(paint: luma(120), thickness: 0.6pt, dash: "dotted")), + ) + ] +] + +// ── 3. Hatch pattern gallery ───────────────────────────────────────────────── +#demo("3 · Hatch pattern styles — `\"ne\"`, `\"nw\"`, `\"h\"`, `\"v\"`, `\"cross\"`, `\"grid\"`")[ + #grid(columns: (1fr, 1fr, 1fr), gutter: 0.5em, + ..for (style, col) in ( + ("ne", blue), + ("nw", red), + ("h", green.darken(20%)), + ("v", purple), + ("cross", rgb("#e06000")), + ("grid", teal), + ) { + ( + align(center)[ + #text(8pt)[`"#style"`] + #v(0.2em) + #plot( + xmin: -1, xmax: 3, ymin: -0.5, ymax: 5, + width: 5, height: 3, + axis-x-pos: "center", axis-y-pos: 0, + xtick: (1, 2), ytick: (1, 2, 3, 4), + fill-area(x => x * x, domain: (0.0, 2.0), + hatch: style, hatch-spacing: 5pt, + hatch-stroke: col + 0.6pt), + (fn: x => x * x, domain: (-0.5, 2.3), stroke: col + 1.2pt), + ) + ], + ) + } + ) +] + +// ── 4. Overlapping fills (intersection) ────────────────────────────────────── +#demo("4 · Overlapping fills — two areas in different colors reveal intersection")[ + #align(center)[ + #plot( + xmin: -0.5, xmax: 7, ymin: -1.5, ymax: 1.5, + width: 12, height: 5, + axis-x-pos: "center", axis-y-pos: 0, + xtick: (1, 2, 3, 4, 5, 6), ytick: (-1, 1), + fill-area(x => calc.sin(x), domain: (0.0, calc.pi), + color: blue.transparentize(60%)), + fill-area(x => calc.sin(x - 1.0), domain: (1.0, calc.pi + 1.0), + color: red.transparentize(60%)), + (fn: x => calc.sin(x), domain: (-0.2, 6.8), stroke: blue + 1.2pt, + label: $sin(x)$, label-pos: 0.15, label-anchor: "south"), + (fn: x => calc.sin(x - 1.0), domain: (-0.2, 6.8), stroke: red + 1.2pt, + label: $sin(x-1)$, label-pos: 0.4, label-anchor: "south"), + ) + ] +] + +// ── 5. annotation + vline + hline ──────────────────────────────────────────── +#demo("5 · `annotation:`, `vline:`, `hline:` — asymptote labelling")[ + #align(center)[ + #plot( + xmin: -4, xmax: 6, ymin: -0.3, ymax: 5, + width: 11, height: 5, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (-3, -2, -1, 2, 3, 4, 5), ytick: (1, 2, 3, 4), + // asymptote lines + (vline: 1.0, stroke: stroke(paint: luma(100), thickness: 0.7pt, dash: "dashed")), + (hline: 1.0, stroke: stroke(paint: luma(100), thickness: 0.7pt, dash: "dashed")), + // curve (two branches) + (fn: x => calc.exp(1.0 / (x - 1.0)), domain: (-4, 0.85), + stroke: blue + 1.2pt, samples: 100), + (fn: x => calc.exp(1.0 / (x - 1.0)), domain: (1.15, 5.8), + stroke: blue + 1.2pt, samples: 100, + label: $f(x) = e^(1/(x-1))$, label-pos: 0.55, label-anchor: "south-west"), + // annotations + note([AV : $x = 1$], (1.15, 4.5), anchor: "west", size: 9pt), + note([AH : $y = 1$], (3.5, 1.2), anchor: "west", size: 9pt), + ) + ] +] + +// ── 6. Proof ln(2) > 1/2 using riemann-sum ────────────────────────────────── +#demo("6 · Proof $ln(2) > 1/2$ — `riemann-sum` (n=1, right) + `fill-area`")[ + #align(center)[ + #plot( + xmin: 0, xmax: 9, ymin: 0, ymax: 1.3, + width: 10, height: 4.5, + axis-x-pos: "bottom", axis-y-pos: "left", + xlabel: $x$, + xtick: (1, 2, 4, 8), ytick: (0.5, 1.0), + show-origin: false, + // right Riemann sum n=1: rectangle [1,2] height f(2)=1/2 + riemann-sum(x => 1.0 / x, domain: (1.0, 2.0), n: 1, method: "right", + color: luma(210), stroke: luma(70) + 0.6pt), + // blue area = ln(2) + fill-area(x => 1.0 / x, baseline: 0.0, domain: (1.0, 2.0), + color: blue.lighten(75%)), + // curve + (fn: x => 1.0 / x, domain: (0.35, 8.8), stroke: blue + 1.4pt, samples: 120, + label: $y = 1/x$, label-pos: 0.08, label-anchor: "north-east"), + // dotted verticals at 4 and 8 + vline(4.0, stroke: stroke(paint: luma(150), thickness: 0.5pt, dash: "dotted")), + vline(8.0, stroke: stroke(paint: luma(150), thickness: 0.5pt, dash: "dotted")), + note($ln 2$, (1.5, 0.2), anchor: "center", size: 9pt), + note($1/2$, (1.88, 0.5), anchor: "south", size: 8pt), + ) + ] +] + +// ── 7. Hatched + solid layers (tkz-fct style) ──────────────────────────────── +#demo("7 · Hatched region + solid outline (tkz-fct style integral illustration)")[ + #align(center)[ + #plot( + xmin: -0.5, xmax: 5, ymin: -0.5, ymax: 3, + width: 11, height: 5, + axis-x-pos: "center", axis-y-pos: 0, + xtick: (1, 2, 3, 4), ytick: (1, 2), + // hatched area between x and x² on [0,1] + area-between(x => x, x => x * x, domain: (0.0, 1.0), + hatch: "ne", hatch-spacing: 4pt, + hatch-stroke: green.darken(20%) + 0.5pt), + // hatched area between x² and 0 on [1,2] + fill-area(x => x * x, domain: (1.0, 2.0), + hatch: "nw", hatch-spacing: 4pt, + hatch-stroke: red.darken(10%) + 0.5pt), + (fn: x => x, domain: (0.0, 4.5), stroke: green.darken(20%) + 1.2pt, + label: $y = x$, label-pos: 0.9, label-anchor: "south-west"), + (fn: x => x * x, domain: (-0.3, 2.2), stroke: red.darken(10%) + 1.2pt, + label: $y = x^2$, label-pos: 0.6, label-anchor: "south-east"), + (vline: 1.0, stroke: stroke(paint: luma(130), thickness: 0.5pt, dash: "dotted")), + (vline: 2.0, stroke: stroke(paint: luma(130), thickness: 0.5pt, dash: "dotted")), + ) + ] +] + +// ── 8. Riemann sums gallery ────────────────────────────────────────────────── +#demo("8 · `riemann-sum` — left, right, midpoint methods")[ + #grid(columns: (1fr, 1fr, 1fr), gutter: 0.5em, + ..for (meth, label-txt) in ( + ("left", "Left (lower for $f$ incr.)"), + ("right", "Right (upper for $f$ incr.)"), + ("mid", "Midpoint"), + ) {( + align(center)[ + #text(8pt)[*#label-txt*] + #v(0.2em) + #plot( + xmin: 0, xmax: 5, ymin: 0, ymax: 4, + width: 5.5, height: 3.5, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (1, 2, 3, 4), ytick: (1, 2, 3), + show-origin: false, + riemann-sum(x => calc.sqrt(x) + 0.3, domain: (0.5, 4.5), n: 4, method: meth, + color: blue.lighten(75%), stroke: blue.darken(20%) + 0.6pt), + fill-area(x => calc.sqrt(x) + 0.3, baseline: 0.0, domain: (0.5, 4.5), + color: blue.transparentize(85%)), + (fn: x => calc.sqrt(x) + 0.3, domain: (0.2, 4.8), stroke: blue + 1.2pt, samples: 80), + ) + ], + )} + ) +] + +// ── 9. Riemann sum for decreasing function (upper/lower bounds) ─────────────── +#demo("9 · Upper and lower Riemann sums for $1/x$ on $[1, 4]$")[ + #grid(columns: (1fr, 1fr), gutter: 1em, + align(center)[ + #text(8pt)[*Left sum (upper bound, $f$ decreasing)*] + #v(0.2em) + #plot( + xmin: 0, xmax: 4.5, ymin: 0, ymax: 1.4, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xlabel: $x$, show-origin: false, + xtick: (1, 2, 3, 4), ytick: (0.5, 1.0), + riemann-sum(x => 1.0 / x, domain: (1.0, 4.0), n: 6, method: "left", + color: luma(220), stroke: luma(80) + 0.5pt), + fill-area(x => 1.0 / x, baseline: 0.0, domain: (1.0, 4.0), + color: blue.lighten(75%)), + (fn: x => 1.0 / x, domain: (0.4, 4.4), stroke: blue + 1.3pt, samples: 100, + label: $1/x$, label-pos: 0.1, label-anchor: "north-east"), + ) + ], + align(center)[ + #text(8pt)[*Right sum (lower bound, $f$ decreasing)*] + #v(0.2em) + #plot( + xmin: 0, xmax: 4.5, ymin: 0, ymax: 1.4, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xlabel: $x$, show-origin: false, + xtick: (1, 2, 3, 4), ytick: (0.5, 1.0), + riemann-sum(x => 1.0 / x, domain: (1.0, 4.0), n: 6, method: "right", + color: luma(220), stroke: luma(80) + 0.5pt), + fill-area(x => 1.0 / x, baseline: 0.0, domain: (1.0, 4.0), + color: blue.lighten(75%)), + (fn: x => 1.0 / x, domain: (0.4, 4.4), stroke: blue + 1.3pt, samples: 100, + label: $1/x$, label-pos: 0.1, label-anchor: "north-east"), + ) + ], + ) +] + +// ── 10. Hatched Riemann sums ────────────────────────────────────────────────── +#demo("10 · Hatched Riemann sum + fill-area overlay")[ + #align(center)[ + #plot( + xmin: -0.3, xmax: 7, ymin: -1.5, ymax: 1.5, + width: 12, height: 5, + axis-x-pos: "center", axis-y-pos: 0, + xtick: (1, 2, 3, 4, 5, 6), ytick: (-1, 1), + riemann-sum(x => calc.sin(x), domain: (0.0, calc.pi), n: 6, method: "mid", + color: none, hatch: "ne", hatch-spacing: 4pt, + hatch-stroke: blue + 0.5pt, stroke: blue.darken(20%) + 0.5pt), + fill-area(x => calc.sin(x), domain: (0.0, calc.pi), color: blue.transparentize(80%)), + (fn: x => calc.sin(x), domain: (-0.2, 6.8), stroke: blue + 1.3pt, + label: $sin(x)$, label-pos: 0.15, label-anchor: "south"), + ) + ] +] diff --git a/packages/preview/simple-plot/0.8.0/gallery/data-fit-1.png b/packages/preview/simple-plot/0.8.0/gallery/data-fit-1.png new file mode 100644 index 0000000000..9ca6b417e6 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/data-fit-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/data-fit.png b/packages/preview/simple-plot/0.8.0/gallery/data-fit.png new file mode 100644 index 0000000000..50bba652ee Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/data-fit.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/data-fit.typ b/packages/preview/simple-plot/0.8.0/gallery/data-fit.typ new file mode 100644 index 0000000000..3d28a8cd39 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/data-fit.typ @@ -0,0 +1,25 @@ +#import "@preview/simple-plot:0.8.0": plot, line-plot + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcases: data points with model fit, grid-label-break +#plot( + xmin: 0, xmax: 10, + ymin: 0, ymax: 12, + xlabel: [Time (s)], + ylabel: [Distance (m)], + show-grid: "both", + minor-grid-step: 5, + axis-x-pos: "bottom", + axis-y-pos: "left", + // Experimental data points + line-plot( + ((0, 0), (1, 0.5), (2, 1.8), (3, 4.2), (4, 5.1), (5, 6.8), (6, 8.2), (7, 9.5)), + stroke: blue + 1pt, + mark: "*", + mark-fill: blue, + ), + // Theoretical model fit + (fn: x => 0.15 * calc.pow(x, 2) + 0.3 * x, + stroke: red + 1.2pt, label: [Model], label-pos: 0.85, label-side: "below-left"), +) diff --git a/packages/preview/simple-plot/0.8.0/gallery/exponential-1.png b/packages/preview/simple-plot/0.8.0/gallery/exponential-1.png new file mode 100644 index 0000000000..851598fc08 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/exponential-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/exponential.png b/packages/preview/simple-plot/0.8.0/gallery/exponential.png new file mode 100644 index 0000000000..f51cb16a98 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/exponential.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/exponential.typ b/packages/preview/simple-plot/0.8.0/gallery/exponential.typ new file mode 100644 index 0000000000..6200df856c --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/exponential.typ @@ -0,0 +1,16 @@ +#import "@preview/simple-plot:0.8.0": plot + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcases: domain restriction, grid-label-break +#plot( + xmin: -2, xmax: 3, + ymin: -2, ymax: 5, + xlabel: $x$, + ylabel: $y$, + show-grid: "both", + minor-grid-step: 5, + (fn: x => calc.exp(x), stroke: green + 1.5pt, label: $e^x$, label-pos: 0.3, label-side: "below-left"), + (fn: x => if x > 0 { calc.ln(x) } else { float.nan }, + domain: (0.01, 3), stroke: orange + 1.5pt, label: $ln(x)$, label-pos: 0.9, label-side: "above-right"), +) diff --git a/packages/preview/simple-plot/0.8.0/gallery/extended-axes-1.png b/packages/preview/simple-plot/0.8.0/gallery/extended-axes-1.png new file mode 100644 index 0000000000..2193c101a4 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/extended-axes-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/extended-axes.png b/packages/preview/simple-plot/0.8.0/gallery/extended-axes.png new file mode 100644 index 0000000000..c405efcdd6 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/extended-axes.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/extended-axes.typ b/packages/preview/simple-plot/0.8.0/gallery/extended-axes.typ new file mode 100644 index 0000000000..15361f0db5 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/extended-axes.typ @@ -0,0 +1,14 @@ +#import "@preview/simple-plot:0.8.0": plot + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcases: default axis extension (0.5 on arrow side), grid-label-break +#plot( + xmin: -5, xmax: 5, + ymin: -3, ymax: 5, + xlabel: $x$, + ylabel: $y$, + show-grid: "major", + (fn: x => 0.2 * calc.pow(x, 2) - 2.0, stroke: blue + 1.5pt, label: $f$, label-pos: 0.9, label-side: "below-right"), + (fn: x => -0.5 * x + 1.0, stroke: red + 1.5pt, label: $g$, label-pos: 0.2, label-side: "above-right"), +) diff --git a/packages/preview/simple-plot/0.8.0/gallery/markers-1.png b/packages/preview/simple-plot/0.8.0/gallery/markers-1.png new file mode 100644 index 0000000000..8fd0ddaad2 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/markers-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/markers.png b/packages/preview/simple-plot/0.8.0/gallery/markers.png new file mode 100644 index 0000000000..a614f273f1 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/markers.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/markers.typ b/packages/preview/simple-plot/0.8.0/gallery/markers.typ new file mode 100644 index 0000000000..e764db4e5c --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/markers.typ @@ -0,0 +1,25 @@ +#import "@preview/simple-plot:0.8.0": plot, scatter + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcase different marker types with grid-label-break +#plot( + xmin: 0, xmax: 8, + ymin: 0, ymax: 8, + width: 8, height: 8, + show-grid: "major", + axis-x-pos: "bottom", + axis-y-pos: "left", + scatter(((1, 7),), mark: "o", mark-size: 0.2, mark-stroke: blue + 1pt), + scatter(((2, 7),), mark: "*", mark-size: 0.2, mark-fill: blue), + scatter(((3, 7),), mark: "square", mark-size: 0.2, mark-stroke: red + 1pt), + scatter(((4, 7),), mark: "square*", mark-size: 0.2, mark-fill: red), + scatter(((5, 7),), mark: "triangle", mark-size: 0.2, mark-stroke: green + 1pt), + scatter(((6, 7),), mark: "triangle*", mark-size: 0.2, mark-fill: green), + scatter(((1, 5),), mark: "diamond", mark-size: 0.2, mark-stroke: purple + 1pt), + scatter(((2, 5),), mark: "diamond*", mark-size: 0.2, mark-fill: purple), + scatter(((3, 5),), mark: "star", mark-size: 0.2, mark-stroke: orange + 1pt), + scatter(((4, 5),), mark: "star*", mark-size: 0.2, mark-fill: orange), + scatter(((5, 5),), mark: "+", mark-size: 0.2, mark-stroke: black + 1.5pt), + scatter(((6, 5),), mark: "x", mark-size: 0.2, mark-stroke: black + 1.5pt), +) diff --git a/packages/preview/simple-plot/0.8.0/gallery/parabola-1.png b/packages/preview/simple-plot/0.8.0/gallery/parabola-1.png new file mode 100644 index 0000000000..4689b0e350 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/parabola-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/parabola.png b/packages/preview/simple-plot/0.8.0/gallery/parabola.png new file mode 100644 index 0000000000..7d20ddb329 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/parabola.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/parabola.typ b/packages/preview/simple-plot/0.8.0/gallery/parabola.typ new file mode 100644 index 0000000000..9aa66b225d --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/parabola.typ @@ -0,0 +1,13 @@ +#import "@preview/simple-plot:0.8.0": plot + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcases: major grid only, grid-label-break, axis extension, integer ticks +#plot( + xmin: -3, xmax: 3, + ymin: -1, ymax: 9, + xlabel: $x$, + ylabel: $y$, + show-grid: "major", + (fn: x => calc.pow(x, 2), stroke: blue + 1.5pt, label: $x^2$, label-pos: 0.7, label-side: "below-right"), +) diff --git a/packages/preview/simple-plot/0.8.0/gallery/revolution-1.png b/packages/preview/simple-plot/0.8.0/gallery/revolution-1.png new file mode 100644 index 0000000000..f3c4e1c5b1 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/revolution-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/revolution-2.png b/packages/preview/simple-plot/0.8.0/gallery/revolution-2.png new file mode 100644 index 0000000000..aa1bc50dcb Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/revolution-2.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/revolution-3.png b/packages/preview/simple-plot/0.8.0/gallery/revolution-3.png new file mode 100644 index 0000000000..309e0169bd Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/revolution-3.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/revolution.typ b/packages/preview/simple-plot/0.8.0/gallery/revolution.typ new file mode 100644 index 0000000000..139da200f6 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/revolution.typ @@ -0,0 +1,261 @@ +// +// revolution.typ — Gallery for volume-of-revolution feature in simple-plot +// +#import "@preview/simple-plot:0.8.0": volume-of-revolution + +#set page(margin: 1.5cm, width: 21cm) +#set text(font: "New Computer Modern", size: 10pt) + +#let demo(title, body) = { + v(0.8em) + block[ + *#title* + #v(0.3em) + #body + ] +} + += simple-plot — Volume of revolution gallery + +// ── 1. Canonical: sqrt(x) ──────────────────────────────────────────────────── +#demo("1 · $f(x) = sqrt(x)$ on $[0; 4]$ — default look")[ + #align(center)[ + #volume-of-revolution( + x => calc.sqrt(x), + domain: (0.0, 4.0), + n-disks: 4, + show-y-axis: true, + width: 8.0, height: 4.0, + label-f: $f(x)=sqrt(x)$, + ) + ] +] + +// ── 2. Cylinder — constant function ────────────────────────────────────────── +#demo("2 · $f(x) = 2$ on $[0; 3]$ — cylinder")[ + #align(center)[ + #volume-of-revolution( + x => 2.0, + domain: (0.0, 3.0), + n-disks: 3, + width: 7.0, height: 3.5, + label-f: $f(x)=2$, + disk-color: blue.lighten(80%), + disk-stroke: blue.darken(20%) + 0.6pt, + profile-stroke: blue + 1.5pt, + ) + ] +] + +// ── 3. Cone — linear function ───────────────────────────────────────────────── +#demo("3 · $f(x) = x$ on $[0; 3]$ — cone")[ + #align(center)[ + #volume-of-revolution( + x => x, + domain: (0.01, 3.0), + n-disks: 4, + width: 7.0, height: 4.0, + label-a: $0$, + label-f: $f(x) = x$, + disk-color: red.lighten(80%), + disk-stroke: red.darken(20%) + 0.6pt, + profile-stroke: red + 1.5pt, + ) + ] +] + +// ── 4. Sphere — semi-circle profile ────────────────────────────────────────── +#demo("4 · $f(x) = sqrt(4 - x^2)$ on $[-2; 2]$ — sphere")[ + #align(center)[ + #volume-of-revolution( + x => calc.sqrt(calc.max(0.0, 4.0 - x * x)), + domain: (-1.99, 1.99), + n-disks: 5, + width: 8.0, height: 4.0, + label-a: $-2$, label-b: $2$, + label-f: $f(x)=sqrt(4-x^2)$, + disk-color: green.lighten(80%), + disk-stroke: green.darken(20%) + 0.6pt, + profile-stroke: green.darken(20%) + 1.5pt, + ) + ] +] + +// ── 5. Exponential — flared solid ───────────────────────────────────────────── +#demo("5 · $f(x) = e^(x\/2)$ on $[0; 2]$ — flared solid")[ + #align(center)[ + #volume-of-revolution( + x => calc.exp(x / 2.0), + domain: (0.0, 2.0), + n-disks: 4, + width: 7.0, height: 4.0, + label-f: $e^(x/2)$, + disk-color: rgb("#fff0d0"), + disk-stroke: rgb("#c06000") + 0.7pt, + profile-stroke: rgb("#c06000") + 1.5pt, + ) + ] +] + +// ── 6. Radius marker feature ───────────────────────────────────────────────── +#demo("6 · `show-radius-marker: true` — radius dimension")[ + #grid(columns: (1fr, 1fr), gutter: 1em, + align(center)[ + _y-axis at $x = a$ (default)_ + #v(0.3em) + #volume-of-revolution( + x => calc.sqrt(x), + domain: (1.0, 4.0), + n-disks: 3, + width: 7.0, height: 3.5, + show-back: false, + show-radius-marker: true, + label-a: $1$, + label-f: $sqrt(x)$, + label-y: $y$, + ) + ], + align(center)[ + _y-axis at interior point $x = 2$_ + #v(0.3em) + #volume-of-revolution( + x => calc.sqrt(x), + domain: (0.0, 4.0), + n-disks: 3, + width: 7.0, height: 3.5, + show-back: false, + show-radius-marker: true, + yaxis-x: 2.0, + label-f: $sqrt(x)$, + label-y: $sqrt(2)$, + ) + ], + ) +] + +// ── 7. Effect of n-disks ────────────────────────────────────────────────────── +#demo("7 · Effect of `n-disks` — `show-back: false` for clean comparison")[ + #grid(columns: (1fr, 1fr, 1fr), gutter: 1em, + ..for n in (2, 5, 10) {( + align(center)[ + #text(8pt)[n-disks: #n] + #v(0.2em) + #volume-of-revolution( + x => calc.sqrt(x + 1.0), + domain: (0.0, 3.0), + n-disks: n, + width: 4.5, height: 2.2, + show-back: false, + show-labels: false, + ) + ], + )} + ) +] + +// ── 8. Full 3D vs front-only (`show-back`) ─────────────────────────────────── +#demo("8 · `show-back: true` (default) vs `show-back: false` — same solid")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _`show-back: true` — full 3D perspective_ + #v(0.3em) + #volume-of-revolution( + x => 1.5 + 0.5 * calc.cos(calc.pi * x), + domain: (0.0, 2.0), + n-disks: 5, + width: 7.0, height: 3.5, + show-back: true, + disk-color: purple.lighten(80%), + disk-stroke: purple.darken(20%) + 0.6pt, + profile-stroke: purple + 1.5pt, + ) + ], + align(center)[ + _`show-back: false` — front half only_ + #v(0.3em) + #volume-of-revolution( + x => 1.5 + 0.5 * calc.cos(calc.pi * x), + domain: (0.0, 2.0), + n-disks: 5, + width: 7.0, height: 3.5, + show-back: false, + disk-color: purple.lighten(80%), + disk-stroke: purple.darken(20%) + 0.6pt, + profile-stroke: purple + 1.5pt, + ) + ], + ) +] + +// ── 9. axis-y — revolution around y = c ────────────────────────────────────── +#demo("9 · `axis-y: 1` — revolution around $y = 1$")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _$f(x) = 1 + sqrt(x)$ around $y = 1$_ + #v(0.3em) + #volume-of-revolution( + x => 1.0 + calc.sqrt(x), + domain: (0.0, 4.0), + axis-y: 1.0, + n-disks: 4, + width: 7.0, height: 3.5, + label-a: $0$, label-b: $4$, + label-f: $1+sqrt(x)$, + ) + ], + align(center)[ + _$f(x) = x + 1$ around $y = 1$ (cone)_ + #v(0.3em) + #volume-of-revolution( + x => x + 1.0, + domain: (0.01, 3.0), + axis-y: 1.0, + n-disks: 4, + width: 7.0, height: 3.5, + label-a: $0$, label-b: $3$, + label-f: $x+1$, + disk-color: rgb("#e8eaf6"), + disk-stroke: rgb("#3949ab") + 0.6pt, + profile-stroke: rgb("#3949ab") + 1.5pt, + ) + ], + ) +] + +// ── 10. axis-slope — revolution around oblique axis ─────────────────────────── +#demo("10 · `axis-slope: 1` — revolution around $y = x$")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _$f(x) = 2x$ around $y = x$ — cone_ + #v(0.3em) + #volume-of-revolution( + x => 2.0 * x, + domain: (0.01, 2.0), + axis-slope: 1.0, + n-disks: 4, + width: 7.0, height: 4.0, + label-a: $0$, label-b: $2$, + label-f: $2x$, + disk-color: rgb("#fce4ec"), + disk-stroke: rgb("#c62828") + 0.6pt, + profile-stroke: rgb("#c62828") + 1.5pt, + ) + ], + align(center)[ + _$f(x) = x^2$ around $y = x$ — pinched at $x=1$_ + #v(0.3em) + #volume-of-revolution( + x => x * x, + domain: (0.01, 2.0), + axis-slope: 1.0, + n-disks: 4, + width: 7.0, height: 4.0, + label-a: $0$, label-b: $2$, + label-f: $x^2$, + disk-color: rgb("#e8f5e9"), + disk-stroke: rgb("#2e7d32") + 0.6pt, + profile-stroke: rgb("#2e7d32") + 1.5pt, + ) + ], + ) +] diff --git a/packages/preview/simple-plot/0.8.0/gallery/riemann-features-1.png b/packages/preview/simple-plot/0.8.0/gallery/riemann-features-1.png new file mode 100644 index 0000000000..1dacb0c407 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/riemann-features-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/riemann-features-2.png b/packages/preview/simple-plot/0.8.0/gallery/riemann-features-2.png new file mode 100644 index 0000000000..e6f8dca712 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/riemann-features-2.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/riemann-features-3.png b/packages/preview/simple-plot/0.8.0/gallery/riemann-features-3.png new file mode 100644 index 0000000000..ed36c08078 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/riemann-features-3.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/riemann-features.typ b/packages/preview/simple-plot/0.8.0/gallery/riemann-features.typ new file mode 100644 index 0000000000..86c069e9c9 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/riemann-features.typ @@ -0,0 +1,280 @@ +// +// riemann-features.typ — Gallery showcasing all riemann-sum annotation features +// +#import "@preview/simple-plot:0.8.0": plot, fill-area, riemann-sum + +#set page(margin: 1.5cm, width: 21cm) +#set text(font: "New Computer Modern", size: 10pt) + +#let demo(title, body) = { + v(0.8em) + block[ + *#title* + #v(0.3em) + #body + ] +} + += simple-plot — Riemann Sum Feature Gallery + +// ── 1. Five methods side by side ───────────────────────────────────────────── +#demo("1 · All five methods — `\"left\"`, `\"right\"`, `\"mid\"`, `\"lower\"`, `\"upper\"`")[ + #grid(columns: (1fr,) * 5, gutter: 0.4em, + ..for (meth, col) in ( + ("left", blue), + ("right", red), + ("mid", green.darken(20%)), + ("lower", purple), + ("upper", rgb("#e06000")), + ) {( + align(center)[ + #text(8pt)[*`"#meth"`*] + #v(0.2em) + #plot( + xmin: 0, xmax: 3.3, ymin: -0.1, ymax: 2.3, + width: 3.5, height: 3, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (1, 2, 3), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x) + 0.1, domain: (0.2, 3.0), n: 5, + method: meth, + color: col.lighten(75%), stroke: col.darken(10%) + 0.6pt), + (fn: x => calc.sqrt(x) + 0.1, domain: (0.0, 3.2), + stroke: col + 1.2pt, samples: 80), + ) + ], + )} + ) +] + +// ── 2. show-points + show-dx + show-xi all at once ─────────────────────────── +#demo("2 · Full annotation: `show-points`, `show-dx`, `show-xi` together")[ + #align(center)[ + #plot( + xmin: -0.2, xmax: 3.5, ymin: -0.1, ymax: 5.5, + width: 11, height: 6, + axis-x-pos: "bottom", axis-y-pos: "left", + xlabel: $x$, ylabel: $y$, + xtick: (0, 1, 2, 3), ytick: (1, 2, 3, 4, 5), + show-origin: false, + riemann-sum( + x => x * x, + domain: (0.0, 3.0), + n: 6, + method: "right", + color: blue.lighten(80%), + stroke: blue.darken(10%) + 0.6pt, + show-points: true, + point-color: rgb("#c94a00"), + point-size: 0.07, + show-dx: true, + dx-rect: 2, + dx-label: $Delta x$, + show-xi: true, + ), + fill-area(x => x * x, domain: (0.0, 3.0), color: blue.transparentize(88%)), + (fn: x => x * x, domain: (0.0, 3.2), stroke: blue + 1.5pt, + label: $f(x) = x^2$, label-pos: 0.88, label-anchor: "south-west"), + ) + ] +] + +// ── 3. show-xi with xi-show-values ─────────────────────────────────────────── +#demo("3 · `show-xi: true` + `xi-show-values: true` — numeric subdivision labels")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _`xi-show-values: false` (default) — symbolic $x_0, x_1, \ldots$_ + #v(0.3em) + #plot( + xmin: -0.2, xmax: 4.2, ymin: -0.1, ymax: 2.5, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (0, 1, 2, 3, 4), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x), domain: (0.0, 4.0), n: 4, method: "left", + color: luma(225), stroke: luma(80) + 0.6pt, + show-xi: true, xi-show-values: false), + (fn: x => calc.sqrt(x), domain: (0.0, 4.2), stroke: blue + 1.3pt, samples: 80), + ) + ], + align(center)[ + _`xi-show-values: true` — actual $x$ values shown_ + #v(0.3em) + #plot( + xmin: -0.2, xmax: 4.2, ymin: -0.1, ymax: 2.5, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (0, 1, 2, 3, 4), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x), domain: (0.0, 4.0), n: 4, method: "left", + color: luma(225), stroke: luma(80) + 0.6pt, + show-xi: true, xi-show-values: true), + (fn: x => calc.sqrt(x), domain: (0.0, 4.2), stroke: blue + 1.3pt, samples: 80), + ) + ], + ) +] + +// ── 4. show-dx bracket detail ──────────────────────────────────────────────── +#demo("4 · `show-dx` — Δx dimension bracket, `dx-rect` selects which rectangle")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _`dx-rect: auto` (middle rectangle)_ + #v(0.3em) + #plot( + xmin: -0.2, xmax: 3.5, ymin: -0.1, ymax: 2.5, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (0, 1, 2, 3), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x + 0.5), domain: (0.0, 3.0), n: 6, method: "mid", + color: green.lighten(80%), stroke: green.darken(20%) + 0.6pt, + show-dx: true), + (fn: x => calc.sqrt(x + 0.5), domain: (0.0, 3.2), stroke: green.darken(20%) + 1.3pt), + ) + ], + align(center)[ + _`dx-rect: 0` (first rectangle), custom `dx-label`_ + #v(0.3em) + #plot( + xmin: -0.2, xmax: 3.5, ymin: -0.1, ymax: 2.5, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (0, 1, 2, 3), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x + 0.5), domain: (0.0, 3.0), n: 6, method: "mid", + color: green.lighten(80%), stroke: green.darken(20%) + 0.6pt, + show-dx: true, dx-rect: 0, dx-label: $h$), + (fn: x => calc.sqrt(x + 0.5), domain: (0.0, 3.2), stroke: green.darken(20%) + 1.3pt), + ) + ], + ) +] + +// ── 5. show-points with custom label ──────────────────────────────────────── +#demo("5 · `show-points` — evaluation dots; `point-label` and `point-label-pos`")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _`point-label: auto` — auto arrow label_ + #v(0.3em) + #plot( + xmin: -0.2, xmax: 3.5, ymin: -0.1, ymax: 2.3, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (0, 1, 2, 3), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x + 0.2), domain: (0.2, 3.2), n: 4, method: "mid", + color: luma(225), stroke: luma(80) + 0.5pt, + show-points: true), + (fn: x => calc.sqrt(x + 0.2), domain: (0.0, 3.4), stroke: blue + 1.3pt), + ) + ], + align(center)[ + _`point-label: none` — dots only, no label_ + #v(0.3em) + #plot( + xmin: -0.2, xmax: 3.5, ymin: -0.1, ymax: 2.3, + width: 7, height: 4, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (0, 1, 2, 3), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x + 0.2), domain: (0.2, 3.2), n: 4, method: "mid", + color: luma(225), stroke: luma(80) + 0.5pt, + show-points: true, point-label: none, + point-color: red, point-size: 0.10), + (fn: x => calc.sqrt(x + 0.2), domain: (0.0, 3.4), stroke: red + 1.3pt), + ) + ], + ) +] + +// ── 6. Hatch controls ──────────────────────────────────────────────────────── +#demo("6 · Hatch controls: `hatch-spacing` and `hatch-stroke` on Riemann rectangles")[ + #grid(columns: (1fr, 1fr, 1fr), gutter: 0.5em, + ..for (spacing, label-txt) in ((3pt, "3pt"), (5pt, "5pt (default)"), (9pt, "9pt")) {( + align(center)[ + #text(8pt)[`hatch-spacing: #label-txt`] + #v(0.2em) + #plot( + xmin: -0.2, xmax: 3.5, ymin: -0.1, ymax: 2.2, + width: 5.5, height: 3.5, + axis-x-pos: "bottom", axis-y-pos: "left", + xtick: (1, 2, 3), ytick: (1, 2), + show-origin: false, + riemann-sum(x => calc.sqrt(x), domain: (0.0, 3.0), n: 6, method: "right", + color: none, hatch: "ne", + hatch-spacing: spacing, + hatch-stroke: blue + 0.6pt, + stroke: blue.darken(20%) + 0.7pt), + (fn: x => calc.sqrt(x), domain: (0.0, 3.2), stroke: blue + 1.3pt), + ) + ], + )} + ) +] + +// ── 7. "lower" / "upper" for U-shaped function ─────────────────────────────── +#demo("7 · `\"lower\"` and `\"upper\"` — true inf/sup; works for U-curves")[ + #grid(columns: (1fr, 1fr), gutter: 1.5em, + align(center)[ + _`\"lower\"` — true minimum on each subinterval_ + #v(0.3em) + #plot( + xmin: -2.2, xmax: 2.2, ymin: -0.3, ymax: 4.5, + width: 7, height: 4.5, + axis-x-pos: "center", axis-y-pos: "center", + xtick: (-2, -1, 1, 2), ytick: (1, 2, 3, 4), + riemann-sum(x => x * x, domain: (-2.0, 2.0), n: 8, method: "lower", + color: blue.lighten(80%), stroke: blue.darken(10%) + 0.6pt, samples: 30), + fill-area(x => x * x, domain: (-2.0, 2.0), color: blue.transparentize(88%)), + (fn: x => x * x, domain: (-2.1, 2.1), stroke: blue + 1.3pt, + label: $x^2$, label-pos: 0.1, label-anchor: "south"), + ) + ], + align(center)[ + _`\"upper\"` — true maximum on each subinterval_ + #v(0.3em) + #plot( + xmin: -2.2, xmax: 2.2, ymin: -0.3, ymax: 4.5, + width: 7, height: 4.5, + axis-x-pos: "center", axis-y-pos: "center", + xtick: (-2, -1, 1, 2), ytick: (1, 2, 3, 4), + riemann-sum(x => x * x, domain: (-2.0, 2.0), n: 8, method: "upper", + color: red.lighten(80%), stroke: red.darken(10%) + 0.6pt, samples: 30), + fill-area(x => x * x, domain: (-2.0, 2.0), color: red.transparentize(88%)), + (fn: x => x * x, domain: (-2.1, 2.1), stroke: red + 1.3pt, + label: $x^2$, label-pos: 0.1, label-anchor: "south"), + ) + ], + ) +] + +// ── 8. Pedagogical: proof illustration ─────────────────────────────────────── +#demo("8 · Pedagogical: right-sum approximation with all annotations enabled")[ + #align(center)[ + #plot( + xmin: -0.2, xmax: calc.pi + 0.3, ymin: -0.05, ymax: 1.3, + width: 14, height: 6, + axis-x-pos: "bottom", axis-y-pos: "left", + xlabel: $x$, + xtick: (0, 1, 2, 3), ytick: (0.5, 1.0), + show-origin: false, + riemann-sum( + x => calc.sin(x), + domain: (0.0, calc.pi), + n: 5, + method: "right", + color: blue.lighten(80%), + stroke: blue.darken(10%) + 0.6pt, + show-points: true, + show-dx: true, dx-rect: 1, dx-label: $Delta x = pi/5$, + show-xi: true, + ), + fill-area(x => calc.sin(x), domain: (0.0, calc.pi), + color: blue.transparentize(88%)), + (fn: x => calc.sin(x), domain: (-0.1, calc.pi + 0.2), + stroke: blue + 1.5pt, samples: 100, + label: $sin(x)$, label-pos: 0.25, label-anchor: "south"), + ) + ] +] diff --git a/packages/preview/simple-plot/0.8.0/gallery/scatter-1.png b/packages/preview/simple-plot/0.8.0/gallery/scatter-1.png new file mode 100644 index 0000000000..d6f21878a4 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/scatter-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/scatter.png b/packages/preview/simple-plot/0.8.0/gallery/scatter.png new file mode 100644 index 0000000000..a4ee624fb4 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/scatter.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/scatter.typ b/packages/preview/simple-plot/0.8.0/gallery/scatter.typ new file mode 100644 index 0000000000..6a17554286 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/scatter.typ @@ -0,0 +1,21 @@ +#import "@preview/simple-plot:0.8.0": plot, scatter + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcases: scatter plots with axes at bottom/left +#plot( + xmin: 0, xmax: 10, + ymin: 0, ymax: 10, + xlabel: $x$, + ylabel: $y$, + show-grid: "both", + minor-grid-step: 5, + axis-x-pos: "bottom", + axis-y-pos: "left", + scatter( + ((1, 2), (2, 3.5), (3, 2.8), (4, 5.2), (5, 4.8), (6, 6.1), (7, 5.9), (8, 7.2), (9, 8.1)), + mark: "*", + mark-fill: blue, + mark-size: 0.15, + ), +) diff --git a/packages/preview/simple-plot/0.8.0/gallery/trig-functions-1.png b/packages/preview/simple-plot/0.8.0/gallery/trig-functions-1.png new file mode 100644 index 0000000000..c50b4d3fda Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/trig-functions-1.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/trig-functions.png b/packages/preview/simple-plot/0.8.0/gallery/trig-functions.png new file mode 100644 index 0000000000..be11d6a2c1 Binary files /dev/null and b/packages/preview/simple-plot/0.8.0/gallery/trig-functions.png differ diff --git a/packages/preview/simple-plot/0.8.0/gallery/trig-functions.typ b/packages/preview/simple-plot/0.8.0/gallery/trig-functions.typ new file mode 100644 index 0000000000..c9a3974abd --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/gallery/trig-functions.typ @@ -0,0 +1,18 @@ +#import "@preview/simple-plot:0.8.0": plot + +#set page(width: auto, height: auto, margin: 0.5cm) + +// Showcases: custom tick labels, grid-label-break with pi notation +#plot( + xmin: -2.0 * calc.pi, xmax: 2.0 * calc.pi, + ymin: -1.5, ymax: 1.5, + width: 10, height: 5, + xlabel: $x$, + ylabel: $y$, + show-grid: "major", + show-origin: false, // Avoid duplicate "0" with custom xtick-labels + xtick: (-2.0*calc.pi, -calc.pi, calc.pi, 2.0*calc.pi), + xtick-labels: ($-2 pi$, $-pi$, $pi$, $2 pi$), + (fn: x => calc.sin(x), stroke: blue + 1.2pt, samples: 200, label: $sin(x)$, label-pos: 0.65, label-side: "below"), + (fn: x => calc.cos(x), stroke: red + 1.2pt, samples: 200, label: $cos(x)$, label-pos: 0.9, label-side: "above"), +) diff --git a/packages/preview/simple-plot/0.8.0/lib.typ b/packages/preview/simple-plot/0.8.0/lib.typ new file mode 100644 index 0000000000..82de26b182 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/lib.typ @@ -0,0 +1,1873 @@ +// simple-plot - A simple pgfplots-like function plotting library for Typst +// https://github.com/nathan/simple-plot +// License: MIT + +#import "@preview/cetz:0.5.2" as cetz + +// ============================================================================ +// GLOBAL DEFAULTS +// ============================================================================ + +#let _plot-defaults = state("simple-plot-defaults", (:)) + +/// Set default values for all subsequent plots. +/// +/// Example: +/// ```typst +/// #set-plot-defaults(width: 10, height: 8, show-grid: true) +/// ``` +#let set-plot-defaults(..args) = { + _plot-defaults.update(current => { + let new = current + for (key, value) in args.named() { + new.insert(key, value) + } + new + }) +} + +/// Reset all defaults to initial values. +#let reset-plot-defaults() = { + _plot-defaults.update(_ => (:)) +} + +// ============================================================================ +// MARKER DEFINITIONS +// ============================================================================ + +/// Available marker types for scatter plots and data points. +#let marker-types = ( + "o", // circle (hollow) + "*", // circle (filled) + "square", // square (hollow) + "square*", // square (filled) + "triangle", // triangle (hollow) + "triangle*",// triangle (filled) + "diamond", // diamond (hollow) + "diamond*", // diamond (filled) + "star", // star (hollow) + "star*", // star (filled) + "+", // plus + "x", // cross + "|", // vertical bar + "-", // horizontal bar + "none", // no marker +) + +// ============================================================================ +// DEFAULT STYLES +// ============================================================================ + +#let default-style = ( + background: ( + fill: none, + stroke: none, + ), + axis: ( + stroke: black + 0.8pt, + arrow: (symbol: "stealth", fill: black, scale: 0.55), + ), + grid: ( + // Elegant thin grid lines inspired by tkz-fct + major: (stroke: luma(200) + 0.5pt), + minor: (stroke: luma(230) + 0.3pt), + ), + ticks: ( + length: 0.1, + stroke: black + 0.6pt, + label-offset: 0.15, + label-size: 10pt, + ), + plot: ( + stroke: blue + 1.2pt, + samples: 100, + ), + marker: ( + size: 0.12, + stroke: black + 0.8pt, + fill: black, + ), + labels: ( + size: 10pt, + offset: 0.3, + ), + xlabel-style: ( + anchor: "west", + offset: (0.3, 0), + ), + ylabel-style: ( + anchor: "south", + offset: (0, 0.3), + ), +) + +#let merge-styles(user-style) = { + let result = default-style + if user-style != none { + for (key, value) in user-style { + if key in result and type(value) == dictionary { + for (k, v) in value { + result.at(key).insert(k, v) + } + } else { + result.insert(key, value) + } + } + } + result +} + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +// Clip a line segment to a rectangle (all 4 edges) using Liang-Barsky +#let clip-segment(p1, p2, xmin, ymin, xmax, ymax) = { + let (x1, y1) = p1 + let (x2, y2) = p2 + + let dx = x2 - x1 + let dy = y2 - y1 + + let t0 = 0.0 + let t1 = 1.0 + + // Check each edge: left, right, bottom, top + let edges = ( + (-dx, x1 - xmin), + (dx, xmax - x1), + (-dy, y1 - ymin), + (dy, ymax - y1), + ) + + for (p, q) in edges { + if p == 0 { + if q < 0 { return none } + } else { + let t = q / p + if p < 0 { + t0 = calc.max(t0, t) + } else { + t1 = calc.min(t1, t) + } + if t0 > t1 { return none } + } + } + + let nx1 = x1 + t0 * dx + let ny1 = y1 + t0 * dy + let nx2 = x1 + t1 * dx + let ny2 = y1 + t1 * dy + + ((nx1, ny1), (nx2, ny2)) +} + +// Convert user-friendly label-side to CeTZ anchor +// "above" means label is above the point, so anchor at "south" (bottom of text) +#let side-to-anchor(side) = { + if side == none { return none } + let mapping = ( + "above": "south", + "below": "north", + "left": "east", + "right": "west", + "above-left": "south-east", + "above-right": "south-west", + "below-left": "north-east", + "below-right": "north-west", + ) + mapping.at(side, default: side) // fallback to raw anchor if not in mapping +} + +#let format-number(n, precision: 2) = { + if calc.abs(n - calc.round(n)) < 0.0001 { + str(int(calc.round(n))) + } else { + let rounded = calc.round(n * calc.pow(10, precision)) / calc.pow(10, precision) + str(rounded) + } +} + +// Generate ticks with step=1 by default, starting on integers +#let generate-ticks(min, max, step: auto, count: auto) = { + let actual-step = if step != auto { + step + } else if count != auto { + (max - min) / count + } else { + // Default to step=1 for clean integer ticks + 1 + } + + let ticks = () + // Start at the first integer >= min (aligned to step) + let start = calc.ceil(min / actual-step) * actual-step + let pos = start + while pos <= max + 0.0001 { + ticks.push(pos) + pos += actual-step + } + (ticks: ticks, step: actual-step) +} + +// ============================================================================ +// MARKER DRAWING +// ============================================================================ + +#let draw-marker(ctx, pos, marker-type, size, fill-color, stroke-style) = { + import cetz.draw: * + + let (cx, cy) = pos + let s = size + let half = s / 2 + + if marker-type == "o" { + circle((cx, cy), radius: half, stroke: stroke-style, fill: none) + } else if marker-type == "*" { + circle((cx, cy), radius: half, stroke: stroke-style, fill: fill-color) + } else if marker-type == "square" { + rect((cx - half, cy - half), (cx + half, cy + half), stroke: stroke-style, fill: none) + } else if marker-type == "square*" { + rect((cx - half, cy - half), (cx + half, cy + half), stroke: stroke-style, fill: fill-color) + } else if marker-type == "triangle" { + let h = s * 0.866 + line((cx, cy + h/2), (cx - half, cy - h/2), (cx + half, cy - h/2), + close: true, stroke: stroke-style, fill: none) + } else if marker-type == "triangle*" { + let h = s * 0.866 + line((cx, cy + h/2), (cx - half, cy - h/2), (cx + half, cy - h/2), + close: true, stroke: stroke-style, fill: fill-color) + } else if marker-type == "diamond" { + line((cx, cy + half), (cx - half, cy), (cx, cy - half), (cx + half, cy), + close: true, stroke: stroke-style, fill: none) + } else if marker-type == "diamond*" { + line((cx, cy + half), (cx - half, cy), (cx, cy - half), (cx + half, cy), + close: true, stroke: stroke-style, fill: fill-color) + } else if marker-type == "star" or marker-type == "star*" { + let outer = half + let inner = half * 0.4 + let points = () + for i in range(10) { + let angle = calc.pi / 2 + i * calc.pi / 5 + let r = if calc.rem(i, 2) == 0 { outer } else { inner } + points.push((cx + r * calc.cos(angle), cy + r * calc.sin(angle))) + } + line(..points, close: true, stroke: stroke-style, + fill: if marker-type == "star*" { fill-color } else { none }) + } else if marker-type == "+" { + line((cx - half, cy), (cx + half, cy), stroke: stroke-style) + line((cx, cy - half), (cx, cy + half), stroke: stroke-style) + } else if marker-type == "x" { + line((cx - half, cy - half), (cx + half, cy + half), stroke: stroke-style) + line((cx - half, cy + half), (cx + half, cy - half), stroke: stroke-style) + } else if marker-type == "|" { + line((cx, cy - half), (cx, cy + half), stroke: stroke-style) + } else if marker-type == "-" { + line((cx - half, cy), (cx + half, cy), stroke: stroke-style) + } +} + +// ============================================================================ +// MAIN PLOT FUNCTION +// ============================================================================ + +/// Create a 2D plot with axes, grid, and function/data visualization. +/// +/// - xmin (auto, float): Minimum x value +/// - xmax (auto, float): Maximum x value +/// - ymin (auto, float): Minimum y value +/// - ymax (auto, float): Maximum y value +/// - width (auto, float): Plot width in cm +/// - height (auto, float): Plot height in cm +/// - scale (auto, float): Scale factor for the entire plot (default: 1) +/// - xlabel (auto, content): X-axis label +/// - ylabel (auto, content): Y-axis label +/// - xlabel-pos (auto, str, array): X label position ("end", "center", or (x,y)) +/// - ylabel-pos (auto, str, array): Y label position ("end", "center", or (x,y)) +/// - xlabel-anchor (auto, str): X label anchor point +/// - ylabel-anchor (auto, str): Y label anchor point +/// - xlabel-offset (auto, array): X label offset (x, y) in cm +/// - ylabel-offset (auto, array): Y label offset (x, y) in cm +/// - xtick (auto, none, array): X tick positions +/// - ytick (auto, none, array): Y tick positions +/// - xtick-step (auto, float): X tick step (default: 1) +/// - ytick-step (auto, float): Y tick step (default: 1) +/// - xtick-labels (auto, none, array): Custom X tick labels (none = no labels) +/// - ytick-labels (auto, none, array): Custom Y tick labels (none = no labels) +/// - xtick-label-step (auto, int): Show X tick label every N ticks (e.g., 5 = labels at 0,5,10...) +/// - ytick-label-step (auto, int): Show Y tick label every N ticks (e.g., 5 = labels at 0,5,10...) +/// - show-grid (auto, bool, str): Grid display ("major", "minor", "both", true, false) +/// - minor-grid-step (auto, int): Minor grid subdivisions per major tick (default: 5) +/// - grid-label-break (auto, bool): Break grid lines around tick labels (default: true) +/// - unit-label-only (auto, bool): Show only "1" label on axes (not -1), useful for minimal style (default: false) +/// - axis-x-pos (auto, float, str): X-axis y-position ("bottom", "center", or value) +/// - axis-y-pos (auto, float, str): Y-axis x-position ("left", "center", or value) +/// - axis-x-extend (auto, float, array): X-axis extension beyond plot (value or (left, right)) +/// - axis-y-extend (auto, float, array): Y-axis extension beyond plot (value or (bottom, top)) +/// - show-origin (auto, bool): Show "0" label at origin (default: true) +/// - tick-label-size (auto, length): Font size for tick labels (default: 0.65em) +/// - axis-label-size (auto, length): Font size for axis labels x/y (default: 0.8em) +/// - style (none, dictionary): Style overrides +/// - ..functions: Function/data specifications to plot +#let plot( + xmin: auto, + xmax: auto, + ymin: auto, + ymax: auto, + width: auto, + height: auto, + scale: auto, + xlabel: auto, + ylabel: auto, + xlabel-pos: auto, + ylabel-pos: auto, + xlabel-anchor: auto, + ylabel-anchor: auto, + xlabel-offset: auto, + ylabel-offset: auto, + xtick: auto, + ytick: auto, + xtick-step: auto, + ytick-step: auto, + xtick-labels: auto, + ytick-labels: auto, + xtick-label-step: auto, + ytick-label-step: auto, + show-grid: auto, + minor-grid-step: auto, + grid-label-break: auto, + unit-label-only: auto, + axis-x-pos: auto, + axis-y-pos: auto, + axis-x-extend: auto, + axis-y-extend: auto, + show-origin: auto, + tick-label-size: auto, + axis-label-size: auto, + style: none, + series: none, + ..functions, +) = context { + let defaults = _plot-defaults.get() + + let resolve(val, key, fallback) = { + if val != auto { val } + else if key in defaults { defaults.at(key) } + else { fallback } + } + + let xmin = resolve(xmin, "xmin", -5) + let xmax = resolve(xmax, "xmax", 5) + let ymin = resolve(ymin, "ymin", -5) + let ymax = resolve(ymax, "ymax", 5) + let width = resolve(width, "width", 6) + let height = resolve(height, "height", 6) + let scale = resolve(scale, "scale", 1) + let width = width * scale + let height = height * scale + let xlabel = resolve(xlabel, "xlabel", $x$) + let ylabel = resolve(ylabel, "ylabel", $y$) + let xlabel-pos = resolve(xlabel-pos, "xlabel-pos", "end") + let ylabel-pos = resolve(ylabel-pos, "ylabel-pos", "end") + let xlabel-anchor = resolve(xlabel-anchor, "xlabel-anchor", "north") + let ylabel-anchor = resolve(ylabel-anchor, "ylabel-anchor", "east") + let xlabel-offset = resolve(xlabel-offset, "xlabel-offset", (0.0, -0.05)) + let ylabel-offset = resolve(ylabel-offset, "ylabel-offset", (-0.05, 0.0)) + let xtick = resolve(xtick, "xtick", auto) + let ytick = resolve(ytick, "ytick", auto) + let xtick-step = resolve(xtick-step, "xtick-step", auto) + let ytick-step = resolve(ytick-step, "ytick-step", auto) + let xtick-labels = resolve(xtick-labels, "xtick-labels", auto) + let ytick-labels = resolve(ytick-labels, "ytick-labels", auto) + let xtick-label-step = resolve(xtick-label-step, "xtick-label-step", 1) + let ytick-label-step = resolve(ytick-label-step, "ytick-label-step", 1) + let show-grid = resolve(show-grid, "show-grid", false) + let minor-grid-step = resolve(minor-grid-step, "minor-grid-step", 5) + let grid-label-break = resolve(grid-label-break, "grid-label-break", true) + let unit-label-only = resolve(unit-label-only, "unit-label-only", false) + let axis-x-pos = resolve(axis-x-pos, "axis-x-pos", 0) + let axis-y-pos = resolve(axis-y-pos, "axis-y-pos", 0) + let axis-x-extend = resolve(axis-x-extend, "axis-x-extend", (0, 0.5)) + let axis-y-extend = resolve(axis-y-extend, "axis-y-extend", (0, 0.5)) + let show-origin = resolve(show-origin, "show-origin", true) + let tick-label-size = resolve(tick-label-size, "tick-label-size", auto) + let axis-label-size = resolve(axis-label-size, "axis-label-size", auto) + + // Normalize extend values to (left/bottom, right/top) tuples + let x-extend = if type(axis-x-extend) == array { axis-x-extend } else { (axis-x-extend, axis-x-extend) } + let y-extend = if type(axis-y-extend) == array { axis-y-extend } else { (axis-y-extend, axis-y-extend) } + + let s = merge-styles(style) + + // Override style values with direct parameters if set + if tick-label-size != auto { + s.ticks.label-size = tick-label-size + } + if axis-label-size != auto { + s.labels.size = axis-label-size + } + + // Scale factors in CeTZ canvas units + let x-scale = width / (xmax - xmin) + let y-scale = height / (ymax - ymin) + + let to-canvas(x, y) = { + ((x - xmin) * x-scale, (y - ymin) * y-scale) + } + + let x-axis-y = if axis-x-pos == "bottom" { ymin } + else if axis-x-pos == "center" { 0 } + else { calc.max(ymin, calc.min(ymax, axis-x-pos)) } + + let y-axis-x = if axis-y-pos == "left" { xmin } + else if axis-y-pos == "center" { 0 } + else { calc.max(xmin, calc.min(xmax, axis-y-pos)) } + + let x-ticks = if xtick == none { (ticks: (), step: 1) } + else if xtick == auto { generate-ticks(xmin, xmax, step: xtick-step) } + else { (ticks: xtick, step: if xtick.len() > 1 { xtick.at(1) - xtick.at(0) } else { 1 }) } + + let y-ticks = if ytick == none { (ticks: (), step: 1) } + else if ytick == auto { generate-ticks(ymin, ymax, step: ytick-step) } + else { (ticks: ytick, step: if ytick.len() > 1 { ytick.at(1) - ytick.at(0) } else { 1 }) } + + cetz.canvas(length: 1cm, { + // Save Typst's native `line` before cetz shadows it — needed for pattern() fills + let native-line = line + import cetz.draw: * + + // ── Hatch pattern builder ──────────────────────────────────────────────── + // Returns a Typst fill paint: a solid color or a repeating pattern. + // style: none | "ne" | "nw" | "h" | "v" | "cross" | "grid" + // spacing: absolute length (e.g. 5pt) + // stroke-style: a Typst stroke value + let make-hatch-pattern(style, spacing, stroke-style) = { + if style == none { return none } + let s = spacing + if style == "ne" { + tiling(size: (s, s))[ + #place(native-line(start: (0pt, s), end: (s, 0pt), stroke: stroke-style)) + ] + } else if style == "nw" { + tiling(size: (s, s))[ + #place(native-line(start: (0pt, 0pt), end: (s, s), stroke: stroke-style)) + ] + } else if style == "h" { + tiling(size: (s, s))[ + #place(native-line(start: (0pt, s / 2), end: (s, s / 2), stroke: stroke-style)) + ] + } else if style == "v" { + tiling(size: (s, s))[ + #place(native-line(start: (s / 2, 0pt), end: (s / 2, s), stroke: stroke-style)) + ] + } else if style == "cross" { + tiling(size: (s, s))[ + #place(native-line(start: (0pt, s), end: (s, 0pt), stroke: stroke-style)) + #place(native-line(start: (0pt, 0pt), end: (s, s), stroke: stroke-style)) + ] + } else if style == "grid" { + tiling(size: (s, s))[ + #place(native-line(start: (0pt, s / 2), end: (s, s / 2), stroke: stroke-style)) + #place(native-line(start: (s / 2, 0pt), end: (s / 2, s), stroke: stroke-style)) + ] + } + } + + set-style( + mark: (fill: black), + stroke: (cap: "round", join: "round"), + content: (padding: 2pt), + ) + + // Background + if s.background.fill != none or s.background.stroke != none { + let (bx1, by1) = to-canvas(xmin, ymin) + let (bx2, by2) = to-canvas(xmax, ymax) + rect((bx1, by1), (bx2, by2), fill: s.background.fill, stroke: s.background.stroke) + } + + // Grid bounds - grid stays within the main plot area (no extension) + // Only the axes extend beyond the grid + let grid-x-start = 0 + let grid-x-end = width + let grid-y-start = 0 + let grid-y-end = height + + // Tick and label dimensions (already unitless floats) + let tick-len = s.ticks.length + let label-offset = s.ticks.label-offset + + // Helper: check if a tick value should have a label displayed + let x-has-label(x) = { + if xtick-labels == none { return false } + if calc.abs(x) < 0.0001 { return false } // 0 handled separately + let label-interval = x-ticks.step * xtick-label-step + let at-interval = calc.abs(calc.rem(x, label-interval)) < 0.0001 or calc.abs(calc.rem(x, label-interval) - label-interval) < 0.0001 + if unit-label-only and calc.abs(x - 1) > 0.0001 { return false } + at-interval + } + + let y-has-label(y) = { + if ytick-labels == none { return false } + if calc.abs(y) < 0.0001 { return false } // 0 handled separately + let label-interval = y-ticks.step * ytick-label-step + let at-interval = calc.abs(calc.rem(y, label-interval)) < 0.0001 or calc.abs(calc.rem(y, label-interval) - label-interval) < 0.0001 + if unit-label-only and calc.abs(y - 1) > 0.0001 { return false } + at-interval + } + + // Pre-compute label exclusion zones for gap-based grid-label-break + // Instead of drawing full grid lines and overlaying white rectangles, + // we draw grid lines with gaps where labels are placed. + // This works on any background color. + let x-break-zones = () // Each: (x-left, x-right, gap-y-bottom, gap-y-top) + let y-break-zones = () // Each: (y-bottom, y-top, gap-x-left, gap-x-right) + + if grid-label-break and (show-grid == "major" or show-grid == "both" or show-grid == true) { + let y-ax-canvas = (x-axis-y - ymin) * y-scale + let x-ax-canvas = (y-axis-x - xmin) * x-scale + + let scale-factor = 1.0 + let pad-x = 0.07 * scale-factor + let pad-y = 0.05 * scale-factor + let char-width = 0.17 * scale-factor + let char-height = 0.25 * scale-factor + let minus-width = 0.10 * scale-factor + + let calc-text-width(val) = { + let label-text = format-number(val) + if val < 0 { + minus-width + (label-text.len() - 1) * char-width + } else { + label-text.len() * char-width + } + } + + // X-axis tick labels (below axis, anchor "north") + for x in x-ticks.ticks { + if x-has-label(x) and calc.abs(x - xmax) > 0.0001 { + let cx = (x - xmin) * x-scale + let text-width = calc-text-width(x) + let anchor-x = cx + let anchor-y = y-ax-canvas - tick-len - label-offset + + x-break-zones.push(( + anchor-x - text-width / 2 - pad-x, + anchor-x + text-width / 2 + pad-x, + anchor-y - char-height - pad-y, + anchor-y + pad-y, + )) + } + } + + // Y-axis tick labels (left of axis, anchor "east") + for y in y-ticks.ticks { + if y-has-label(y) and calc.abs(y - ymax) > 0.0001 { + let cy = (y - ymin) * y-scale + let text-width = calc-text-width(y) + let anchor-x = x-ax-canvas - tick-len - label-offset + let anchor-y = cy + + y-break-zones.push(( + anchor-y - char-height / 2 - pad-y, + anchor-y + char-height / 2 + pad-y, + anchor-x - text-width - pad-x, + anchor-x + pad-x, + )) + } + } + + // Origin label zone (anchor "north-east", text extends down and left) + if show-origin and calc.abs(x-axis-y) < 0.0001 and calc.abs(y-axis-x) < 0.0001 { + let (ox, oy) = to-canvas(0, 0) + let anchor-x = ox - tick-len - 0.05 + let anchor-y = oy - tick-len - 0.05 + let text-width = char-width // Single "0" + + // Origin label can intersect both vertical and horizontal grid lines + x-break-zones.push(( + anchor-x - text-width - pad-x, + anchor-x + pad-x, + anchor-y - char-height - pad-y, + anchor-y + pad-y, + )) + y-break-zones.push(( + anchor-y - char-height - pad-y, + anchor-y + pad-y, + anchor-x - text-width - pad-x, + anchor-x + pad-x, + )) + } + } + + // Helper: draw a vertical line with gaps for break zones + let draw-vline-with-gaps(cx, y-start, y-end, stroke-style) = { + // Collect gap y-ranges that intersect this vertical line's x position + let gaps = () + for zone in x-break-zones { + let (x-left, x-right, gap-y-bottom, gap-y-top) = zone + if cx >= x-left and cx <= x-right { + gaps.push((gap-y-bottom, gap-y-top)) + } + } + + if gaps.len() == 0 { + line((cx, y-start), (cx, y-end), stroke: stroke-style) + } else { + // Sort gaps by bottom y + let sorted-gaps = gaps.sorted(key: g => g.at(0)) + let current-y = y-start + for (gap-bottom, gap-top) in sorted-gaps { + let seg-end = calc.max(current-y, calc.min(gap-bottom, y-end)) + if seg-end > current-y + 0.001 { + line((cx, current-y), (cx, seg-end), stroke: stroke-style) + } + current-y = calc.max(current-y, gap-top) + } + if current-y < y-end - 0.001 { + line((cx, current-y), (cx, y-end), stroke: stroke-style) + } + } + } + + // Helper: draw a horizontal line with gaps for break zones + let draw-hline-with-gaps(cy, x-start, x-end, stroke-style) = { + // Collect gap x-ranges that intersect this horizontal line's y position + let gaps = () + for zone in y-break-zones { + let (y-bottom, y-top, gap-x-left, gap-x-right) = zone + if cy >= y-bottom and cy <= y-top { + gaps.push((gap-x-left, gap-x-right)) + } + } + + if gaps.len() == 0 { + line((x-start, cy), (x-end, cy), stroke: stroke-style) + } else { + // Sort gaps by left x + let sorted-gaps = gaps.sorted(key: g => g.at(0)) + let current-x = x-start + for (gap-left, gap-right) in sorted-gaps { + let seg-end = calc.max(current-x, calc.min(gap-left, x-end)) + if seg-end > current-x + 0.001 { + line((current-x, cy), (seg-end, cy), stroke: stroke-style) + } + current-x = calc.max(current-x, gap-right) + } + if current-x < x-end - 0.001 { + line((current-x, cy), (x-end, cy), stroke: stroke-style) + } + } + } + + // Minor grid + if show-grid == "minor" or show-grid == "both" or show-grid == true { + let minor-x-step = x-ticks.step / minor-grid-step + let minor-y-step = y-ticks.step / minor-grid-step + let nx = int(calc.ceil((xmax - xmin) / minor-x-step)) + 1 + let ny = int(calc.ceil((ymax - ymin) / minor-y-step)) + 1 + + for i in range(nx) { + let x = xmin + i * minor-x-step + if x <= xmax { + let cx = (x - xmin) * x-scale + if grid-label-break and x-break-zones.len() + y-break-zones.len() > 0 { + draw-vline-with-gaps(cx, grid-y-start, grid-y-end, s.grid.minor.stroke) + } else { + line((cx, grid-y-start), (cx, grid-y-end), stroke: s.grid.minor.stroke) + } + } + } + for i in range(ny) { + let y = ymin + i * minor-y-step + if y <= ymax { + let cy = (y - ymin) * y-scale + if grid-label-break and x-break-zones.len() + y-break-zones.len() > 0 { + draw-hline-with-gaps(cy, grid-x-start, grid-x-end, s.grid.minor.stroke) + } else { + line((grid-x-start, cy), (grid-x-end, cy), stroke: s.grid.minor.stroke) + } + } + } + } + + // Major grid + if show-grid == "major" or show-grid == "both" or show-grid == true { + for x in x-ticks.ticks { + let cx = (x - xmin) * x-scale + if grid-label-break and x-break-zones.len() + y-break-zones.len() > 0 { + draw-vline-with-gaps(cx, grid-y-start, grid-y-end, s.grid.major.stroke) + } else { + line((cx, grid-y-start), (cx, grid-y-end), stroke: s.grid.major.stroke) + } + } + for y in y-ticks.ticks { + let cy = (y - ymin) * y-scale + if grid-label-break and x-break-zones.len() + y-break-zones.len() > 0 { + draw-hline-with-gaps(cy, grid-x-start, grid-x-end, s.grid.major.stroke) + } else { + line((grid-x-start, cy), (grid-x-end, cy), stroke: s.grid.major.stroke) + } + } + } + + // Axes (with optional extension beyond plot area) + let (x1, y-ax) = to-canvas(xmin, x-axis-y) + let (x2, _) = to-canvas(xmax, x-axis-y) + let x1-ext = x1 - x-extend.at(0) * x-scale + let x2-ext = x2 + x-extend.at(1) * x-scale + line((x1-ext, y-ax), (x2-ext, y-ax), stroke: s.axis.stroke, mark: (end: s.axis.arrow)) + + let (x-ax, y1) = to-canvas(y-axis-x, ymin) + let (_, y2) = to-canvas(y-axis-x, ymax) + let y1-ext = y1 - y-extend.at(0) * y-scale + let y2-ext = y2 + y-extend.at(1) * y-scale + line((x-ax, y1-ext), (x-ax, y2-ext), stroke: s.axis.stroke, mark: (end: s.axis.arrow)) + + // Ticks and labels (tick-len already defined above) + for (i, x) in x-ticks.ticks.enumerate() { + // Skip tick at xmax (where arrow is) + if calc.abs(x - xmax) < 0.0001 { continue } + let (cx, cy) = to-canvas(x, x-axis-y) + line((cx, cy - tick-len), (cx, cy + tick-len), stroke: s.ticks.stroke) + // Only show label if x is a multiple of (tick-step * label-step) + let label-interval = x-ticks.step * xtick-label-step + let show-this-label = calc.abs(calc.rem(x, label-interval)) < 0.0001 or calc.abs(calc.rem(x, label-interval) - label-interval) < 0.0001 + // If unit-label-only, only show label for x = 1 (not -1 or other values) + if unit-label-only and calc.abs(x - 1) > 0.0001 { + show-this-label = false + } + // Avoid duplicate "0" when explicit origin label is enabled. + if show-origin and calc.abs(x-axis-y) < 0.0001 and calc.abs(y-axis-x) < 0.0001 and calc.abs(x) < 0.0001 { + show-this-label = false + } + if show-this-label and xtick-labels != none { + let label = if xtick-labels == auto { format-number(x) } + else if i < xtick-labels.len() { xtick-labels.at(i) } + else { "" } + let render-label = if type(label) == content { true } + else { label != "" and label != "0" } + if render-label { + content((cx, cy - tick-len - label-offset), + text(size: s.ticks.label-size)[#label], anchor: "north") + } + } + } + + for (i, y) in y-ticks.ticks.enumerate() { + // Skip tick at ymax (where arrow is) + if calc.abs(y - ymax) < 0.0001 { continue } + let (cx, cy) = to-canvas(y-axis-x, y) + line((cx - tick-len, cy), (cx + tick-len, cy), stroke: s.ticks.stroke) + // Only show label if y is a multiple of (tick-step * label-step) + let label-interval = y-ticks.step * ytick-label-step + let show-this-label = calc.abs(calc.rem(y, label-interval)) < 0.0001 or calc.abs(calc.rem(y, label-interval) - label-interval) < 0.0001 + // If unit-label-only, only show label for y = 1 (not -1 or other values) + if unit-label-only and calc.abs(y - 1) > 0.0001 { + show-this-label = false + } + // Avoid duplicate "0" when explicit origin label is enabled. + if show-origin and calc.abs(x-axis-y) < 0.0001 and calc.abs(y-axis-x) < 0.0001 and calc.abs(y) < 0.0001 { + show-this-label = false + } + if show-this-label and ytick-labels != none { + let label = if ytick-labels == auto { format-number(y) } + else if i < ytick-labels.len() { ytick-labels.at(i) } + else { "" } + let render-label = if type(label) == content { true } + else { label != "" and label != "0" } + if render-label { + content((cx - tick-len - label-offset, cy), + text(size: s.ticks.label-size)[#label], anchor: "east") + } + } + } + + // Origin label + if show-origin and calc.abs(x-axis-y) < 0.0001 and calc.abs(y-axis-x) < 0.0001 { + let (ox, oy) = to-canvas(0, 0) + content((ox - tick-len - 0.05, oy - tick-len - 0.05), + text(size: s.ticks.label-size)[0], anchor: "north-east") + } + + // Axis labels - positioned at the extended arrow tips + if xlabel != none { + let (lx, ly) = if xlabel-pos == "end" { + // Position just before the extended arrow tip so labels stay inside tight panels. + let (base-x, base-y) = to-canvas(xmax, x-axis-y) + (base-x + x-extend.at(1) * x-scale - 0.18, base-y) + } else if xlabel-pos == "center" { to-canvas((xmin + xmax) / 2, x-axis-y) } + else if type(xlabel-pos) == array { to-canvas(xlabel-pos.at(0), xlabel-pos.at(1)) } + else { to-canvas(xmax, x-axis-y) } + let (ox, oy) = xlabel-offset + content((lx + ox, ly + oy), text(size: s.labels.size)[#xlabel], anchor: xlabel-anchor) + } + + if ylabel != none { + let (lx, ly) = if ylabel-pos == "end" { + // Position just below the extended arrow tip so labels stay inside tight panels. + let (base-x, base-y) = to-canvas(y-axis-x, ymax) + (base-x, base-y + y-extend.at(1) * y-scale - 0.18) + } else if ylabel-pos == "center" { to-canvas(y-axis-x, (ymin + ymax) / 2) } + else if type(ylabel-pos) == array { to-canvas(ylabel-pos.at(0), ylabel-pos.at(1)) } + else { to-canvas(y-axis-x, ymax) } + let (ox, oy) = ylabel-offset + content((lx + ox, ly + oy), text(size: s.labels.size)[#ylabel], anchor: ylabel-anchor) + } + + // Extended bounds for clipping area + let x-clip-min = xmin - x-extend.at(0) + let x-clip-max = xmax + x-extend.at(1) + let y-clip-min = ymin - y-extend.at(0) + let y-clip-max = ymax + y-extend.at(1) + + // Sampling bounds (extend further so lines reach clip edges) + let sample-margin = calc.max(xmax - xmin, ymax - ymin) * 0.5 + let x-plot-min = x-clip-min - sample-margin + let x-plot-max = x-clip-max + sample-margin + let y-plot-min = y-clip-min - sample-margin + let y-plot-max = y-clip-max + sample-margin + + // Clip bounds in canvas coordinates + let clip-x1 = grid-x-start + let clip-y1 = grid-y-start + let clip-x2 = grid-x-end + let clip-y2 = grid-y-end + + // Merge series: array with positional functions + let all-funcs = if series != none { series + functions.pos() } else { functions.pos() } + + // Plot functions and data (with manual line clipping) + for func-spec in all-funcs { + let fn = func-spec.at("fn", default: none) + let data-points = func-spec.at("points", default: none) + let stroke-style = func-spec.at("stroke", default: s.plot.stroke) + let mark-type = func-spec.at("mark", default: "none") + let mark-size = func-spec.at("mark-size", default: s.marker.size) + let mark-fill = func-spec.at("mark-fill", default: s.marker.fill) + let mark-stroke = func-spec.at("mark-stroke", default: s.marker.stroke) + let mark-interval = func-spec.at("mark-interval", default: 1) + let label = func-spec.at("label", default: none) + let points-to-draw = () + + if fn != none { + let domain = func-spec.at("domain", default: none) + let domain-min = if domain == none { x-plot-min } else { domain.at(0) } + let domain-max = if domain == none { x-plot-max } else { domain.at(1) } + let samples = func-spec.at("samples", default: s.plot.samples) + let step = (domain-max - domain-min) / samples + + // Collect all valid points first + let all-points = () + for i in range(samples + 1) { + let x = domain-min + i * step + let y = fn(x) + if y != none and not float(y).is-nan() { + let (cx, cy) = to-canvas(x, y) + all-points.push((cx, cy, i)) + // Check if point is inside clip area for markers + if cx >= clip-x1 and cx <= clip-x2 and cy >= clip-y1 and cy <= clip-y2 { + points-to-draw.push((cx, cy, i)) + } + } else { + all-points.push(none) // Mark break in function + } + } + + // Draw clipped line segments between consecutive valid points + for j in range(all-points.len() - 1) { + let pt1 = all-points.at(j) + let pt2 = all-points.at(j + 1) + if pt1 != none and pt2 != none { + let (x1, y1, _) = pt1 + let (x2, y2, _) = pt2 + let clipped = clip-segment((x1, y1), (x2, y2), clip-x1, clip-y1, clip-x2, clip-y2) + if clipped != none { + let (p1, p2) = clipped + line(p1, p2, stroke: stroke-style) + } + } + } + + if label != none { + let label-pos = func-spec.at("label-pos", default: 1.0) + let label-side = func-spec.at("label-side", default: none) + // Clip label position to the data range (xmin/xmax). + // Do NOT clip to x-clip-max — that includes axis arrow overshoot and places labels outside the canvas. + // "south-west" text extends right into the axis extension zone, which is natural for end-of-curve labels. + let label-domain-min = if domain == none { xmin } else { calc.max(float(domain-min), xmin) } + let label-domain-max = if domain == none { xmax } else { calc.min(float(domain-max), xmax) } + let label-anchor = if label-side != none { + side-to-anchor(label-side) + } else { + func-spec.at("label-anchor", default: "south-west") + } + let lx = label-domain-min + (label-domain-max - label-domain-min) * label-pos + let ly = fn(lx) + if ly != none and not float(ly).is-nan() and ly >= y-clip-min and ly <= y-clip-max { + let (cx, cy) = to-canvas(lx, ly) + content((cx, cy), label, anchor: label-anchor) + } + } + + } else if data-points != none { + let connect = func-spec.at("connect", default: true) + let canvas-points = () + for (i, pt) in data-points.enumerate() { + let (x, y) = pt + if x >= x-clip-min and x <= x-clip-max and y >= y-clip-min and y <= y-clip-max { + let (cx, cy) = to-canvas(x, y) + canvas-points.push((cx, cy)) + points-to-draw.push((cx, cy, i)) + } + } + if connect and canvas-points.len() > 1 { + line(..canvas-points, stroke: stroke-style) + } + if label != none and canvas-points.len() > 0 { + let label-pos = func-spec.at("label-pos", default: 0.8) + let label-side = func-spec.at("label-side", default: none) + let label-anchor = if label-side != none { side-to-anchor(label-side) } else { func-spec.at("label-anchor", default: "south-west") } + let idx = calc.min(int(canvas-points.len() * label-pos), canvas-points.len() - 1) + let (cx, cy) = canvas-points.at(idx) + content((cx, cy), label, anchor: label-anchor) + } + + // ── Fill below a single function to a baseline ───────────────────── + // Keys: fill:fn, baseline:float, domain:(a,b), color:color, + // hatch:style, hatch-spacing:length, hatch-stroke:stroke, samples:int + } else if "fill" in func-spec { + let fill-fn = func-spec.at("fill") + let baseline = func-spec.at("baseline", default: 0.0) + let domain = func-spec.at("domain", default: (xmin, xmax)) + let samples = func-spec.at("samples", default: 80) + let fill-color = func-spec.at("color", default: luma(220)) + let hatch-style = func-spec.at("hatch", default: none) + let hatch-sp = func-spec.at("hatch-spacing", default: 5pt) + let hatch-stroke = func-spec.at("hatch-stroke", default: luma(80) + 0.5pt) + + let (d1, d2) = domain + let step = (d2 - d1) / samples + let top-pts = () + let bot-pts = () + + for i in range(samples + 1) { + let x = d1 + i * step + let y = fill-fn(x) + if y != none and not float(y).is-nan() { + let (cx, cy-top) = to-canvas(x, float(y)) + let (_, cy-bot) = to-canvas(x, float(baseline)) + top-pts.push((cx, cy-top)) + bot-pts.push((cx, cy-bot)) + } + } + + let all-pts = top-pts + bot-pts.rev() + if all-pts.len() > 2 { + let paint = if hatch-style != none { + make-hatch-pattern(hatch-style, hatch-sp, hatch-stroke) + } else { fill-color } + line(..all-pts, close: true, fill: paint, stroke: none) + } + + // ── Fill between two functions ─────────────────────────────────────── + // Keys: fill-between:(fn1, fn2), domain:(a,b), color:color, + // hatch:style, hatch-spacing:length, hatch-stroke:stroke, samples:int + // Alias: fill-fn1: fn1 (legacy key, fn2 via fill-fn2:) + } else if "fill-between" in func-spec or "fill-fn1" in func-spec { + let (fn1, fn2) = if "fill-between" in func-spec { + func-spec.at("fill-between") + } else { + (func-spec.at("fill-fn1"), func-spec.at("fill-fn2", default: x => 0.0)) + } + let domain = func-spec.at("domain", default: (xmin, xmax)) + let samples = func-spec.at("samples", default: 80) + let fill-color = func-spec.at("color", + default: func-spec.at("fill", default: luma(220))) + let hatch-style = func-spec.at("hatch", default: none) + let hatch-sp = func-spec.at("hatch-spacing", default: 5pt) + let hatch-stroke = func-spec.at("hatch-stroke", default: luma(80) + 0.5pt) + + let (d1, d2) = domain + let step = (d2 - d1) / samples + let fwd-pts = () + let bwd-pts = () + + for i in range(samples + 1) { + let x = d1 + i * step + let y1 = fn1(x) + let y2 = fn2(x) + if (y1 != none and not float(y1).is-nan() + and y2 != none and not float(y2).is-nan()) { + let (cx, cy-top) = to-canvas(x, calc.max(float(y1), float(y2))) + let (_, cy-bot) = to-canvas(x, calc.min(float(y1), float(y2))) + fwd-pts.push((cx, cy-top)) + bwd-pts.push((cx, cy-bot)) + } + } + + let all-pts = fwd-pts + bwd-pts.rev() + if all-pts.len() > 2 { + let paint = if hatch-style != none { + make-hatch-pattern(hatch-style, hatch-sp, hatch-stroke) + } else { fill-color } + line(..all-pts, close: true, fill: paint, stroke: none) + } + + // ── Text annotation at data coordinates ───────────────────────────── + // Keys: annotation:content, pos:(x,y), anchor:string, size:length + } else if "annotation" in func-spec { + let ann-text = func-spec.at("annotation") + let ann-pos = func-spec.at("pos") + let ann-anchor = func-spec.at("anchor", default: "center") + let ann-size = func-spec.at("size", default: 10pt) + let (ax, ay) = ann-pos + let (cx, cy) = to-canvas(ax, ay) + content((cx, cy), text(ann-text, size: ann-size), anchor: ann-anchor) + + // ── Riemann sum rectangles ─────────────────────────────────────────── + // Keys: riemann:fn, domain:(a,b), n:int, method:"left"|"right"|"mid", + // baseline:float, color:color, stroke:stroke, + // hatch:style, hatch-spacing:length, hatch-stroke:stroke + } else if "riemann" in func-spec { + let r-fn = func-spec.at("riemann") + let r-domain = func-spec.at("domain", default: (xmin, xmax)) + let r-n = func-spec.at("n", default: 4) + let r-method = func-spec.at("method", default: "right") + let r-base = func-spec.at("baseline", default: 0.0) + let fill-color = func-spec.at("color", default: luma(220)) + let rect-stroke = func-spec.at("stroke", default: luma(80) + 0.6pt) + let hatch-style = func-spec.at("hatch", default: none) + let hatch-sp = func-spec.at("hatch-spacing", default: 5pt) + let hatch-stk = func-spec.at("hatch-stroke", default: luma(80) + 0.5pt) + + let r-samples = func-spec.at("samples", default: 20) + let r-show-points = func-spec.at("show-points", default: false) + let r-point-color = func-spec.at("point-color", default: rgb("#c94a00")) + let r-point-size = func-spec.at("point-size", default: 0.07) + let r-point-label = func-spec.at("point-label", default: none) + let r-point-lpos = func-spec.at("point-label-pos", default: auto) + let r-show-dx = func-spec.at("show-dx", default: false) + let r-dx-rect = func-spec.at("dx-rect", default: auto) + let r-dx-label = func-spec.at("dx-label", default: $Delta x$) + let r-show-xi = func-spec.at("show-xi", default: false) + let r-xi-labels = func-spec.at("xi-labels", default: auto) + let r-xi-show-values = func-spec.at("xi-show-values", default: false) + + let (d1, d2) = r-domain + let w = (d2 - d1) / r-n + + // Collect canvas evaluation points for dots/arrows (left/right/mid only) + let eval-pts = () + + for i in range(r-n) { + let xl = d1 + i * w + let xr = d1 + (i + 1) * w + let y = if r-method == "lower" or r-method == "upper" { + let sub-ys = () + for j in range(r-samples + 1) { + let x = xl + j * (xr - xl) / r-samples + let v = r-fn(x) + if v != none and not float(v).is-nan() { sub-ys.push(float(v)) } + } + if sub-ys.len() == 0 { none } + else if r-method == "lower" { calc.min(..sub-ys) } + else { calc.max(..sub-ys) } + } else { + let xeval = if r-method == "left" { xl } + else if r-method == "right" { xr } + else { (xl + xr) / 2.0 } + let ev = r-fn(xeval) + if ev != none and not float(ev).is-nan() { + eval-pts.push(to-canvas(xeval, float(ev))) + } + ev + } + if y != none and not float(y).is-nan() { + let yv = float(y) + let (cxl, cybot) = to-canvas(xl, r-base) + let (cxr, cytop) = to-canvas(xr, yv) + let paint = if hatch-style != none { + make-hatch-pattern(hatch-style, hatch-sp, hatch-stk) + } else { fill-color } + rect((cxl, cybot), (cxr, cytop), fill: paint, stroke: rect-stroke) + } + } + + // ── Δx bracket ────────────────────────────────────────────────────── + let dx-di = if r-dx-rect == auto { calc.floor(r-n / 2) } else { r-dx-rect } + if r-show-dx { + let xl = d1 + dx-di * w + let xr = d1 + (dx-di + 1) * w + let (cxl, cy-base) = to-canvas(xl, r-base) + let (cxr, _) = to-canvas(xr, r-base) + let tick-drop = 0.10 + let arrow-y = cy-base - 0.18 + line((cxl, cy-base - 0.02), (cxl, cy-base - tick-drop), stroke: black + 0.5pt) + line((cxr, cy-base - 0.02), (cxr, cy-base - tick-drop), stroke: black + 0.5pt) + line((cxl, arrow-y), (cxr, arrow-y), + mark: (start: (symbol: "stealth", fill: black, scale: 0.35), + end: (symbol: "stealth", fill: black, scale: 0.35)), + stroke: black + 0.5pt) + content(((cxl + cxr) / 2, arrow-y - 0.06), r-dx-label, anchor: "north") + } + + // ── x_i labels ────────────────────────────────────────────────────── + if r-show-xi { + for i in range(r-n + 1) { + // Skip the two indices that straddle the Δx bracket to avoid overlap + if r-show-dx and (i == dx-di or i == dx-di + 1) { continue } + let x = d1 + i * w + let (cx, cy) = to-canvas(x, r-base) + let xi-lbl = if r-xi-labels != auto and i < r-xi-labels.len() { + r-xi-labels.at(i) + } else { + math.attach($x$, b: [#i]) + } + let lbl = if r-xi-show-values { + // Stack: numeric value on top, xi subscript below + let sz = s.ticks.label-size + stack(dir: ttb, spacing: 1pt, + text(size: sz)[$#format-number(x)$], + text(size: sz)[#xi-lbl], + ) + } else { + xi-lbl + } + // Always shift right when xi label lands on the y-axis to avoid overlap + let x-shift = if calc.abs(x - y-axis-x) < 0.001 { 0.35 } else { 0.0 } + content((cx + x-shift, cy - 0.20), lbl, anchor: "north") + } + } + + // ── Endpoint dots + label with arrows ─────────────────────────────── + if r-show-points and eval-pts.len() > 0 { + let lbl-text = if r-point-label == auto { + if r-method == "left" { [Left endpoints] } + else if r-method == "right" { [Right endpoints] } + else if r-method == "mid" { [Midpoints] } + else { none } + } else { r-point-label } + + if lbl-text != none { + let (lx, ly) = if r-point-lpos == auto { + // Place inside the canvas: upper-left for right method, upper-right for left/mid + let frac = if r-method == "right" { 0.25 } else { 0.75 } + let lx-data = d1 + frac * (d2 - d1) + let ly-data = ymax - 0.12 * (ymax - ymin) + to-canvas(lx-data, ly-data) + } else { + to-canvas(r-point-lpos.at(0), r-point-lpos.at(1)) + } + // Arrows first so dots render on top + for (px, py) in eval-pts { + line((lx, ly), (px, py), + mark: (end: (symbol: "stealth", fill: black, scale: 0.35)), + stroke: black + 0.5pt) + } + content((lx, ly), lbl-text, anchor: "center") + } + // Dots on top + for (px, py) in eval-pts { + circle((px, py), radius: r-point-size, fill: r-point-color, stroke: none) + } + } + + // ── Vertical reference line ────────────────────────────────────────── + // Keys: vline:x, ymin:float, ymax:float, stroke:stroke + } else if "vline" in func-spec { + let x0 = func-spec.at("vline") + let vy1 = func-spec.at("ymin", default: ymin) + let vy2 = func-spec.at("ymax", default: ymax) + let (cx, cy1) = to-canvas(x0, vy1) + let (_, cy2) = to-canvas(x0, vy2) + line((cx, cy1), (cx, cy2), stroke: stroke-style) + + // ── Horizontal reference line ──────────────────────────────────────── + // Keys: hline:y, xmin:float, xmax:float, stroke:stroke + } else if "hline" in func-spec { + let y0 = func-spec.at("hline") + let hx1 = func-spec.at("xmin", default: xmin) + let hx2 = func-spec.at("xmax", default: xmax) + let (cx1, cy) = to-canvas(hx1, y0) + let (cx2, _) = to-canvas(hx2, y0) + line((cx1, cy), (cx2, cy), stroke: stroke-style) + + // ── Parametric curve (fn-x(t), fn-y(t)) ───────────────────────────── + // Keys: parametric:(fn-x, fn-y), domain:(t1,t2), samples:int, stroke:stroke + } else if "parametric" in func-spec { + let (par-x, par-y) = func-spec.at("parametric") + let par-domain = func-spec.at("domain", default: (0.0, 1.0)) + let par-samples = func-spec.at("samples", default: 100) + let (t1, t2) = par-domain + let step = (t2 - t1) / par-samples + let all-par-pts = () + for i in range(par-samples + 1) { + let t = t1 + i * step + let px = par-x(t) + let py = par-y(t) + if px != none and py != none and not float(px).is-nan() and not float(py).is-nan() { + all-par-pts.push(to-canvas(float(px), float(py))) + } else { + all-par-pts.push(none) + } + } + for j in range(all-par-pts.len() - 1) { + let pt1 = all-par-pts.at(j) + let pt2 = all-par-pts.at(j + 1) + if pt1 != none and pt2 != none { + let clipped = clip-segment(pt1, pt2, clip-x1, clip-y1, clip-x2, clip-y2) + if clipped != none { + let (p1, p2) = clipped + line(p1, p2, stroke: stroke-style) + } + } + } + + // ── Fill area enclosed by parametric closed curve ──────────────────── + // Keys: fill-closed:(fn-x, fn-y), domain:(t1,t2), color:color, + // hatch:style, hatch-spacing:length, hatch-stroke:stroke, samples:int + } else if "fill-closed" in func-spec { + let (par-x, par-y) = func-spec.at("fill-closed") + let par-domain = func-spec.at("domain", default: (0.0, 1.0)) + let par-samples = func-spec.at("samples", default: 80) + let fill-color = func-spec.at("color", default: luma(220)) + let hatch-style = func-spec.at("hatch", default: none) + let hatch-sp = func-spec.at("hatch-spacing", default: 5pt) + let hatch-stroke = func-spec.at("hatch-stroke", default: luma(80) + 0.5pt) + let (t1, t2) = par-domain + let step = (t2 - t1) / par-samples + let par-pts = () + for i in range(par-samples + 1) { + let t = t1 + i * step + let px = par-x(t) + let py = par-y(t) + if px != none and py != none and not float(px).is-nan() and not float(py).is-nan() { + par-pts.push(to-canvas(float(px), float(py))) + } + } + if par-pts.len() > 2 { + let paint = if hatch-style != none { + make-hatch-pattern(hatch-style, hatch-sp, hatch-stroke) + } else { fill-color } + line(..par-pts, close: true, fill: paint, stroke: none) + } + } + + if mark-type != "none" and points-to-draw.len() > 0 { + for (cx, cy, i) in points-to-draw { + if calc.rem(i, mark-interval) == 0 { + draw-marker(none, (cx, cy), mark-type, mark-size, mark-fill, mark-stroke) + } + } + } + } + }) +} + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +/// Quick single function plot with auto-scaling. +#let plot-fn( + fn, + domain: (-5, 5), + ymin: auto, + ymax: auto, + stroke: blue + 1.2pt, + ..args +) = { + let samples = args.named().at("samples", default: 100) + let (y-min, y-max) = if ymin == auto or ymax == auto { + let ys = () + let step = (domain.at(1) - domain.at(0)) / samples + for i in range(samples + 1) { + let x = domain.at(0) + i * step + let y = fn(x) + if y != none and not float(y).is-nan() { ys.push(y) } + } + let min-y = calc.min(..ys) + let max-y = calc.max(..ys) + let padding = (max-y - min-y) * 0.1 + (min-y - padding, max-y + padding) + } else { (ymin, ymax) } + + plot( + xmin: domain.at(0), xmax: domain.at(1), + ymin: if ymin == auto { y-min } else { ymin }, + ymax: if ymax == auto { y-max } else { ymax }, + ..args, + (fn: fn, stroke: stroke, domain: domain), + ) +} + +/// Create a scatter plot specification. +#let scatter( + points, + mark: "*", + mark-size: 0.12, + mark-fill: blue, + mark-stroke: blue + 0.8pt, + connect: false, + stroke: none, + label: none, + label-pos: 0.8, + label-anchor: "south-west", +) = ( + points: points, mark: mark, mark-size: mark-size, + mark-fill: mark-fill, mark-stroke: mark-stroke, + connect: connect, stroke: stroke, label: label, + label-pos: label-pos, label-anchor: label-anchor, +) + +/// Create a line plot with markers specification. +#let line-plot( + points, + stroke: blue + 1.2pt, + mark: "o", + mark-size: 0.1, + mark-fill: white, + mark-stroke: blue + 0.8pt, + label: none, + label-pos: 0.8, + label-anchor: "south-west", +) = ( + points: points, stroke: stroke, mark: mark, + mark-size: mark-size, mark-fill: mark-fill, + mark-stroke: mark-stroke, connect: true, + label: label, label-pos: label-pos, label-anchor: label-anchor, +) + +/// Create a function plot specification with markers. +#let func-plot( + fn, + domain: auto, + stroke: blue + 1.2pt, + samples: 100, + mark: "none", + mark-size: 0.1, + mark-fill: blue, + mark-stroke: blue + 0.8pt, + mark-interval: 10, + label: none, + label-pos: 0.8, + label-anchor: "south-west", +) = { + let spec = ( + fn: fn, stroke: stroke, samples: samples, + mark: mark, mark-size: mark-size, mark-fill: mark-fill, + mark-stroke: mark-stroke, mark-interval: mark-interval, + label: label, label-pos: label-pos, label-anchor: label-anchor, + ) + if domain != auto { spec.insert("domain", domain) } + spec +} + +/// Build a fill-below-curve series spec. +/// +/// Fills the region between `fn` and `baseline` (default 0) over `domain`. +/// +/// Example: +/// ```typst +/// #plot(..., +/// fill-area(x => calc.sin(x), domain: (0, calc.pi), color: blue.lighten(70%)), +/// (fn: x => calc.sin(x), stroke: blue + 1.2pt), +/// ) +/// ``` +#let fill-area( + fn, + domain: auto, + baseline: 0.0, + color: luma(220), + hatch: none, + hatch-spacing: 5pt, + hatch-stroke: luma(80) + 0.5pt, + samples: 80, +) = { + let spec = ( + fill: fn, baseline: baseline, color: color, + hatch: hatch, hatch-spacing: hatch-spacing, hatch-stroke: hatch-stroke, + samples: samples, + ) + if domain != auto { spec.insert("domain", domain) } + spec +} + +/// Build a fill-between-curves series spec. +/// +/// Fills the region between `fn1` and `fn2` over `domain`. +/// The filled shape always encloses both curves (uses max/min at each sample). +/// +/// Hatch styles: `"ne"` (/), `"nw"` (\), `"h"`, `"v"`, `"cross"`, `"grid"`. +/// +/// Example: +/// ```typst +/// #plot(..., +/// area-between(x => calc.exp(x), x => x + 1, domain: (0, 1), +/// color: green.lighten(60%)), +/// ) +/// ``` +#let area-between( + fn1, + fn2, + domain: auto, + color: luma(220), + hatch: none, + hatch-spacing: 5pt, + hatch-stroke: luma(80) + 0.5pt, + samples: 80, +) = { + let spec = ( + fill-between: (fn1, fn2), color: color, + hatch: hatch, hatch-spacing: hatch-spacing, hatch-stroke: hatch-stroke, + samples: samples, + ) + if domain != auto { spec.insert("domain", domain) } + spec +} + +/// Place a text annotation at a data-coordinate position. +/// +/// Example: +/// ```typst +/// #plot(..., +/// note([AV : $x = 0$], pos: (0.4, -2.5), anchor: "west"), +/// ) +/// ``` +#let note( + body, + pos, + anchor: "center", + size: 9pt, +) = (annotation: body, pos: pos, anchor: anchor, size: size) + +/// Vertical reference line at x = `x0`. +/// +/// Example: +/// ```typst +/// (vline: 1.0, stroke: (dash: "dashed") + luma(100) + 0.6pt) +/// ``` +#let vline(x0, stroke: luma(100) + 0.6pt, ymin: auto, ymax: auto) = { + let spec = (vline: x0, stroke: stroke) + if ymin != auto { spec.insert("ymin", ymin) } + if ymax != auto { spec.insert("ymax", ymax) } + spec +} + +/// Horizontal reference line at y = `y0`. +/// +/// Example: +/// ```typst +/// (hline: 0.0, stroke: (dash: "dashed") + luma(100) + 0.6pt) +/// ``` +#let hline(y0, stroke: luma(100) + 0.6pt, xmin: auto, xmax: auto) = { + let spec = (hline: y0, stroke: stroke) + if xmin != auto { spec.insert("xmin", xmin) } + if xmax != auto { spec.insert("xmax", xmax) } + spec +} + +/// Draw Riemann sum rectangles for `fn` over `domain` with `n` subdivisions. +/// +/// - method: `"left"`, `"right"`, `"mid"`, `"lower"` (true infimum), `"upper"` (true supremum) +/// - samples: sample points per subinterval for `"lower"`/`"upper"` (default 20) +/// - show-points: draw a dot at each evaluation point (left/right/mid only) +/// - point-color: fill color of the dots (default dark orange) +/// - point-size: radius of dots in cm (default 0.07) +/// - point-label: content label with arrows to dots; `auto` = method-based text, `none` = no label +/// - point-label-pos: (x,y) in data coords for the label; `auto` = upper-right of dots +/// - show-dx: draw a Δx dimension bracket under one rectangle +/// - dx-rect: index of rectangle to annotate (0-based); `auto` = middle rectangle +/// - dx-label: content for the bracket label (default $Delta x$) +/// - show-xi: draw x₀, x₁, … labels at subdivision points below the axis +/// - xi-labels: array of content overrides; `auto` = generate x_i subscripts +/// - xi-show-values: if true, stack the numeric x value above each xi label +/// - Hatch styles: `"ne"`, `"nw"`, `"h"`, `"v"`, `"cross"`, `"grid"` +#let riemann-sum( + fn, + domain: auto, + n: 4, + method: "right", + baseline: 0.0, + color: luma(220), + stroke: luma(80) + 0.6pt, + hatch: none, + hatch-spacing: 5pt, + hatch-stroke: luma(80) + 0.5pt, + samples: 20, + show-points: false, + point-color: rgb("#c94a00"), + point-size: 0.07, + point-label: auto, + point-label-pos: auto, + show-dx: false, + dx-rect: auto, + dx-label: $Delta x$, + show-xi: false, + xi-labels: auto, + xi-show-values: false, +) = { + let spec = ( + riemann: fn, n: n, method: method, baseline: baseline, + color: color, stroke: stroke, + hatch: hatch, hatch-spacing: hatch-spacing, hatch-stroke: hatch-stroke, + samples: samples, + show-points: show-points, point-color: point-color, point-size: point-size, + point-label: point-label, point-label-pos: point-label-pos, + show-dx: show-dx, dx-rect: dx-rect, dx-label: dx-label, + show-xi: show-xi, xi-labels: xi-labels, xi-show-values: xi-show-values, + ) + if domain != auto { spec.insert("domain", domain) } + spec +} + +/// Parametric curve specification: plot the curve (fn-x(t), fn-y(t)) for t in domain. +/// +/// Example (unit circle): +/// ```typst +/// #plot(xmin: -1.5, xmax: 1.5, ymin: -1.5, ymax: 1.5, +/// parametric(t => calc.cos(t), t => calc.sin(t), domain: (0, 2*calc.pi)), +/// ) +/// ``` +#let parametric( + fn-x, + fn-y, + domain: (0.0, 1.0), + stroke: blue + 1.2pt, + samples: 100, +) = (parametric: (fn-x, fn-y), domain: domain, stroke: stroke, samples: samples) + +/// Fill area enclosed by a parametric closed curve (fn-x(t), fn-y(t)). +/// +/// The curve should be closed: (fn-x(t1), fn-y(t1)) ≈ (fn-x(t2), fn-y(t2)). +/// +/// Example (filled unit circle): +/// ```typst +/// #plot(xmin: -1.5, xmax: 1.5, ymin: -1.5, ymax: 1.5, +/// fill-closed(t => calc.cos(t), t => calc.sin(t), domain: (0, 2*calc.pi), +/// color: blue.lighten(70%)), +/// ) +/// ``` +#let fill-closed( + fn-x, + fn-y, + domain: (0.0, 1.0), + color: luma(220), + hatch: none, + hatch-spacing: 5pt, + hatch-stroke: luma(80) + 0.5pt, + samples: 80, +) = ( + fill-closed: (fn-x, fn-y), domain: domain, color: color, + hatch: hatch, hatch-spacing: hatch-spacing, hatch-stroke: hatch-stroke, + samples: samples, +) + +// ============================================================================ +// SOLID OF REVOLUTION ILLUSTRATION +// ============================================================================ + +/// Draw a 3D-style solid of revolution illustration. +/// +/// Rotates the region between y = fn(x) and y = axis-y from x=a to x=b. +/// Renders a perspective view with profile curves, end caps, and disk cross-sections. +/// +/// - fn: profile function y = f(x) > 0 +/// - domain: (a, b) — interval of revolution +/// - axis-y: y-value of the horizontal axis of revolution (default: 0, the x-axis) +/// - n-disks: number of intermediate circular cross-sections to show +/// - width, height: canvas size in cm +/// - samples: number of points to sample the profile +/// - show-axis: draw x-axis through center +/// - show-y-axis: draw a coordinate y-axis +/// - show-labels: show a, b, f labels +/// - profile-stroke: stroke for the top profile curve +/// - disk-color: fill color for the solid body +/// - label-a, label-b, label-f: content for axis position labels and function label +/// +/// Example: +/// ```typst +/// #volume-of-revolution(x => calc.sqrt(x + 1), domain: (0, 3), n-disks: 4) +/// ``` +#let volume-of-revolution( + fn, + domain: (0.0, 4.0), + n-disks: 4, + width: 5.0, + height: 3.5, + samples: 60, + show-axis: true, + show-y-axis: false, + y-axis-x: auto, + y-axis-offset: 0.45, + y-axis-extend: (0.35, 0.45), + axis-y: 0.0, + axis-slope: 0.0, // slope m: revolution axis is y = m*x + axis-y + show-yaxis: false, + show-radius-marker: false, + yaxis-x: auto, + show-labels: true, + show-back: true, + profile-stroke: blue + 1.5pt, + disk-color: luma(218), + disk-stroke: luma(90) + 0.6pt, + axis-stroke: black + 0.7pt, + label-a: $a$, + label-b: $b$, + label-f: $f$, + label-y: $y$, +) = { + let (a, b) = domain + let m = float(axis-slope) + let b0 = float(axis-y) // y-intercept of axis: y = m*x + b0 + let dsq = 1.0 + m * m // denominator 1 + m² + + // Perpendicular distance from (px, py) to the axis line y = m*px + b0. + let perp-r(px, py) = calc.abs(py - m * px - b0) / calc.sqrt(dsq) + + // X-coordinate of the foot of the perpendicular from (px, py) onto the axis. + let foot-x(px, py) = (px + m * (py - b0)) / dsq + + // Sample curve to find foot-x range and maximum radius. + let n-s = samples + let step = (b - a) / n-s + let foot-xs = () + let radii = () + for i in range(n-s + 1) { + let x = a + i * step + let y = fn(x) + if y != none and not float(y).is-nan() { + let fy = float(y) + let r = perp-r(x, fy) + if r > 0.0 { + foot-xs.push(foot-x(x, fy)) + radii.push(r) + } + } + } + let fx-min = if foot-xs.len() > 0 { calc.min(..foot-xs) } else { float(a) } + let fx-max = if foot-xs.len() > 0 { calc.max(..foot-xs) } else { float(b) } + let r-max = if radii.len() > 0 { calc.max(..radii) } else { 1.0 } + + let x-sc = width / (fx-max - fx-min) + let y-sc = (height / 2.0) / r-max + let ell-r = 0.30 + + // Canvas coordinate helpers — both take the original (x, y) point. + let cx(px, py) = (foot-x(px, float(py)) - fx-min) * x-sc + let cr(px, py) = perp-r(px, float(py)) * y-sc + + let safe-cr(raw, px) = { + if raw == none { 0.0 } + else { + let v = float(raw) + if v.is-nan() { 0.0 } else { perp-r(px, v) * y-sc } + } + } + + cetz.canvas(length: 1cm, { + import cetz.draw: * + + let axis-cy = 0.0 + let disk-steps = 36 + let dashed-stroke = (paint: luma(160), thickness: 0.45pt, dash: "dashed") + let draw-coordinate-y-axis = show-y-axis or show-yaxis + let draw-radius-marker = show-radius-marker + + let ellipse-half(ex, radius, side) = { + let pts = () + let er = radius * ell-r + for j in range(disk-steps + 1) { + let t = j / disk-steps + let angle = if side == "front" { + -90deg + t * 180deg + } else if side == "back" { + 90deg + t * 180deg + } else if side == "upper" { + 0deg + t * 180deg + } else if side == "upper-front" { + 0deg + t * 90deg + } else if side == "upper-back" { + 90deg + t * 90deg + } else { + 180deg + t * 180deg + } + pts.push(( + ex + er * calc.cos(angle), + axis-cy + radius * calc.sin(angle), + )) + } + pts + } + + let ellipse-full(ex, radius) = { + let pts = () + let er = radius * ell-r + for j in range(disk-steps + 1) { + let t = j / disk-steps + let angle = -90deg + t * 360deg + pts.push(( + ex + er * calc.cos(angle), + axis-cy + radius * calc.sin(angle), + )) + } + pts + } + + // Collect profile points. + let top-pts = () + let bot-pts = () + for i in range(n-s + 1) { + let x = a + i * step + let y = fn(x) + if y != none and not float(y).is-nan() { + let fy = float(y) + let r = cr(x, fy) + if r > 0.0 { + top-pts.push((cx(x, fy), axis-cy + r)) + bot-pts.push((cx(x, fy), axis-cy - r)) + } + } + } + + // Right cap at x=b, drawn first so the closing disk sits behind the body. + let yb = fn(b) + if yb != none { + let fyb = float(yb) + let ex = cx(b, fyb) + let radius = safe-cr(yb, b) + if radius > 0.02 { + if show-back { + line(..ellipse-full(ex, radius), close: true, fill: disk-color, stroke: none) + line(..ellipse-half(ex, radius, "back"), stroke: (paint: luma(140), thickness: 0.4pt, dash: "dashed")) + } else { + line(..ellipse-half(ex, radius, "upper"), close: true, fill: disk-color, stroke: none) + line(..ellipse-half(ex, radius, "upper-back"), stroke: dashed-stroke) + } + } + } + + // Filled solid body + if top-pts.len() > 0 { + if show-back { + line(..(top-pts + bot-pts.rev()), close: true, fill: disk-color, stroke: none) + } else { + let x-left = top-pts.first().at(0) + let x-right = top-pts.last().at(0) + line(..(top-pts + ((x-right, axis-cy), (x-left, axis-cy))), close: true, + fill: disk-color, stroke: none) + } + } + + // Intermediate disk cross-sections + for i in range(1, n-disks + 1) { + let xd = a + i * (b - a) / (n-disks + 1) + let yd = fn(xd) + if yd != none and not float(yd).is-nan() { + let fyd = float(yd) + let ex = cx(xd, fyd) + let radius = cr(xd, fyd) + if radius > 0.02 { + if show-back { + line(..ellipse-half(ex, radius, "back"), stroke: dashed-stroke) + line(..ellipse-half(ex, radius, "front"), stroke: disk-stroke) + } else { + line(..ellipse-half(ex, radius, "upper-back"), stroke: dashed-stroke) + line(..ellipse-half(ex, radius, "upper-front"), stroke: disk-stroke) + } + } + } + } + + // Bottom profile (dashed) + if show-back and bot-pts.len() > 1 { + line(..bot-pts, stroke: dashed-stroke) + } + + // Left cap at x=a + let ya = fn(a) + if ya != none { + let fya = float(ya) + let ex = cx(a, fya) + let radius = safe-cr(ya, a) + if radius > 0.02 { + if show-back { + line(..ellipse-full(ex, radius), close: true, fill: disk-color, stroke: none) + line(..ellipse-half(ex, radius, "back"), stroke: (paint: luma(170), thickness: 0.4pt, dash: "dashed")) + line(..ellipse-half(ex, radius, "front"), stroke: (paint: luma(100), thickness: 0.5pt)) + } else { + line(..ellipse-half(ex, radius, "upper"), close: true, fill: disk-color, stroke: none) + line(..ellipse-half(ex, radius, "upper-back"), stroke: dashed-stroke) + line(..ellipse-half(ex, radius, "upper-front"), stroke: (paint: luma(100), thickness: 0.5pt)) + } + } + } + + // Top profile + if top-pts.len() > 1 { + line(..top-pts, stroke: profile-stroke) + } + + let coord-y-axis-x = if draw-coordinate-y-axis { + if y-axis-x == auto { + -float(y-axis-offset) + } else { + let xv = y-axis-x + let yv-raw = fn(xv) + if yv-raw != none { cx(xv, float(yv-raw)) } else { 0.0 } + } + } else { none } + + // Coordinate y-axis (optional) + if draw-coordinate-y-axis { + let (ext-bot, ext-top) = if type(y-axis-extend) == array { y-axis-extend } else { (y-axis-extend, y-axis-extend) } + let y-bottom = -height / 2.0 - ext-bot + let y-top = height / 2.0 + ext-top + line((coord-y-axis-x, y-bottom), (coord-y-axis-x, axis-cy + y-top), stroke: axis-stroke, + mark: (end: (symbol: "stealth", fill: black, scale: 0.55))) + content((coord-y-axis-x - 0.05, axis-cy + y-top), label-y, anchor: "east") + } + + // Radius marker (optional) + if draw-radius-marker { + let xv = if yaxis-x == auto { a } else { yaxis-x } + let yv-raw = fn(xv) + let yv = safe-cr(yv-raw, xv) + if yv > 0.05 { + let yax-x = if yv-raw != none { cx(xv, float(yv-raw)) } else { 0.0 } + line((yax-x, axis-cy), (yax-x, axis-cy + yv + 0.35), stroke: axis-stroke, + mark: (end: (symbol: "stealth", fill: black, scale: 0.55))) + content((yax-x - 0.05, axis-cy + yv + 0.35), label-y, anchor: "east") + } + } + + // Revolution axis arrow + if show-axis { + let axis-start-x = if coord-y-axis-x != none { coord-y-axis-x } else { -0.25 } + line((axis-start-x, axis-cy), (width + 0.4, axis-cy), stroke: axis-stroke, + mark: (end: (symbol: "stealth", fill: black, scale: 0.55))) + content((width + 0.45, axis-cy - 0.03), $x$, anchor: "north-west") + } + + // Tick marks and labels at domain endpoints + if show-labels { + let tick = 0.15 + // Canvas positions of the domain endpoints' foot projections + let cx-a = if ya != none { cx(a, float(ya)) } else { 0.0 } + let cx-b = if yb != none { cx(b, float(yb)) } else { width } + line((cx-a, axis-cy + tick), (cx-a, axis-cy - tick), stroke: axis-stroke) + line((cx-b, axis-cy + tick), (cx-b, axis-cy - tick), stroke: axis-stroke) + content((cx-a, axis-cy - (tick + 0.08)), label-a, anchor: "north") + content((cx-b, axis-cy - (tick + 0.08)), label-b, anchor: "north") + let lx = a + (b - a) * 0.18 + let ly = fn(lx) + if ly != none { + let fly = float(ly) + content((cx(lx, fly), axis-cy + safe-cr(ly, lx) + 0.22), label-f, anchor: "south") + } + } + }) +} + +#let solid-of-revolution = volume-of-revolution diff --git a/packages/preview/simple-plot/0.8.0/typst.toml b/packages/preview/simple-plot/0.8.0/typst.toml new file mode 100644 index 0000000000..d3c1fc6015 --- /dev/null +++ b/packages/preview/simple-plot/0.8.0/typst.toml @@ -0,0 +1,13 @@ +[package] +name = "simple-plot" +version = "0.8.0" +entrypoint = "lib.typ" +authors = ["Nathan Scheinmann"] +license = "MIT" +description = "Simple, pgfplots-like function plotting for Typst" +repository = "https://github.com/nathan-ed/typst-package-simple-plot" +keywords = ["plot", "graph", "function", "math", "visualization", "chart", "axis"] +categories = ["visualization"] +disciplines = ["mathematics", "physics", "engineering"] +compiler = "0.11.0" +exclude = ["examples/*", "docs/*", "*.pdf"]