TinyVolt

Nova

Your markdown editor for STEM.

What is Nova?

Nova is a markdown editor focused on STEM. Powered by a declarative graphics DSL, native autodiff and reactivity, it turns a static markdown into an interactive canvas - including LaTeX strings and even the markdown text itself.

Editor: A VS Code-style editor where one can write and preview content locally.

Platform: The invisible scaffold that handles publishing, hosting and serving. The same article lives in two places:


The authoring format

Code in a ```pygeomatic block runs without the reader seeing it. If you want the reader to set something off themselves, give those lines a name with with group("name"):, then write {label}(ref:name) in your text. That becomes a link. The reader clicks it, and those lines run. For a single command you don't need a group: put the command inside the link itself: {set the scale}(scale = gm.scalar(1)). Everything else is normal markdown, math included.

# Drawing a walk

```pygeomatic
origin = gm.p0
a = gm.point(3, 0)
walk = gm.line(origin, a)
gm.hide(walk)

with group("walk-x"):
    gm.highlight(walk)
    gm.show(walk)
```

Reach the point by {moving a distance}(ref:walk-x) of $3$ units.
Or reset it inline: {set scale to 1}(scale = gm.scalar(1)).

Binding LaTeX to live nodes

Give a formula an id (a %id: line inside the $$…$$ block), reach it with gm.tex("id"), then attach a store node. Whatever drives the node reflows the formula. Two modes:

bind - show a node's value in a slot

  • A number inside the formula tracks a node (integral limit, fraction numerator).
  • Address the slot as tex.family.slot (e.g. int.upper, frac.num) and call .bind(node).
$$
%id:energy
\int_{a}^{b} x^2 \, dx
$$

```pygeomatic
b = gm.scalar(3, out="b")
energy = gm.tex("energy")    # matches %id:energy
energy.int.upper.bind(b)   # upper limit now shows b
```

Change it: {b = 5}(b = gm.scalar(5))

highlight - paint matrix cells by position

  • Paint a row, column, diagonal, or region of a matrix.
  • Build a selector over each cell's grid position; it moves as its node changes.
$$
%id:M
\begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix}
$$

```pygeomatic
r = gm.scalar(0, out="r")
M = gm.tex("M")
M.highlight(M.rows() == r, color="pink")   # row r
M.triu().highlight(color="blue")         # upper triangle
```

Move it: {row 1}(r = gm.scalar(1)) · {row 2}(r = gm.scalar(2))
  • Boxes: M[3:, 4:]. Regions: M.diag() / M.triu() / M.tril().
  • Arithmetic cols - rows > 0, combine with & / |, gate with .scale(node).

Controls your reader can move

A slider is just a number the reader can change. Make one with gm.ui.slider, then put it in a sentence using an f-string. You need gm.md(…) to write that sentence, because a ```pygeomatic block produces no text on its own, and an f-string only works inside python.

Moving the slider doesn't re-run your code. It changes the number, and anything built from that number updates on its own: the drawing, and any formula you bound to it above.

```pygeomatic
r = gm.ui.slider(1, 5, step=0.5, value=3, label="radius")
c = gm.circle(gm.p0, r)         # r is an ordinary Scalar

gm.md(f"Drag to resize: {r}")  # the slider appears here
```

r is an ordinary number - exactly what gm.scalar(3) would have given you. So r * 2, gm.circle(gm.p0, r) and .bind(r) all work as usual.

The six controls

  • gm.ui.slider(start, stop, step=, value=, label=, show_value=) → a number, for sliding through a range.
  • gm.ui.number(start=, stop=, value=, step=, label=) → a number, for typing an exact one. Start and stop are optional.
  • gm.ui.checkbox(value, label=) → true or false.
  • gm.ui.dropdown(options, value=, label=) → the chosen option; text options give text, number options give a number.
  • gm.ui.radio(options, value=, label=) → the same as a dropdown, but every option is on screen.
  • gm.ui.text(value, label=, placeholder=) → whatever the reader types.

