engineering tools picjs

picjs—plain-text drawing with a built-in programming language...

Dave Thomas Dave Thomas
· · 10 min read
picjs—plain-text drawing with a built-in programming language...

We generate a lot of diagrams at Strike48 Labs. A lot of diagrams. And of course we strongly prefer them to be written in text, embedded in our docs, and stored in our repos.

Mermaid has been our go to tool, but if you know Mermaid, you know it can be difficult to get the layout you want.

Enter picjs. Just like Mermaid, you write your diagrams in code blocks in your Markdown. Unlike Mermaid, picjs lets you control how things look:

b1 = box "One" 
line -> "process" above 
b2 = box "Two"
Face south
line -> from b1.s
b3 = box "Three"
arc <-> from b2.s to b3.e dotted stroke ~orange
OneprocessTwoThree

Positioning is done using constraints. By default, picjs draws from left to right, so box -> box positions the shapes so that the east side on one is located at the west side of the next. These are implicit constraints.

Face south changes the default direction, so when we draw a line from the south of b1 it runs down the page. Then, when we draw b3, it automatically places its north at the end of the previous line.

Similarly, the arc is constrained at both its start and end. It also has a couple of additional attributes, dotted and stroke ~orange.

You can build lines with multiple segments using then.

result = box "it's full" "of stars"
line -> from result.e right .5
     then down .6
     then left until even with result.sw  - (.5,0)
     then up  .6
     then to result.w 
     radius .2
it's fullof stars

By default, objects are positioned to the right of the previous object.

Label "Input" ->  box "Process" fill ~b2 -> Label "Output"
InputProcessOutput

You can change the direction:

Face south
Label "Input" 
-> length .5  box "Process" fill ~b2 
-> same       Label "Output"
InputProcessOutput

When I first wrote this, I just added the Face south in front of the previous code. It worked, but the lines looked too long, so I used the length attribute to change it. Rather than repeat the attribute on the second line, I used same, which copies the attributes from the previous shape of the same type.

I also split it onto multiple lines. picjs doesn't have statements; everything is an expression, and each expression ends when the parser comes across a token that doesn't make sense in the current expression.

You don't have to lay things out linearly:

sun = circle fill ~orange

mercury = circle rad 2 fill ~none stroke ~b4 at sun
venus   = circle rad 3 fill ~none stroke ~b2 at sun
earth   = circle rad 4 fill ~none stroke ~b3 at sun

circle rad .2 fill ~b4 at mercury.n
circle rad .35 fill ~b2 at venus.nw
circle rad .35 fill ~b3 at earth.w

Notice how you can use cardinal points to indicate the boundaries of objects.

b = box wid 1 ht 1
Label "n"  with .s  at b.n
Label "ne" with .sw at b.ne
Label "e"  with .w  at b.e
Label "se" with .nw at b.se
Label "s"  with .n  at b.s
Label "sw" with .ne at b.sw
Label "w"  with .e  at b.w
Label "nw" with .se at b.nw
nneesesswwnw

Let's add some labels to our solar system. For each planet we'll show its name, and a pointer to the planet itself, something like

m_label = Label "Mercury" at mercury.ne + (.7,-.3)
-> from m_label.sw to mercury

But that's going to get tedious when we have all 8 (or 9) planets, so let's write a function.

add_label = (planet, name) => {
  lab = Label name at planet.ne + (.7, -.1)
  -> from lab to planet
}

sun = circle fill ~orange

mercury_orbit = circle rad 2 fill ~none stroke ~b4 at sun
venus_orbit   = circle rad 3 fill ~none stroke ~b2 at sun
earth_orbit   = circle rad 4 fill ~none stroke ~b3 at sun

mercury = circle rad .2  fill ~b4 at mercury_orbit.n
venus   = circle rad .35 fill ~b2 at venus_orbit.nw
earth   = circle rad .35 fill ~b3 at earth_orbit.w

add_label(mercury, "Mercury")
add_label(venus, "Venus")
add_label(earth, "Earth")
MercuryVenusEarth

There's still a lot of duplication in there, so...

add_label = (planet, name) => {
  lab = Label name at planet.ne + (.7, -.1)
  -> from lab to planet
}

draw_planet = (name, orbit_size, planet_size, angle, color) => {
  orbit  = circle fill ~none rad orbit_size stroke color at sun
  planet = circle fill color rad planet_size at polar(orbit_size, angle)
  add_label(planet, name)
}

sun = circle fill ~orange

draw_planet("Mercury", 2, .2,  260, ~b4)
draw_planet("Venus",   3, .35, 200, ~b2)
draw_planet("Earth",   4, .35, 220, ~b3)
MercuryVenusEarth

Some of the Cooler Stuff

Blocks

Blocks group shapes together. Those groups can then be laid out just like regular shapes.


Box.stroke = ~f1

a = { Face south    
      box "A"  box "B"   box "C" }
Gap .4
b = { Face south    
      box "1"  box "2"   box "3"  box "4" }
Gap same
a = { Face south    
      box "X"  box "Y" }
Gap same
d = { Face south    
      box "6"  box "7"   box "8"  box "9" }

// draw a box around the last group
box wid d.wid*1.2   ht d.ht*1.1 
    fill ~b4  at d   behind d
ABC1234XY6789

We drew the background box around the last group, sizing it just bigger than that group and at the same position. Because it is drawn later than the group, it would normally appear in front of it, so we tell picjs to position it behind.

Ranges and Interpolation

The syntax [a..b] defines a range. The start and end can be numbers, positions, or colors (but they both have to be the same type).

One use of ranges is iteration:

[1..4].each(n => { box "#{n}"  Gap .1 })
1234

For continuous values, like positions and colors, you have to tell picjs how many values to take:

