cormullion’s blog

Warning: this frivolous post contains many animated GIFs. Don’t load it if you are concerned with bandwidth, or if you don’t want your device to overheat...!

Slackmojif

image label

If you use the chat app Slack (“where work happens”), you probably already have opinions about it. Is it: a waste of time, a vital tool, a distraction, an addiction, a flexible way to chat, an overcomplicated IRC alternative, a poorly-implemented CPU hog, “not worth the $17 billion ($28/share) it has reportedly been valued at over the past few months.”, worth way more than that? [1], or is it just another application? (Or all the above?)

One aspect of Slack that I like is the system of reactions (adding small static or animated graphics in response to someone’s message). It’s a nice way of allowing people to contribute to a conversation without actually interrupting it. And over the last couple of years I’ve started adding a few custom reactions to the Julia Slack workspace. They might possibly be useful to others, which is great, but it’s also fun to make them, particularly the animated ones. I’m using Julia (and Luxor.jl), rather than something much easier (AdobeSlackEmojiMakerProfessionalCreativeCloud or whatever). I like the puzzle-solving aspect of making them.

These Slack/emoji/animated GIFs (I’ll call them Slackmojifs, which temporarily solves the GIF pronounciation problem) can be tricky to make. They have to be less than 128KB, and fit into a 128 by 128 pixel square. For the purposes of this post, I’m going to liberate a few slackmojifs from their current 128³ prison, and let them roam free over this page at a slighly higher resolution. These free-range versions can enjoy a brief moment in the foreground before scuttling back into the shadows and wasting CPU cycles in the background.

Framed

Animations in Luxor.jl are usually built around the idea of a steadily increasing frame number. You write a function that specifies the graphics for frame number n, with n going from, say, 1 to 250, and saves them in a PNG file. Your system’s ffmpeg binary converts the PNGs to an animated GIF lasting in this case about 8 seconds, at the default frame rate of 30 per second. Here, the three Julia circles (a function for which is built in to Luxor.jl) are drawn with a radius specified by the incoming frame number.

using Luxor

function frame(scene, framenumber)
    background("white")
    juliacircles(framenumber)
    sethue("black")
    fontsize(50)
    text(string(framenumber), halign=:center)
end

simpleanimation = Movie(400, 400, "animation")

animate(simpleanimation, [
        Scene(simpleanimation, frame)
    ],
    creategif=true,
    pathname="../images/slackmojif/simple.gif")

image label

Each frame produced by frame() will be a complete stand-alone PNG file, so you’ll have to do your drawing set-up (eg backgrounds, font selections, scaling, color choices, and so on) for each frame. If you have a lot of calculation and set-up to do for every frame, it’s definitely worth trying to do it all at once, and storing (cacheing/memoizing) it for easy and repeated access. Then, for frame 79, say, you just draw the graphics for the 79th image.

Spot the difference

This approach is great for things like drawing the solutions generated by Differential Equations solvers. Say you’ve set up your problem in the usual way:

using DifferentialEquations
function f(du, u, p, t)
  du[1] = u[2]
  du[2] = -p
end

function condition(u, t, integrator)
  u[1]
end

function affect!(integrator)
  integrator.u[2] = -integrator.u[2]
  integrator.u[2] *= 0.86
end
cb        = ContinuousCallback(condition, affect!)
tspan     = (0.0, 40.0)
p         = 9.8
u0        = [100.0, -6]
prob      = ODEProblem(f, u0, tspan, p)
ball_sol  = solve(prob, Tsit5(), callback=cb)

Now ball_sol is the solution to the ODE problem, and it contains all the information you want (it can provide samples, interpolate between steps, and so on). You can now extract the values for a moment in time and draw the result.

In computer graphics, y values start at 0 at the top of the page, and positive values increase downwards, so you’ll have to flip the sign of y coordinates if you don’t want balls bouncing on the ceiling.) Here’s a simple example:

using Luxor
demomovie = Movie(400, 400, "bouncing ball")
function frame(scene::Scene, framenumber)
    ballradius = scene.movie.width/8
    ground = scene.movie.height/2 * 0.95
    height = scene.movie.height/2
    i = rescale(framenumber, 1,
                scene.framerange.stop,
                tspan[1], tspan[end])
    yval = clamp(ball_sol(i)[1], 0.0, height)
    ypos = rescale(yval, 0, height/2,
                   ground - ballradius, -height/2)
    xpos = rescale(framenumber, 1,
                   scene.framerange.stop,
                  -scene.movie.width/2 + 100,
                   scene.movie.width/2 - 100)
    sethue("orange")
    circle(Point(xpos, ypos), ballradius, :fill)
    sethue("black")
    setline(5)
    rule(Point(0, ground))
