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 eiπ+1=0e^{i\pi} + 1 = 0, or put $$…$$ on lines of its own for a display block:

∫0∞e−x2 dx=π2\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}

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.

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)
```

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').

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

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:


Autodiff

pie.partial(target, var) is the derivative of scalar target with respect to var:

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.


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 };

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; }.


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: