picjs Guide

An introduction to picjs, a constraint-based drawing and animation language

(For reference material, have a look at the picjs Reference and the Quick Reference Card).

Before we start, a few notes:

  • Play along with the examples in the guide using the online editor or install picjs locally using npm:

    $ npm install -g picjs
    $ picjs --playground
  • If you want your AI buddy to play, too, there are two skills files under skills, one for the baic stuff, and the other for animations. (Splitting skills 'cos I care about your context...)

  • Integrating picjs

    If you have control over the Markdown to HTML conversion used by your site, you can add picjs as a plugin, and ``` picjs code blocks will be replaced by SVG in the output.

    There are plugins for Lume and Eleventy in the extras/ directory. Feel free to contribute more.

    If you don't use animation, then the HTML generated by the plugins is free-standing.

    If you do use animations, you'll need to include the picjs runtime on the page. The Eleventy plugin does this automatically. For other setups, add a script tag that imports initAnimations from the runtime and calls it:

    <script type="module">
      import { initAnimations } from "picjs/runtime"
      initAnimations()
    </script>

    This finds all animated diagram containers on the page and attaches playback controls to them.

  • Converting In Place

    You might want to use picjs to add diagrams to something like a Github README.md. In this case, you'll need to preprocess the file before you push it.

    The picjs command will convert each diagram in a file into SVG. It will then insert the SVG, and also include the original picjs as an HTML comment, along with a checksum. The resulting file will display the SVG in place of the diagram. If you want to alter a diagram, edit the picjs source in the comment and rerun the command. It will compare the checksum with that of the source, and regenerate the SVG if the source has changed.

Enough boring stuff. Let's draw some pictures.

Hello World: default positioning

box  "Hello"
line ->
box  "World"
HelloWorldXQFM

box and line are shapes. By default, picjs draws shapes from west to east.

picjs is written using expressions, not statements, and each expression ends when picjs comes across something that doesn't belong. Whitespace (including newlines) are irrelevant, unless they are separating two tokens. That means the previous example can be written:

box "Hello" line -> box "World"
HelloWorldXQFM

And because -> is both an attribute and a shortcut, you can write:

box "Hello" -> box "World"
HelloWorldXQFM

The current direction is set using the Face command.

Face s
box "Hello" -> box "World"
HelloWorldXQFM

The connections between shapes are centered on the direction we're drawing.

box "Hello"
       -> circle "你好"
Face s -> Oval "Hola"
Face w -> ellipse "привіт"
Face n ->
Hello你好HolaпривітXQFM

Constraints

Every 2D shape has a bounding box with nine associated positions:

Expected "!=", "&&", ")", "==", "^", "||", [%*-+\-/], operator, or space but "=" found.

So far we've used the default layout method. If we're facing east, then the next shape's .w is located at the previous shape's .e. If we're facing south, then .n is placed at the previous .s, and so on.

You can override that positioning using constraints. A constraint is a relationship between a shape and some other position.

Positions

Drawings are made on an X/Y plane, where the X coordinate increases to the east and the Y coordinate increases to the south. A position is a point on that plane.

Absolute positions are simply two expressions between parentheses. The comma is optional as long as there's a space between them.

box wid 3 ht 3 with .nw at (-.5, -.5) fill ~gray
[0..2].each(n => {
  h = line from (-.1, n) to (2,n) thick 0.01 stroke ~skyblue
  v = line from (n, 0) to (n,2.1)  same
  Label "#{n}" with .e at h.w - (.1,0)
  Label "#{n}" with .n at v.s + (0,.1)
})

box "A" at (1, 1)
box "B" at (1.5 1.5) fill ~b2
001122ABXQFM

Each of the coordinates can be an expression:

[0..359].steps(10, theta => {
  circle at (2*sin(theta), 2*cos(theta))
})
XQFM

(The [0..359].steps(10, theta => {...}) syntax iterates over the range from zero to 359, taking 10 steps, and passing the current interpolated value to the function as theta.)

Positions are also values, so you can perform arithmetic on them:

[0..359].steps(10, theta => {
  circle at 2*(sin(theta), cos(theta))
})
XQFM

You can also interpolate between them:

[(0,0)..(2,1)].steps(7, pos =>
  circle fill ~b4 stroke ~f4 at pos
)
XQFM

Relative Positions

It's fairly unusual to use absolute positions, since they don't adapt to changes in the layout. Instead, we locate shapes relative to each other.

Each of the cardinal points of a shape is a position value. We can use at just as we did above, but using a shape's position instead of an absolute one:

a = box "A" fill ~b2
box "B" at a.se opacity 0.5
ABXQFM

Using a shape value as a position selects the center of that shape, so the previous example positioned the center of the second box at the southeast corner of the first.

Use the with clause to change the starting point of the position:

a = box "A" fill ~b2
box "B" with .nw at a.se opacity .7
ABXQFM

We can use arithmetic:

a = box "A" fill ~b2
box "B" with .nw  at a.se - (.2,.2) opacity .7
ABXQFM

Algebra works as expected on positions:

a = box "A"
b = box "B" at a.c + (2,1)
circle radius .1 at a.se + (b.nw-a.se) * 25%
circle radius .1 at a.se + (b.nw-a.se) * 50%
circle radius .1 at a.se + (b.nw-a.se) * 75%
ABXQFM

Colors

Most shapes have fill colors and stroke colors (lines only have stroke colors, and labels only have fill). These are set using the fill and stroke attributes.

box fill ~Salmon stroke ~FireBrick
line stroke ~green
circle fill ~pink stroke ~purple
XQFM

These examples used the ~ notation for named colors. All the CSS named colors are supported. In addition you can use the color functions rgb, hsl, and oklch. They each take three parameters, along with a fourth optional opacity.

However, if you want consistency, use a color palette. Picjs ships with a number of palettes, and each has eight foreground colors (~f1~f8) and eight background colors (~b1~b8).

As a general guide, the ~b1 is darker than ~b2 and so on, but that depends on the palette.

The colors are chosen so that using a given foreground color on a background color of the same number will ensure WCAG accessibility.

Palette.current = "sunset"

b1 = box fill ~b1
b2 = box fill ~b2 with .nw at b1
b3 = box fill ~b3 with .sw at b2
b4 = box fill ~b4 with .nw at b3
XQFM

The Palette object lets you switch the palette used for all subsequent objects. You can also define your own palettes by assigning to Palette.b1, Palette.b2 and so on.

There's a table showing the available palettes in the editor.

Color Interpolation

You can interpolate between two colors.

Palette.current = "ocean"
[~b1..~b8].steps(7, color =>
  box fill color stroke ~black thickness .03
)
XQFM

Color Manipulation

Face s
{
  Face e
  ["", "Lighten", "Darken", "Desaturate", "Saturate"].each(head => {
    box (head) ht .3 rad 0 fill ~grey
  })
}

{
  [60..360].steps(8, rotation => {
    {
      base_color = ~b3.spin(rotation)
      Face e
      box fill base_color ("#{base_color}" fill ~black)
      box fill base_color.lighten(.2)
      box fill base_color.darken(.2)
      box fill base_color.desaturate(.3)
      box fill base_color.saturate(.3)

    }
  })
}

Gap .1

Face s
box ht .3 wid 5*Box.width rad 0  "Grayscale" fill ~gray

{
  [~cyan, ~red, ~green, ~blue, ~purple].each(base_color => {
    Face e
    {
      Face s
      box ht Box.height/2 fill base_color rad 0
      box same fill base_color.grayscale()
    }
  })
}
LightenDarkenDesaturateSaturate#996c45#7f7a40#57845b#348681#427f9e#6a73a6#8b6895#9e6374GrayscaleXQFM

Other Shape Attributes

fill and stroke are two of the dozens of attributes that picjs supports. They let you change the width, height, corner radius, and fonts used. Down towards the end of this document you'll find the details.

Basic Programming

picjs is a mini programming language. It has variables:

b1 = box "one"
Gap
b2 = box "two"
arc from b1.n to b2.n
onetwoXQFM

picjs has lists, ranges, strings, booleans, and positions. It comes with the usual set of operators (+, -) and so on, and it tries to apply them polymorphically:

1 + 2         // 3
[ 1, 2 ] + 3  // [4, 5, 6]
"cow" + 99    // "cow99"
3 * (2, 3)    // (6, 9)   (x,y) is a position

It has if statements:

if (condition)
  expression_or_block
else
  expression_or_block

condition is an expression evaluating to a boolean.

expression_or_block is either a single expression or a set of expressions enclosed in braces.

if (name == "Dave")
  box "Hello"
else {
  circle "Sorry"
  oval   "Don't know you"
}

The else is optional.

Functions

A function is created using the => operator. It may be preceded by a list of parameters, and it must be followed by an expression_or_block.

The parameters are a list of names between parentheses, separated by commas. The parentheses can be omitted if there is only one parameter.


// a function that applies `* 2` to its parameter
n => n * 2

// return the (x,y) coordinates given polar coordinates
(r, theta) => r*(sin(theta), cos(theta))

// draw a circle inside a box
=> {
  b = box
  circle rad .3 fill ~f2 at b
}

You'll typically assign function values to variables or pass them to other functions.

short_box = label =>
   box ht 0.3 "--#{label}--"

[1,2,3].each(short_box)
--1----2----3--XQFM

Blocks and Groups

Blocks and groups have identical syntax: a set of expressions enclosed in braces.

A block is used when you want to provide multiple expressions as the body of a function, or in the arms of an if expression.

if (Box.width < 2) {
  Box wid 1 "Hello"
  Box wid 2 "World"
}

A group is used when you want to associate a set of drawing objects and treat them as a single entity.

{
  Box wid 1 "Hello"
  Box wid 2 "World"
}

The value of a block is the value of the last expression executed. The value of a group is a shape object (an instance of Group).

The shapes inside a group are positioned relative to the group as a whole, and so when you position the group, you position the shapes it contains. Also, if you set the Face direction in a group, it is restored when the group exits.

This is a common pattern for centering variable height lists.

{
  Face s
  box "A"
  box "B"
  box "C"
}
Gap .2
{
  Face s
  box "D"
  box "E"
}
Gap .2
box "F"
ABCDEFXQFM

Because they're shapes, groups can be positioned.

c = circle "Circle"
{
  box "A"
  box "B"
  box "C"
} with .s at c.n
CircleABCXQFM

This is often used to draw a background around a group of shapes.

Expected "(", "@", "draw", "if", "move", "pause", "rotate", "set", group, identifier, shape, space, start of expression, or term but ":" found.

If you do this multiple times in a drawing, use a function:

Syntax error inside function body

There are two subtleties here. First, inside the surround function we put the box and label inside their own group, which lets us put them both behind the shape.

Second, we don't have to store the group we're wrapping in a variable. The second team is passed as a literal group to surround.

Functions Are Closures

multiplier = a => {
  b => Label "#{a} x #{b} = #{a*b}"
}

times_2 = multiplier(2)
times_3 = multiplier(3)

Face s
Label.fill = ~black
times_2(5)
times_2(6)
times_3(7)
times_3(8)
2 x 5 = 102 x 6 = 123 x 7 = 213 x 8 = 24XQFM

In this example, multiplier is a function that returns another function. That second function draws a label, using the b, its parameter and a, which is the enclosed value passed to the function that created it.

Attributes Are Dynamic

Every value in picjs can have attributes. Many value types have predefined attributes. When you write box.fill = ~b2 you're setting the fill attribute of a Box value, and when you write len = aline.length you're accessing the length attribute of a line. The quick reference starts with a list of all predefined attributes, and the full reference goes into more detail.

You can also add your own attributes to a value. If your attribute name is a valid variable name, you just reference it as value.some_name. If the name isn't a valid variable name, use the syntax value[attr name]. Both versions can be used both to fetch the current value and set a new value (using assignment).

One use of this is to add flags to certain shapes. For example, in an animation of the Towers of Hanoi, you might want to add an attribute to each disk saying which tower it is currently on.

Because functions are values, you can assign them to attributes. On its own, this ability is not particularly useful. But a simple trick means you can use it to implement the equivalent of object constructors.

Functions + Closures == Constructors

Let's make a box that can hold other, smaller, boxes. Having created the outer box, we'd like it to have a .add function that places its argument at the next available position in the box.

make_container_of = shape => {
   next_nw = (0,0)
   shape.add = other => {
      other.nw = (shape.nw + next_nw)
      next_nw.x += other.width
      if (other.width + next_nw.x > shape.width) {
        next_nw.x = 0
        next_nw.y += 1
      }
  }
  shape
}

b = make_container_of(box 4x4)

[~red..~blue].steps(8, shade => {
  b.add(box "A" fill shade)
  b.add(box "B" fill shade.desaturate(.5))
})
ABABABABABABABABXQFM

We'll start towards the end of the code.

b = make_container_of(box 4x4) first creates a 4-by-4 box, then passes it to the make_container_of function. This function augments the box with an add function.

The skeleton of this function is:

make_container_of = shape => {
  // ...
  shape.add = other => {
    // ...
  }
  shape
}

This creates an add attribute on the shape we pass in. The value of that attribute is a function that takes the shape we're adding. We'll get to that next.

Finally, the function returns the shape.

How does add work? It relies on the fact that function definitions act as closures.

next_nw = (0,0)
shape.add = other => {
   other.nw = (shape.nw + next_nw)
   next_nw.x += other.width
   if (other.width + next_nw.x > shape.width) {
     next_nw.x = 0
     next_nw.y += 1
   }
}

We create a variable next_nw in the outer scope. This is the position within the outer shape that we want to position the northwest corner of the next box we add. Then comes the add function body. It makes copious use of the variables shape and next_nw. Both of these are defined outside the body of add, so they are automatically enclosed: this particular incantation of add will have these variables accessible even after make_container_of returns, and those variables will be unique to that particular function. If we call make_container_of again on a new shape, then it will have different shape and next_nw variables.

Animation

picjs lets you change attributes of drawing objects over time.

a = box "Hello"
move a.c to a.se take 2
HelloXQFM

Hit the Play control to the left of the timeline, and the box should move. Position the scrubber back and forth, and the box's position will reflect the time.

Expected "(", "@", "draw", "if", "move", "pause", "rotate", "set", group, identifier, shape, space, start of expression, or term but "~" found.

Notice we can set @ to any value, including ones before the current animation time.

Chaining Animations

Sometimes, though, you do want animations to run sequentially. then to the rescue.

a = box "Hello"
Gap
b = circle "World"
l = line from a to b

move a to (1,1)
then move b to (1,-1)
then set l.thickness to .2
HelloWorldXQFM

More About @

The @ value has some other tricks up its temporal sleeve. It has a number of attributes:

Expression Value
@ Current time (shorthand for @.now)
@.now Current time
@.max_time Maximum time in the timeline
@.last_animation_start Start time of most recent animation
@.last_animation_end End time of most recent animation
@.start_from Time offset for next animation

Assigning to @.now is the same as assigning to @: it sets the current time.

@.start_from can also be assigned to. When set, it determines the time that the next animation will start, but doesn't change @.now. This is rarely used, but has a place when you want to schedule a future animation without interfering with calculations that use @.

Then there's the strange @@ sigil. It's an abbreviation for

@.now = @.last_animation_end

Called after an animation, it updates the value of @ so that a subsequent animation will start immediately after the previous one. For adjacent animations, it's like using then. It's more useful when you have your animations broken into chunks, and you want to synchronize their execution.

Easing

As with interpolations, you can add an easing function to animations: linear, cubicIn, cubicOut, cubic, cubicInOut, quadIn, quadOut, quad, quadInOut, and bounce.

Lines and Arrows

Lines and arrows have two distinct types of animation. We've already seen the first: their start and end points track the shapes they are attached to, and they have attributes like stroke to set the color and thickness to set their width.

But lines can also be animated when they are drawn: they grow from their start to their end.

For this to work, we have to tell the line not to draw itself initially using the nodraw property. We can then animate it using the draw animator.

c1 = circle rad .1
c2 = circle rad .1 at c1.c + (2, 1)

l = line -> from c1 to c2 nodraw

draw l take 2 ease quad
XQFM

Attachment

You'll probably notice that if you join two shapes with a line and move one of the shapes, the line adjusts so it is still attached.

This is an example of shape attachment. Two shapes are attached when the position of one explicitly depends on the position of the other. This dependency is created when you use a constraint.

b1 = box fill ~b1
b2 = box fill ~b2
b3 = box fill ~b3
b4 = box fill ~b4 with .w at b3.e

move b1 up .5
move b3 up .5
XQFM

The first three boxes are unconstrained. Box b2 is next to b1, but that just because the layout mechanism put it there. The position of b4, however, is defined in terms of b3. Moving b1 has no impact on b2, but when b3 moves, the constraint means that b4 moves with it.

Now let's repeat the experiment, but moving b2 and b4.

b1 = box fill ~b1
b2 = box fill ~b2
b3 = box fill ~b3
b4 = box fill ~b4 with .w at b3.e

move b2 up .5
move b4 up .5
XQFM

Perhaps surprisingly, b4 doesn't move. The constraint glues it to b3, and the animation respects that.

Next...

  • Open up the playground and draw stuff.
  • While you're in the playground, look at the examples for more ideas.
  • If you come up with cool images and animations, send them to me (dave@pragdave.me): I'd like to start a gallery.

Have fun.

Dave