end

animate(demomovie, [
    Scene(demomovie, (s, f) -> background("white"), 1:100),
    Scene(demomovie, frame, 1:100)],
    creategif=true,
    pathname="simple-bouncing-ball.gif")

image label

Some animations will require information from more than a single instant. For example, to show the trace made by a chaotic double pendulum, you’ll want to access the generated data not just for each current instant, but for all the previous instants too. So each frame-generating function will do something like this:

function frame(scene, framenumber)
    ...
    for previousframe in 1:framenumber
        # draw just the location
        drawpositionofpendulum(previousframe)
    end
    # finally, draw the pendulum at its current position
    ...

image label

This can also be used to emulate the effect of the phosphor persistence on old cathode ray tube displays... It’s funny how much computer time these days is spent on reproducing analogue effects.

image label

Is this normal?

It’s often useful or necessary to normalize (rescale to between 0 and 1) the incoming frame number. A simple way to do this is:

nfn = rescale(framenumber,
              scene.framerange.start, scene.framerange.stop)

which rescales[2] the frame number so as to return 0.0 at the start of the scene, and 1.0 at the end. If you want a continuous animation with a circular motion, for example, normalize the frame numbers to run between 0 and 2π.

function frame(scene, framenumber)
    background("white")
    nfn = rescale(framenumber, scene.framerange.start, scene.framerange.stop)
    rotate(nfn * 2π)
    juliacircles(100)
end

mymovie = Movie(400, 400, "mymovie")
animate(mymovie, [Scene(mymovie, frame, 1:60)],
    creategif=true,
    pathname="juliaspinner.gif")

image label

And you can produce a back and forth rocking motion if you use sin(nfn * 2π):

function frame(scene, framenumber)
    background("white")
    nfn = rescale(framenumber,
                  scene.framerange.start, scene.framerange.stop)
    rotate(sin(nfn * 2π))
    juliacircles(100)
end

mymovie = Movie(400, 400, "mymovie")
animate(mymovie, [Scene(mymovie, frame, 1:60)],
    creategif=true,
    pathname="juliarocker.gif")

image label

Simple linear motion is also easy if you use the frame number. The answer when anyone asks how to do interactive graphics in Julia is - [drum roll please]:

image label

Easy does it

Another reason for using normalized frame numbers is that you can apply easing functions. These modify the values to make more natural-looking movements, with gentler accelerations and decelerations, without having to calculate the physics involved. There are many ways to get from 0.0 to 1.0, and only one of them is the shortest. Most easing functions take a while to move away from 0.0, compared to the plain linear one, but they soon speed through, before they start slowing down in time to stop at 1.0.

You can get an eased value for the current frame number by accessing the scene’s easing function, which by default is easingflat().

...
    enfn = scene.easingfunction((framenumber - scene.framerange.start),
              0, 1, (scene.framerange.stop - scene.framerange.start))
    rotate(enfn * 2π)
    juliacircles(100)
    ...

Remember to pass a suitable easing function when you run the animate() function.:

animate(mymovie, [Scene(mymovie, frame, 1:60,
          easingfunction=easeinoutexpo)],
    creategif=true,
    pathname="juliaeasing.gif")

For more “exciting” accelerations, you can work your way up through easeinoutcirc, easeinoutsine, easeinoutquad, easeinoutcubic, easeinoutquart, easeinoutquint, to easeinoutexpo.

In this comparison, the linear easingflat starts well, but is quickly overhauled by the quadratic and quintic easing functions, and the exponential is, for a brief moment, faster than all of them.

image label

Each animation takes the same time, but speeds up and slows down differently as it travels from the beginning to the end.

You can also do Bézier easing, using a Bézier curve to control the flow from 0.0 to 1.0. The steepness of the curve determines the speed of the transition.

image label

Here the rotation accelerations follow this Bézier curve:

image label

defined by the points [O, Point(0.01, .99), Point(0.99, -1.5), Point(1.0, 1.0)]. It’s fascinating to tangle with the way the human brain perceives motion.

