Documentation
pie is a Markdown-based static site generator for interactive science explainers. A page is a Markdown file. Readers can run Python in the page, and that Python can draw on a canvas next to the text. Every feature below has a live demo on the examples page.
Getting started
pie needs Node 24. Create a site and start the dev server:
npx @tinyvolt/pie init my-site
cd my-site
npm install
npm run dev
| Command | Does |
|---|---|
npm run dev |
starts the dev server |
npm run build |
builds the site into .output/, and checks it (see Build checks) |
npm run start |
runs the built site |
npm run preview |
previews the build with vite |
Folders
| Folder / file | Holds |
|---|---|
pages/ |
Markdown pages |
functions/ |
your own TypeScript functions, called from Python as pie.<keyword>(...) |
nodes/ |
your own shapes: what they hold and how they are drawn |
layouts/ |
optional header.tsx and index.tsx, to replace the header and the homepage |
styles/ |
CSS that loads after the built-in styles, so it wins |
public/ |
static files, served as they are |
site.yml |
site settings |
Don't edit node_modules/@tinyvolt/pie/: installing or updating the package replaces it.
Site settings: site.yml
One key: value per line. Values may be quoted. Each key may appear only once.
| Key | Meaning |
|---|---|
title |
Required. Shown in the header and in every browser tab. |
url |
Where the site is deployed, e.g. https://example.com. See SEO. |
description |
Used in search results and link previews for a page with no description of its own. |
sidebar |
The sidebar for every page that doesn't set its own, relative to the project root, e.g. pages/_topics.md. |
toc |
true gives every page a table of contents, unless the page sets toc: false. |
Pages
pages/a/b.md is served at /a/b. /pages lists every page.
A file whose name starts with _, e.g. pages/_topics.md, is not a page: it gets no URL and isn't listed. Use files like that as sidebars.
pages/index.md is served at /index, not /. The homepage always comes from layouts/index.tsx, or the built-in one.
A page's URL can't be / or /pages, because pie uses those. For example, pages/pages.md fails the build.
Frontmatter
Optional settings at the very top of a page, one key: value per line:
---
title: Circles
canvas: right
toc: true
---
| Key | Values |
|---|---|
title |
The tab title. Left out, the tab shows only the site title. |
description |
Used in search results and link previews. Left out: site.yml's description. |
canvas |
left, right, top, bottom, or none (the default). Adds a canvas on that side of the page. |
sidebar |
A Markdown file to show as a sidebar, relative to the page, e.g. _topics.md. none turns off the one from site.yml. |
toc |
true or false. Left out: site.yml's toc, or else false. |
Other keys are ignored. A bad value fails the build, and shows an error screen in dev.
Math
Math uses KaTeX. Write $e^{i\pi} + 1 = 0$ inline for , or put $$…$$ on lines of its own for a display block:
Sidebar
sidebar: _topics.md shows that file in a collapsible sidebar on the left. It starts closed, and a button opens it. On screens narrower than 768px it opens over the page, and tapping outside it or on one of its links closes it.
- The sidebar file's own frontmatter is ignored.
- A sidebar file that doesn't exist, or is outside the project, fails the build.
- Python editors in a sidebar share variables only with each other, and have no canvas.
Table of contents
toc: true adds a list of the page's ## and ### headings. It goes after the page's leading # title, or at the very start if there is none. Each ### is nested under the ## before it. Every heading gets an id to link to, e.g. [Build checks](#build-checks). Repeated headings get -2, -3, ….
Python editors
A python-editor code fence becomes an editor with Run and Stop buttons. A plain python fence is only highlighted.
```python-editor
import math
print(math.pi)
```
- Python runs in the reader's browser with Pyodide 0.28.2. No server runs the code.
import numpy,scipy,matplotliband the other Pyodide packages load the first time they are imported.plt.show()shows a matplotlib figure under the editor.- All editors on a page share the same variables and the same canvas.
```python-editor collapsedstarts the editor collapsed: only its toolbar shows until the reader opens it.
Demo: Python.
Drawing on the canvas
In an editor, pie is already imported. Each pie.<keyword>(...) call makes a node: one thing the page keeps track of, such as a point or a circle. Nodes are drawn on the canvas.
a = pie.point(1, 2, out='a')
r = pie.scalar(1, out='r')
pie.circle(a, r)
Arguments can be nodes or plain numbers. The canvas's y-axis points up, and one grid unit is 50 pixels unless you change it.
Demo: Canvas.
Naming nodes with out
out='<id>' gives a node its id. Without it, the node gets an automatic one.
center = pie.point(1, 2) does not give the node the id center. The Python variable and the node's id are separate. To name the node, write pie.point(1, 2, out='center').
Colors
fill='<color>' and stroke='<color>' color a node, e.g. pie.circle(a, r, fill='orange', stroke='#333').
- A color is any CSS color (
'red','#ff0000','rgb(255, 0, 0)') or'none'. A bad color raises an error. - Every node a call makes gets the colors, including extra ones such as the center point of
ellipse_from_foci. - A node shows only the colors it is drawn with: a point uses only
fill, a line onlystroke. A gradient arrow usesstrokefor its line andfillfor its head.
Built-in functions
| Call | Makes |
|---|---|
pie.scalar(value) |
a number |
pie.text(value, x, y, font_size, w, h) |
a string, drawn centered at (x, y) only if both are given. All optional: value '', font_size 12 pixels, w and h 0. For w/h: both 0 is one line with no box; above 0 is a fixed size; -1 fits the text, up to 5 units. |
pie.point(x, y) |
a point |
pie.line(p1, p2) |
a line |
pie.distance(p1, p2) |
a number |
pie.triangle(v1, v2, v3) |
a triangle |
pie.circle(center, radius) |
a circle |
pie.ellipse(center, radius_x, radius_y, rotation) |
an ellipse, rotation in degrees |
pie.ellipse_from_foci(f1, f2, string_length) |
an ellipse with foci f1 and f2 |
pie.arc(center, radius, start_angle, end_angle) |
an arc, counterclockwise, angles in degrees |
pie.bezier_quadratic(p1, control, p2) |
a curve |
pie.bezier_cubic(p1, control1, control2, p2) |
a curve |
pie.partial(target, var) |
a derivative: see Autodiff |
Every call also takes hidden=True, to make a node that isn't drawn.
Canvas settings
Three nodes always exist. Set them like any other node:
| Node | Default | Example |
|---|---|---|
unit: pixels per grid unit |
50 | pie.scalar(80, out='unit') |
gridOpacity: multiplies the opacity of the grid and axes; 0 hides them |
1 | pie.scalar(0.5, out='gridOpacity') |
canvasColor: the canvas background, a CSS color |
'', no background |
pie.text('#111', out='canvasColor') |
Changing nodes
These change existing nodes instead of making new ones. They run once, when called.
| Call | Does |
|---|---|
pie.clear() |
removes every node, and resets unit, gridOpacity and canvasColor |
pie.translate_point_to(p, x, y) |
moves point p to (x, y) |
pie.translate_point_by(p, dx, dy) |
moves point p by (dx, dy) |
pie.translate_scalar_to(s, value) |
sets scalar s to value |
pie.translate_scalar_by(s, delta) |
adds delta to scalar s |
pie.set_fill(node, color) |
changes node's fill |
pie.set_stroke(node, color) |
changes node's stroke |
pie.hide(node) |
stops drawing node |
pie.show(node) |
draws a hidden node again |
pie.remove(node) |
removes node, and every node built from it |
p,sornodecan be an array: every item changes.- A moved node stops following the nodes it was built from. Nodes built from it follow the move. A moved point keeps its colors.
Reactivity
A node remembers the nodes it was built from. Call again with the same out and that node is replaced, and everything built from it updates:
pie.scalar(3, out='r') # the circle built from r now has radius 3
Demo: Reactivity.
Animation
The four translate_* calls take animate=True. The value then glides to its new value over half a second instead of jumping:
pie.translate_point_to(p, 3, 4, animate=True)
The call returns when the move ends, so a loop of animated calls plays one move after another. Items of an array move together. Any other call raises an error if passed animate.
Demo: Animation.
Arrays and broadcasting
Pass an array where a function expects one item, and the function runs once per item:
pie.point([0, 1, 2, 3], 0) # 4 points on the x-axis
A Python list is an array, and a list of lists has more than one dimension: [[1], [2]] has shape (2, 1). A numpy array must be turned into a list with .tolist() first. Several arrays are matched the way numpy does it: shapes are lined up from the right, and each pair of sizes must be equal or one of them must be 1. So centers with shape (3,) and radii with shape (4, 1) give circles with shape (4, 3).
An array node can be indexed like a numpy array: circles[0], circles[:, 1], circles[2, 0].
pie.partial doesn't take arrays.
Demo: Broadcasting.
Lifting Python functions: pie.lift
pie.lift(f, out_type) turns a plain Python function into one that takes nodes and returns a node. It reruns whenever its input nodes change. out_type is one of pie.Scalar, pie.Point, pie.Text (or str), pie.Array[pie.Scalar] and pie.Array[pie.Point].
import numpy as np
linspace = pie.lift(np.linspace, out_type=pie.Array[pie.Scalar])
n = pie.scalar(5, out='n')
xs = linspace(0, n, 5)
pie.point(xs, 0) # 5 points from 0 to 5
Set n again with pie.scalar(3, out='n'), and the points move to lie between 0 and 3.
The function gets a point as a tuple (x, y), and an array as a numpy array. To return a point, return a tuple or list of 2 numbers. A lifted function also takes fill, stroke, out and hidden.
Limits:
- A lifted function can't call
pie.*or index arrays. pie.partialcan't differentiate through a lifted function.- Don't give the lifted function the same name as the original, as in
f = pie.lift(f, ...). pie finds a function defined on the page by its name, so the rerun would call the lifted function instead off.
Autodiff
pie.partial(target, var) is the derivative of scalar target with respect to var:
- If
varis a scalar node, the result is a number. - If
varis a point node, the result is the gradient, drawn as an arrow at the point. It points the waytargetgrows fastest, and its length is how fast it grows.
a = pie.point(0, 0, out='a')
b = pie.point(3, 4, out='b')
d = pie.distance(a, b)
pie.partial(d, a) # an arrow of length 1 at a, pointing away from b
The derivative is exact, not an estimate: pie differentiates each step that computed target (automatic differentiation). It updates as the drawing changes. var must be a node, not a plain number.
Demo: Autodiff.
Your own functions
Each functions/**/*.ts file exports one or more Node2NodeFunc objects. Each one becomes pie.<keyword> in Python. For example, functions/midpoint.ts:
import { Node2NodeFunc } from '~/lib/nodes/functions/Node2NodeFunc';
import { AnyNode, PointNode } from '~/lib/nodes/state/Node';
import { add, div, ref, scalar } from '~/lib/nodes/jax';
export const Midpoint: Node2NodeFunc = {
keyword: 'midpoint', outputType: 'Point',
parameters: [{ name: 'point1', type: 'Point' }, { name: 'point2', type: 'Point' }],
compute(inputs: AnyNode[]) {
const p1 = inputs[0] as PointNode;
const p2 = inputs[1] as PointNode;
return {
main: { type: 'Point', x: div(add(ref(p1.x), ref(p2.x)), scalar(2)), y: div(add(ref(p1.y), ref(p2.y)), scalar(2)) },
};
},
};
Now pie.midpoint(p1, p2) works in any editor.
computereturns an object whosemainentry is the output node. Other entries are extra nodes drawn with it.- Do the math with the helpers in
~/lib/nodes/jax(add,sub,mul,div,sqrt,sin,cos,atan2, …), not with JavaScript's+or*. Wrap each number field of a node inref(...)before using it. This keepspie.partialworking through your function. - The node types and their fields are in
node_modules/@tinyvolt/pie/src/lib/nodes/state/Node.ts. - A file can also export a
SideEffectFunc, which changes existing nodes instead of making one. Seenode_modules/@tinyvolt/pie/src/lib/nodes/functions/sideEffects.ts. - Using a keyword twice, in two of your files or in yours and a built-in, fails the build.
Your own shapes
Each nodes/**/*.{ts,tsx} file exports one or more NodeDefinition objects. Each one adds a node type that functions can return and the canvas draws. nodes/square.tsx:
import type { Accessor } from 'solid-js';
import type { Node, NodeDefinition, PointNode, ScalarNode } from '~/lib/nodes/state/Node';
import { num } from '~/lib/nodes/jax';
export interface SquareNode extends Node {
type: 'Square';
center: PointNode;
side: ScalarNode;
}
// Adds 'Square' to the node types, so functions can take and return it.
declare module '~/lib/nodes/state/Node' {
interface NodeTypeMap {
Square: SquareNode;
}
}
const renderSquare = (square: Accessor<SquareNode>) => {
const side = () => num(square().side.value);
return (
<rect
x={num(square().center.x) - side() / 2}
y={num(square().center.y) - side() / 2}
width={side()}
height={side()}
fill={square().fill ?? 'none'} stroke={square().stroke ?? 'currentColor'}
stroke-width={2} vector-effect="non-scaling-stroke"
/>
);
};
export const Square: NodeDefinition<SquareNode> = { type: 'Square', prefix: 'sq', render: renderSquare };
typeis the type's name, and must match the interface'stype.prefixstarts the automatic ids:sqgivessq0,sq1, …. Left out: the type name in lower case.render(node, unit)returns Solid SVG elements, in grid units. Callnode()inside the attributes, so the drawing updates when the node changes.unitis pixels per grid unit: divide by it for a size that stays the same on screen. Leaverenderout for a node that isn't drawn.num(...)turns a node's number field into a plain JavaScript number.- Read
node().fillandnode().strokebefore your defaults, or the colors set from Python are ignored. - Python makes a node of your type through a function in
functions/:functions/square.tsaddspie.square(center, side). - Using a type name twice, in two of your files or in yours and a built-in, fails the build.
Header and homepage
layouts/header.tsx replaces the header on every page, and layouts/index.tsx replaces the homepage. Each must export default a Solid component. Solid's JSX uses class, not className. Delete a file to get the built-in one back. To read site.yml in a layout, import site from "../site.yml".
Styles
Every .css file in styles/ loads after the built-in styles, so its rules win. Change the CSS variables in styles/theme.css (colors, fonts, text width), or add rules of your own, e.g. main h1 { color: crimson; }.
- Sidebar:
--sidebar-width(default16rem),--sidebar-drawer-below(default768px: narrower screens open it over the page) and--color-backdrop(behind it when it opens over the page). Its parts have the classes.sidebar,.sidebar-text,.sidebar-edge,.sidebar-toggleand.sidebar-backdrop. - Table of contents:
.toc.
SEO
Set url in site.yml before deploying. The build then writes sitemap.xml (every page) and robots.txt (pointing to the sitemap), and every page gets a canonical link and og:url. Without url, the build skips them and warns. A file of the same name in public/ replaces the generated one.
Every page also gets og:title and og:site_name, and a description if there is one: the page's own description, or else site.yml's.
Build checks
npm run build fails, with a message naming the file, when:
- a page's frontmatter or
site.ymlhas a bad line or value, orsite.ymlhas notitle - a
sidebarfile doesn't exist or is outside the project - a page's URL is
/or/pages - a
functions/keyword is used twice - a
nodes/type name is used twice - a
layouts/file has no default export