Give each control its own name. {r} looks up the control by its name, so two controls sharing one name would be ambiguous.


Text that appears on demand

with when(…) hides some text until a condition is true. The condition is checked as the reader moves things, so ticking a box or dragging a slider makes text appear and disappear. There is nothing to click.

```pygeomatic
show = gm.ui.checkbox(False, label="Show the proof")
gm.md(f"{show}")

with when(show):                       # a Bool node on its own
    gm.md("Because $ab=ba$, the map commutes.")

with when(gm.cond.ge(r, 4)):         # or a comparison
    gm.md("**Large:** the radius is 4 or more.")
```

Writing the condition

  • A tick box on its own is true when it is ticked. A number on its own is true when it isn't zero.
  • To compare, use gm.cond.ge / gt / le / lt / eq / ne. Text can only use eq and ne.
  • To join conditions, use &, | and ~, or gm.cond.all_(…) and gm.cond.any_(…).

Write gm.cond.ge(r, 4), not r >= 4. In geomatic, r >= 4 already means something else - it builds a new value. The two are kept apart on purpose.

Two things to watch

  • Don't put clickable links inside a when block. Links run in the order they appear on the page, and hiding one doesn't take it out of that order - so the reader gets stuck waiting to click something they can't see. Text, math and controls are all fine.
  • Drawing still happens. Only the text is hidden. Anything you draw inside the block still shows up, so hiding text never quietly changes the picture.

Shapes the reader can click

with gm.ui.onclick(node): attaches pygeomatic commands to a shape drawn in the canvas. The reader clicks it on the canvas and those commands run.

```pygeomatic
label = gm.annotate_text_box("where does it land?", 2, 3)

with gm.ui.onclick(label):          # runs when the reader clicks the box
    p = gm.point(2, 3)
    far = gm.gt(gm.distance(p, gm.p0), 1)

with when(far):
    gm.md("The point landed outside the unit circle.")
```

The one rule

One can define a new node or redefine / overwrite an existing one.

What a handler won't take

  • A target that isn't drawn: a number, a true/false, a piece of text. There is nothing on screen to click.
  • An empty block, or one block inside another.
  • A control (gm.ui.slider and friends). The control has to exist before the reader touches anything.
  • A group(…), or gm.md(…). Prose is written once, when the article is built; gate it with when instead.

Handlers live with the published article, not with the commands. Copying a command list into the editor carries the drawing but not its clicks.


What the page starts with

Everything above waits for a click. with gm.onpageload(): doesn't: those commands run the moment the page loads, before the reader touches anything. Use it for the picture you want the article to open on, and for controls - a slider is on screen straight away, so the number behind it has to be there too.

```pygeomatic
with gm.onpageload():                       # runs before any link
    r = gm.ui.slider(1, 5, step=0.5, value=3, label="radius")
    gm.circle(gm.p0, r)

gm.md(f"Drag to resize: {r}")
```

The rest of the article can build on what the block made - here, later commands can move or recolour that circle. A handler works the other way round, because its commands haven't run yet.

Three rules

  • It goes in a ```pygeomatic block, like everything else.
  • One per article. Put everything the page starts with in it.
  • It must be in the first block, before anything else is drawn. The block runs ahead of every link, so nothing may come before it.

Start over brings it back. Clearing the canvas - the Start over button, or a \clear of your own - rebuilds the block first, so the reader lands back on the page as they first found it.

Something clickable from the start

Put a with gm.ui.onclick(…) inside the block and the shape is on the canvas, waiting to be clicked, before the reader does anything. The shape is built at load; what the click does still waits for the click.

```pygeomatic
with gm.onpageload():
    box = gm.annotate_text_box("what happens here?", 2, 3)

    with gm.ui.onclick(box):      # still waits for the click
        p = gm.point(2, 3)
```