Passing information

It’s often useful to be able to pass information around from function to function, and there are a couple of ways of doing this. In this simple pool (what we used to call “snooker” or “billiards”) animation, the array of three balls can be defined in a main function and passed to the animation functions.

image label

It would be nice to run the animation for longer, but (as a slackmojif) it quickly reaches the 128KB limit.

Both ways are shown in the next fragment. The initial() function takes an additional argument. The update() function receives the array in a keyword argument optarg, which is accessed via scene.opts.

...  make an array of balls, then:

animate(juliaballmovie, [
    Scene(juliaballmovie, backdrop,                       1:200),
    Scene(juliaballmovie, (s, f) -> initial(s, f, balls), 1:15),
    Scene(juliaballmovie, update, optarg=balls,          15:200),
    ], creategif=true, pathname="juliapool.gif")

The update() function starts like this:

function update(scene, framenumber)
     balls = scene.opts
     ...

(The rest of the code for this animation is simply some basic collision checking (is a ball’s x coordinate greater than the right hand side of the box, etc.), and of little interest... :)

Sometimes, if the task is very simple, it's just easier to define everything in a single main() function. This version of Life uses the DSP.jl package for its conv2() function.

using Luxor, DSP
function nextgeneration!(LifeWorld, m)
    convLifeWorld = conv2(LifeWorld, m)
    lives2 = convLifeWorld .== 2
    lives3 = convLifeWorld .== 3
    twoneighbours   =     LifeWorld  .& lives2[2:end-1, 2:end-1]
    threeneighbours =     LifeWorld  .& lives3[2:end-1, 2:end-1]
    newlife         =  .~(LifeWorld) .& lives3[2:end-1, 2:end-1]
    LifeWorld[:]    = twoneighbours .| threeneighbours .| newlife
end

function generation(LifeWorld, n=1)
    matrix = [1 1 1; 1 0 1; 1 1 1]
    for i in 1:n
        nextgeneration!(LifeWorld, matrix)
    end
end

function draw(width, height, LifeWorld)
    g = Tiler(width, height, size(LifeWorld)[1:2]..., margin=10)
    w = g.tilewidth/2.0
    h = g.tileheight/2.0
    for (pos, n) in g
        LifeWorld[n] == 1 && begin
            setcolor"yellow"
            circle(pos, w, :fill)
        end
    end
end

function main()
    lifemovie = Movie(400, 400, "lifemovie")
    LifeWorld = rand(0:1, 50, 50)
    function frame(scene, framenumber)
        background("seagreen")
        generation(LifeWorld)
        draw(400, 400, LifeWorld)
    end
    animate(lifemovie, [
            Scene(lifemovie, frame, 1:200)
            ],
        creategif=true,
        pathname="/tmp/luxor-life.gif")
end

main()

image label

Using and abusing frame numbers

The frame number in this little homage to Julia’s supercool broadcasting facilities is used for a couple of things.

First, a 2π-normalized version drives the rotation of the whatever-it-is at the top of the mast. Second, the waves are drawn by circles whose radius is controlled by a multiple of the frame number (and they fade out as they get larger):

sethue("orange")
setline(10)
k = 6
for i in 1:k
    setopacity(0.75 - i/k)
    circle(c, i * framenumber, :stroke)
end

image label

Go for a loop

GIF animations usually repeat in an infinite loop. It makes sense, I suppose, because if they played once only and then stopped, you’d often miss them. Perhaps web browsers are smart enough to animate GIFs only when they’re visible (like a light inside a fridge, it’s hard to check). Animations look better when you can’t spot where the last frame stops and the first frame starts. To make an animation look seamless, all you have to do is make the first frame follow logically from the last frame.

This is not always easy to do. But sometimes you don’t need to worry about it.

The eclipse GIF (made in anticipation of the Great Solar Eclipse of 2017, and useful very occasionally for other things) isn’t continuous: the horizontal translation of the three circles jumps from +600 at the end to -600 at the beginning, but they’re off the screen so the start and the end frames are visually identical.

image label

It’s trickier when you want to have something seamless. This GIF looks like continuous snow falling. Can you spot where the last frame jumps to the first?

snowfall

You can’t really spot it, but there are three copies of each snowflake, and you see only one at a time. Here’s a version which shows what’s happening more clearly with just four snowflakes:

simpler snowfall

and, as with the eclipse, you don’t notice the change between the last and first frames, because it happens off-stage, “in the wings”.

Low framerates

The default framerate of animations in Luxor.jl is 30 frames per second. I don’t know why it’s that high; the framerate for movies is typically 24, and for TV it’s 25 or almost 30. (YouTuber Captain Disillusion explains it well.)

I can’t tell which of the following three animations is which.

function framespinner(scene, framenumber)
    background("white")
    nfn = rescale(framenumber, scene.framerange.start,
                  scene.framerange.stop)
    rotate(sin(nfn * 2π))
    juliacircles(50)
end

animation = Movie(200, 200, "animation")

for framerate in [24, 28, 30]
    animate(animation, [Scene(animation,
                 framespinner, 1:2framerate)],
        framerate=framerate,
        creategif=true, pathname="/tmp/juliaspinner-$framerate.gif")
end

24 fps 28 fps 30 fps

Of course, your computer may be sampling the GIFs at different rates, trying to maintain an approximate correspondence between the display’s refresh rate (typically 50 or 60 times a second) and the GIF rate. Or your browser may be trying to be clever. Or perhaps it’s time for a new computer?

You don’t always need high framerates, though. Sometimes an animation works well with low framerates, even down to just one per second. The Julia bikeshed comes in 30 different colors, one every second.

bikeshed

Less is less

Sometimes you want high framerates, or at least smooth motion, but don’t need many frames. This Illuminati animation has 16 frames, and the yellow lines rotate through just π/8 radians. So although the movement looks circular and continuous, it’s just restarting after half a second and a small rotation.

image label

(After seeing this little GIF animation, the Illuminati (UK) reached out and commissioned a more discreet version, with less conspicuous colors, “in order to communicate our core brand values and strategic vision more clearly - in black and white - to investors”. I agreed, as long as I could retain some of the Julia iconography[3].)

When Julia maestro Valentin Churavy announced debugging tool Cthulhu.jl I suddenly felt a strange compulsion to render some graphical offering:

This thing, which seemed instinct with a fearsome and unnatural malignancy, was of a somewhat bloated corpulence, and squatted evilly on a rectangular block or pedestal covered with undecipherable characters. (The Call of Cthulhu, H. P. Lovecraft, 1928)

(Harsh words for the output of @code_warntype?). There are only 10 frames, with some sine and cosine functions generating slimy oscillations, and the Julia logo was pressed into service to provide some blinking eyeballs. It’s a dozen frames.

image label

Rhythmic pulsing or blinking is easy enough to do. I was tempted to record my own heart rate and blinking patterns, but it was quicker to define a simple function:

function eyeblink(n;
        start=0.45,
        finish=0.55)
    if start < n < finish
        return 1 - sin(rescale(n, start, finish, 0, π))
    else
        return 1
    end
end

image label

image label

This function can also be used to generate a heartbeat. Not a realistic one, but alive enough for its purpose.

image label

Multiple scenes