p1 = (0,0)
p2 = (10,4)

[ p1..p2 ].steps(7, pos => circle at pos)
[~red..~blue].steps(12, color => box ht 3 fill color)

You can also get a pick a single value out of a range by multiplying it by a number from zero to one.

// what color is three quarters the way from red to blue?
?? [~red..~blue]*75%   // #7a00f4

Functions

Functions in picjs are just another value type, created using the => operator.

fib = n => if (n <= 2) 1 else fib(n-1) + fib(n-2)

Because functions are closures, and because we can define arbitrary attributes on any value, we can write a memoize function that takes a unary function as an argument and returns a memoized version:

memoize = fn => {
  seen = "any object"
  n => {
    if (seen.has(n))
      seen[n]
    else
      seen[n] = fn(n)
  }
}
  
fib = memoize(n => if (n <= 2) 1 else fib(n-1) + fib(n-2)) 

?? fib(50)   // 12586269025

Using a string as an attribute bucket is ugly. We'll be adding proper dictionaries to picjs shortly.

Animation

picjs has an interesting animation system. It has the concept of "the current animation time," which is the time that subsequent animations will run, and it has a handful of animation verbs: move, set, and draw. The current animation time in seconds is aliased to a special variable, @, which defaults to zero. You don't have to create your animation sequentially: setting @ lets you choose exactly when the next animation you code will run. (Press the play button below the drawing to make it run.)

[1..10].each(n => {
  b = box fill ~blue.spin(30*n)
  @ = n/5
  
  move b up .3 ease quad 
  then move b down .3 ease quad
  
  set b.height to 1 ease cubic 
  then set b.height to Box.height
})

When the drawing first loads, only the first box is visible, because each subsequent box is created with @ greater than zero.

That value of @ also triggers two animations. The first moves the box up a little and then down. The second increases and then restores the height.

We might want to draw the boxes first, and then animate them:

[1..10].map(n => {
  box fill ~blue.spin(30*n)
}).each((b,n) => {
  @ = n/10
  
  move b up .3 ease quad 
  then move b down .3 ease quad
  
  set b.height to 1 ease cubic 
  then set b.height to Box.height ease cubic
})

And of course, there's the obligatory monk waster:

There's a lot more to picjs: visit the GitHub repo for the source, or read the documentation.

In the meantime...

Design Decisions Worth Talking About

Language Model

  • Every value can have attributes. Numbers, strings, colors, shapes — all of them carry a property bag. You can write disk.tower = 2 or pole.push = (d) => { ... } and it just works. This single mechanism replaces classes, prototypes, and mixins. There's no special syntax to learn; you assign a function to an attribute and you've built a method.

  • Everything is an expression. There are no statements. if returns a value, a group { ... } returns a shape, a function implicitly returns its last expression. This keeps the language small while making composition natural — you can pass a group literal directly to a function, or nest an if inside an attribute list.

  • Operators are polymorphic across types. 3 * (2, 3) scales a position to (6, 9). [1, 2] + 3 broadcasts to [4, 5]. "a,b,c" / "," splits a string into a list. The same small set of operators works on numbers, positions, lists, strings, and colors — each type decides what the operation means.

Animation Timeline

  • Animations are concurrent by default. Every animation starts at the current value of @ (the timeline cursor), which begins at zero. If you create five animations without touching @, they all run simultaneously. This is the opposite of what most people expect, but it's the right primitive: sequential is just concurrent with offsets, and then chains give you that when you want it.

  • The timeline is a first-class value. @ isn't a magic keyword — it's a value you can read, write, and do arithmetic on. @ += 0.3 slides the cursor forward. @@ snaps it to the end of the last animation. @.start_from lets you schedule a future animation without disturbing the current time. The timeline is data, not control flow.

Positioning

  • Layout positioning and constraint positioning are separate ideas. By default, shapes flow left-to-right (or whichever way you're facing). This placement is fire-and-forget — moving a shape doesn't drag its neighbors. But write with .nw at other.se and you've created a live constraint: when other moves, the dependent shape follows. This distinction makes simple diagrams simple and complex ones possible.

  • The viewBox probes the future. Animated shapes may move outside their initial bounds. Rather than guess at padding, picjs walks the animation timeline at every boundary time, runs the program in an offscreen SVG to get real text measurements, and unions the resulting bounding boxes. The viewBox you see at frame zero already accounts for where things will be at the end.

Colors and Ranges

  • Ranges are a generic interpolation primitive. [1..10] is an integer range, but [~red..~blue] interpolates through oklch color space and [(0,0)..(3,2)] interpolates positions. Call .steps(n, fn) on any of them and you get n evenly-spaced samples — with optional easing. Ranges turn "do this N times, varying a parameter" into a one-liner.

  • Palette colors are paired for accessibility. Each palette defines eight foreground and eight background colors. Foreground N on background N is guaranteed to meet WCAG contrast requirements. You can restyle an entire diagram by changing one line (Palette.current = "ocean") and know the result is readable.

Implementation

  • Closures are the only abstraction mechanism. There are no classes, no prototypes, no special constructor syntax. A function that takes an object, assigns function-valued attributes to it, and returns it is a constructor. The closed-over variables are private state. This fell out of the language design rather than being planned, and it's been enough for every example so far.

  • A source-regenerating visitor powers debug output. The ?? inspect operator needs to show both the evaluated result and the original source expression. A separate visitor walks the AST and reconstructs source text, while the main interpreter evaluates it. Same tree, two interpretations.

  • No JavaScript required unless you use animations. Free-standing static web pages can be generated.

  • Light/Dark mode selector can be passed to the page generator.

Dave Thomas
Dave Thomas

Developer and author. Passionate about tools that make hard things simple.