There’s a feature that lets you build an animation from a number of separate scenes. This is occasionally useful. For example, in the following animation of the DifferentialEquations logo (a nice design by Paweł Biernat), there are multiple overlapping scenes. A filled version fades in at frame 100, as the route-following (done using polyportion() is finishing.

animate(demomovie, [
    Scene(demomovie, backdrop,    1:140),
    Scene(demomovie, animatepoly, 1:120,
           easingfunction = easeinoutsine),
    Scene(demomovie, fillpoly,  100:120),
    Scene(demomovie, endscene,  121:140),
    ...

image label

It’s an easy way to have a restful finishing scene, before the looping animation restarts. A pause on the last image helps to make the point better.

function framemove(scene, framenumber)
    delta = rescale(framenumber, 0, scene.framerange.stop, 0, 120)
    background("white")
    fontsize(80)
    fontface("ChunkFive")
    sethue("black")
    text("1", Point(0, delta - 100), halign=:center, valign=:middle)
    text("[  ]", halign=:center, valign=:middle)
    sethue("grey50")
    text("0", Point(0, delta), halign=:center, valign=:middle)
    if framenumber > 4
       fontsize(60)
       setopacity(0.6)
       sethue("red")
       text("X", Point(0, delta), halign=:center, valign=:middle)
    end
end

function framestop(scene, framenumber)
    background("white")
    fontsize(80)
    fontface("ChunkFive")
    sethue("black")
    text("[  ]", halign=:center, valign=:middle)
    text("1",    halign=:center, valign=:middle)
end

julia_one_index = Movie(128, 128, "julia_one_index")

animate(julia_one_index, [
    Scene(julia_one_index, framemove, 0:30),
    Scene(julia_one_index, framestop, 25:60)
    ], creategif=true, framerate=20,
    pathname="/tmp/julia-one-index-animation-1.gif")

image label

Random acts of whatever

Sometimes you want to use random numbers. If your frame-generating function calls rand(), though, you’ll get different random numbers in each frame. This might not be what you want. What you probably want is to have the same random numbers used for each frame. Do this by calling Random.seed!(n) with the same n each frame.

using Luxor, Random

function frame(scene, framenumber)
    background("white")
    Random.seed!(42)

I can’t remember what encouraged me to make this next animation. I think there was a period of a few weeks leading up to the release of Julia version 0.7 when useful packages broke seemingly every day. Each paint splodge (or is it blood?) generated for a frame has to be reproduced again on all subsequent frames. You could store them, of course. But it was easier to use seeded random numbers.

image label

Diversion: A_PNG in the net

The animated GIF format is a grizzled veteran of the internet. There are many younger and more able formats for animated images that would do a better job. Most are faster, smaller, better in other ways, etc. But trying to dethrone the reigning champion is proving difficult. Apple and Google probably want you to use video formats. Google, this week at least, also want you to use WebM (or WebP), until they announce something new. Adobe would probably prefer you to use SVG, or Flash, or something. But there is an obvious competing format that is at first glance better than animated GIFs: the Animated PNG.

Most browsers support Animated PNGs. Here’s one, and if it moves, yours does too:

image label

using Luxor

function frame(scene, framenumber)
    enfn = scene.easingfunction((framenumber - scene.framerange.start),
    0, 1, (scene.framerange.stop - scene.framerange.start))
    shape = ngon(O, scene.movie.height/4, 5, -π/10, vertices=true)
    path = bezierpathtopoly(makebezierpath(shape,
             smoothing=-1.5), steps=40)
    paths = [polyportion(path, mod1(enfn + k, 1.0))[end]
            for k in 0:.25:0.8]
    cols = [Luxor.julia_red, Luxor.julia_green,
            Luxor.julia_purple,  Luxor.julia_blue]
    for (k, p) in enumerate(paths)
        sethue(cols[k])
        circle(p, 15, :fill)
    end
end

animated_png_test = Movie(200, 200, "animated_png")

tempdirectory = "/tmp/"

animate(animated_png_test, [
        Scene(animated_png_test, frame, 0:80)
    ], creategif=true, framerate=30,
    tempdirectory=tempdirectory,
    pathname="/tmp/julia-animated-png-test.gif")

run(`ffmpeg -r 30 -f image2 -i $(tempdirectory)/%10d.png -y /tmp/animated-png.apng`)

Animated PNGs (APNGs) are better quality than animated GIFs because GIFs are restricted to 256 indexed colors, and they don’t do transparency very well. APNGs aren’t always bigger than equivalent GIFs, and have generally better color handling, although this better color handling can sometimes lead to bigger file sizes.

The example code above produces both a GIF and an APNG and I think it requires a relatively recent version of the behemoth that is ffmpeg. Also, it doesn’t produce a continuously looping APNG, and you’ll have to preview it in a browser that supports APNGs because - at least on macOS - they don’t animate when viewed in the Finder.

If you want more control over the looping and compression of the APNG, use the tools listed here. I used a macOS app from here.

All this is a bit moot, though, because on Slack, you’ll find that animated PNGs are supported on desktop clients but not on mobile clients. (Compare this with Dark Mode, which is available on mobile clients but not desktop clients...) So APNGs are not yet ready to loosen GIF’s stranglehold in the world of Slack.

Diversion: Dark mode

Talking of Dark Mode, it’s a good idea to consider that an animated GIF will be displayed on either a white or a dark background. With the increasing popularity of Dark Mode, you shouldn’t assume (as I mistakenly have for ages) that your white backgrounds will blend seamlessly into a white page. You should, ideally, create your GIFs with a transparent background, and design them so that they can appear on either a white or dark background with no problems. Unfortunately this isn’t easy. Transparent animated GIF backgrounds have proved elusive on the few occasions I’ve tried to make them. Compare the GIF with the APNG made with the same code:

image label

The problem appears to be low quality anti-aliasing. So far, this is the best quality transparent-background GIF I’ve been able to obtain from ffmpeg.

Image there’s no heaven

Occasionally small images can be a welcome addition to a slackmojif. With the 128³ restrictions, though, the image has to be reasonably iconic to be useful at low resolutions.

For example, this image of 2001’s HAL-9000 computer (primitive machine learning in action) just about worked with the Julia colors.

image label

The translucent color blends are drawn over the top of the image. (Would the use of Slack have helped avoid the problems on the 2001 ship Discovery One? Probably not, but HAL could have at least added Dark Mode and support for APNGs.)

People powered

A Slack workspace is all about the people. Your Slack colleagues are often represented by unique small images (avatars) and these are an irresistible source for reactions and slackmojifs. I occasionally like to add simple animations of people’s avatars by way of saying “thank you” visually for their help and encouragement. Acknowledging the helpful contributions of others seems to be a legitimate justification for these...

One welcome sight in any Slack channel is the appearance of the Viking-themed avatar of Stefan Karpinski, co-creator of Julia, which always heralds a vital or useful contribution to the discussion, for which every Julia user old and new is grateful. Stefan hasn’t yet complained about me using his exceptionally iconic avatar...

I was disappointed that I failed to make this “Stefan Multitasking” animation small enough to fit into a Slack cell.

image label

Chris Rackauckas named this riff on the Sierpinski Triangle the Skarpinski TriHeart, and it just made it into the 128KB & Below club.

image label

The last two animations in this post (yes, the end is in sight!) split an image into pixels, and each pixel is represented by an instance of a Julia type that has position and velocity. Some rudimentary physics (where is a physicist when you need one?) moves the pixels around in a pseudo-Newtonian way. The pixels don’t interact with each other, of course.

And this does look as if Thanos from Marvel’s Infinity War has snapped his fingers. This animation was made in 2017, so this may well be where Thanos got the idea from.

dissolving head mp4 version to save space

Fortunately for all of us, the compiler quickly restores the original.

undissolving head

Get Slacking

Feel free to copy or re-use anything you see here. And if you want something else made, ask me, I might be able to help. Sometimes inspiration from new directions is very welcome!

The Slack logo animation at the top of this post was made with this code:

using Luxor, Colors
function frame(scene, framenumber)
    background("white")
    nfn_2π = 2π * rescale(framenumber,
                  scene.framerange.start, scene.framerange.stop)
    w = 40; Δw = 8
    setline(w)
    setlinecap("round")
    col=[Lab(76, 14, 70),  Lab(49, 72, 19),
    Lab(74, -28, -32), Lab(66, -48, 19), Lab(50, -33, 85)]
    for (n, θ) in enumerate(range(0, step=2π/4, length=4))
        t = Table(2, 2, 2w + Δw, w + Δw, Point(w + Δw, w + Δw))
        @layer begin
             rotate(θ + nfn_2π)
             translate(30 + 20sin(nfn_2π), 0)
             setcolor(col[mod1(n, end)])
             line(sin(nfn_2π) * t[1], sin(nfn_2π) * t[2], :stroke)
             circle(t[3], w/2, :fill)
             rect(t[3] - (w/2, w/2), w/2, w/2, :fill)
        end
    end
end

slackanimation = Movie(400, 400, "slackanimation")
animate(slackanimation, [
    Scene(slackanimation, frame, 1:60),
    ],
    creategif=true,
    pathname="/tmp/slackanimation.gif")

Footnotes

[1] “not worth the $17 billion” Forbes.com, written on June 7, 2019 two weeks before the actual launch of Slack. Later, on June 22, Forbes reports: “On its first day of trading, Slack shares opened at $38.50, 48% higher than its expected price, giving it a market cap of about $20 billion.”
[2] rescale() is a simple lerp function, but I find it so useful I added it to Luxor.jl. It’s a simple one-line definition, but I got tired of typing it all the time. I once tried to add it to base Julia, but it was considered to be better defined in a separate package.
[3] You can spend money on Illuminati.jl-themed swag here. A small portion of each sale goes to NumFocus and thereby helps the future development of Julia.
[2019-06-23]

cormullion signing off

This page was generated using Literate.jl.