slama.dev

Motion Canvas Icon Groups, Animations, Signals, Effects

In this part of the series, we’ll explore some more layout-related animations, explore the animation flow and discover signals + effects.

We’ll also play around with colors a bit 🙂.

Grouping objects

Motion Canvas doesn’t fully support the same grouping as Manim (i.e. change the color of all objects in this particular group). Instead, we should always be working with the scene hierarchy and layout objects, which do supports certain operations, mostly related to their position, scale/size and rotation.

In this example, we’re also using the fact that Motion Canvas supports any X11 colors names – feel free to browse through them and pick the ones that you like!

import {Layout, makeScene2D, Rect} from '@motion-canvas/2d';
import {all, createRef, sequence} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const rectangles = Array.from({length: 3}, () => createRef<Rect>());

    // we can use any of the names from the X11 color standard!
    // a nice website for picking colors: https://x11.linci.co/
    const rectangleColors = ['crimson', 'forestgreen', 'deepskyblue'];

    const layout = createRef<Layout>();

    view.add(
        <Layout layout gap={50} ref={layout}>
            {rectangles.map((ref, i) =>
                <Rect
                    ref={ref} opacity={0} stroke={rectangleColors[i]}
                    lineWidth={5} size={300}
                />
            )}
        </Layout>
    )

    yield* sequence(0.15, ...rectangles.map(ref => appear(ref())));

    // scale the entire group
    yield* all(
        layout().scale(1.5, 1),
        layout().position.y(-200, 1),
    )

    // suppress the layout for a while and remember the positions
    layout().children().forEach(ref => ref.save())
    layout().layout(false);
    layout().children().forEach(ref => ref.restore())

    // now we can move it
    yield* rectangles[1]().position.y(300, 1);

    // layout doesn't have attributes of the children, so setting colors on it won't work
    yield* all(...rectangles.map(ref => ref().stroke('white', 1)));
    yield* all(...rectangles.map(ref => ref().fill('white', 1)));
});

Arranging objects in Manim is usually done via the arrange function. In Motion Canvas, we again utilize the almighty flexbox… well, kind of – animating certain flexbox properties is still not supported (but will likely be in the future):

import {Circle, Layout, makeScene2D} from '@motion-canvas/2d';
import {all, createRef, sequence, useRandom} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    let random = useRandom(0xdeadbeef);

    const circles = Array.from({length: 15}, () => createRef<Circle>());
    const layout = createRef<Layout>();

    view.add(
        <Layout layout gap={50} ref={layout} alignItems={'center'}>
            {circles.map((ref, i) =>
                <Circle
                    x={random.nextInt(-700, 700)}
                    y={random.nextInt(-300, 300)}
                    ref={ref} opacity={0} stroke={'red'}
                    lineWidth={5} size={random.nextInt(10, 100)}
                />
            )}
        </Layout>
    )

    layout().children().forEach(ref => ref.save())
    layout().layout(false);

    yield* sequence(0.05, ...circles.map(ref => appear(ref())));

    yield* sequence(0.05,
        ...circles.map(ref => all(
            ref().restore(1),
            ref().opacity(1, 1)
        )),
    );

    layout().layout(true);

    // some layout properties are not supported yet :(
    // https://github.com/motion-canvas/motion-canvas/issues/363
    yield* all(
        layout().gap(10, 1),
        layout().direction('column', 1),
    );
});

For grids, Manim’s arrange_in_grid is just a special case of Motion Canvas’ flexbox shenanigans. The only difference here is that we’re newly using the wrap property, since the circles would otherwise be squished and not wrapped to form a grid.

To make the animation a bit more interesting, we can utilize the chroma.js (which Motion Canvas internally uses to work with colors) to assign colors using a color scale.

import {Circle, Layout, makeScene2D} from '@motion-canvas/2d';
import {all, createRef, sequence, useRandom} from '@motion-canvas/core';
import {appear} from "../../utilities";
import chroma from 'chroma-js';

export default makeScene2D(function* (view) {
    let random = useRandom(0xdeadbeef);

    const circles = Array.from({length: 12 ** 2}, () => createRef<Circle>());
    const layout = createRef<Layout>();

    // we can use chroma.js for nice color shenanigans
    // we're using the lightness-chroma-hue for a nicer-looking scale
    let colors = chroma.scale(['#fafa6e', '#2A4858']).mode('lch')
        .colors(circles.length)

    view.add(
        <Layout layout gap={50} ref={layout} wrap={'wrap'} width={1400}
                alignItems={'center'}
        >
            {circles.map((ref, i) =>
                <Circle
                    x={random.nextInt(-600, 600)}
                    y={random.nextInt(-300, 300)}
                    ref={ref} opacity={0} stroke={colors[i]}
                    lineWidth={5} size={random.nextInt(10, 50)}
                />
            )}
        </Layout>
    )

    layout().children().forEach(ref => ref.save())
    layout().layout(false);

    yield* sequence(0.01, ...circles.map(ref => appear(ref())));

    yield* sequence(
        0.01,
        ...circles.map(ref => all(ref().restore(1), ref().opacity(1, 1))),
    );

    layout().layout(true);

    yield* all(
        layout().gap(30, 1),
        layout().width(1200, 1),
    );

    yield* all(
        layout().gap(60, 1),
        layout().width(1600, 1),
    );
});

Add, remove and ordering

The order in which the objects are rendered are based on the scene hierarchy – the higher they are, the sooner they are rendered (i.e. the more at the bottom they are). However, if they differ in their z-index, the one with a higher z-index will always be drawn on top of the other:

import {Circle, Layout, makeScene2D, Polygon, Rect} from '@motion-canvas/2d';
import {all, createRef, sequence, Vector2, waitFor} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    // start with a square
    const square = createRef<Rect>();
    view.add(<Rect ref={square} fill={'white'} size={300}/>);

    yield* waitFor(1);

    // will be added to the very top (i.e. will now be the _last child of root_)
    const circle = createRef<Circle>();
    view.add(<Circle ref={circle} fill={'red'} size={350}/>);

    yield* waitFor(1);

    // this moves the object "to the bottom" (i.e. below the square)
    // this means that it will be the _first child of root_
    circle().moveToBottom();

    yield* waitFor(1);

    // circle: hi again!
    circle().moveToTop();

    yield* waitFor(1);

    // this is a bit more interesting; we move to the bottom, but also
    // increase the Z-index, which is the more important parameter wrt. rendering
    //
    // circle: I'm staying on top!
    circle().moveToBottom();
    circle().zIndex(10);
    circle().fill('blue');

    yield* waitFor(1);

    // circle: *dies*
    circle().remove();

    yield* waitFor(1);
});

Animation flow

We’ve already seen a few of these in the previous post, but we can use different functions for working with animation flow. The main difference between Manim and one of the main features of Motion Canvas is that the animation model inherently allows for a lot of concurrency, since you can have multiple threads concurrently changing different properties, even of the same object:

import {Layout, makeScene2D, Rect, Txt} from '@motion-canvas/2d';
import {all, any, chain, createRef, delay, loop, sequence, waitFor} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const rectangles = Array.from({length: 3}, () => createRef<Rect>());
    const rectangleColors = ['crimson', 'forestgreen', 'deepskyblue'];

    const text = createRef<Txt>();

    view.add(
        <Layout gap={50} fontSize={100} layout direction={'column'} alignItems={'center'}>
            <Txt ref={text} fill={'white'}></Txt>
            <Layout layout gap={50}>
                {rectangles.map((ref, i) =>
                    <Rect
                        ref={ref} opacity={0} stroke={rectangleColors[i]}
                        lineWidth={5} size={300}
                    />
                )}
            </Layout>
        </Layout>
    )

    yield* text().text('sequence(t, ...)', 1)

    // sequence: launch animations in a delayed sequence
    yield* sequence(
        0.15, ...rectangles.map(ref => appear(ref()))
    );

    yield* text().text('all(...)', 1)

    // all: launch all animations together
    yield* all(
        ...rectangles.map(ref => ref().opacity(0, 1))
    )

    yield* text().text('chain(...)', 1)

    // chain: launch animations one after another
    yield* chain(
        rectangles[0]().opacity(1, 0.5),
        rectangles[1]().opacity(1, 1),
        rectangles[2]().opacity(1, 0.25),
    )

    yield* text().text('delay(t, .)', 1)

    // delay: launch animations with a delay
    // we can also nest!
    yield* all(
        delay(0.6, rectangles[0]().opacity(0, 1)),
        delay(0.3, rectangles[1]().opacity(0, 1)),
        delay(0.9, rectangles[2]().opacity(0, 1)),
    )

    // any: continue when at least one finishes!!!
    yield* text().text('any(...)', 1)

    yield* any(
        delay(1, rectangles[0]().opacity(1, 1)),
        delay(0.25, rectangles[1]().opacity(1, 1)),
        delay(2, rectangles[2]().opacity(1, 1)),
    )

    yield* text().text('one finished!', 1)

    yield* waitFor(1);

    yield* text().text('loop(...)', 1)

    // loop: starts an infinite animation; expects an animation factory to do this
    // because of this, we have to use yield (without star!), running this in the background
    yield loop(
        () => rectangles[1]().opacity(0, 0.25).to(1, 0.25),
    )

    yield* waitFor(1);

    // we can, however, still animate some of its other properties!
    yield* all(
        rectangles[1]().scale(0.5, 3),
        text().text('change while looping!', 1),
    );
});

Signals

Signals are Manim’s updaters on crack.

Instead of object’s characteristics being static values, they are usually signals, which are (as the documentation describes) values that can change over time and define dependencies between objects.

This means that, as opposed to Manim’s updater we don’t need to explicitly say that an object’s attribute should be set to this value at every frame – we say that it is that value:

import {Circle, Layout, makeScene2D, Rect, Txt, Node} from '@motion-canvas/2d';
import {all, createRef, sequence, useRandom} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const outerSquare = createRef<Rect>();
    const innerSquare = createRef<Rect>();
    const text = createRef<Txt>();

    view.add(
        // we're using two squares, since rotation changes the position of the top
        // view the scene graph in the UI editor if it's unclear what we're doing
        <Rect ref={outerSquare} size={300}>
            <Rect ref={innerSquare} stroke={'white'} lineWidth={5} size={300}/>
        </Rect>
    );

    view.add(
        <Txt ref={text} text={"A neat square."} fill={'white'}
             // make the properties of text based on signals of the square
             opacity={outerSquare().opacity}
             bottom={outerSquare().top}
             scale={outerSquare().scale}
             padding={30}
        />
    );

    yield* appear(outerSquare());

    // we change the position of outer, dragging the text with
    yield* outerSquare().position.x(-300, 1);

    // we change the rotation of the inner, meaning that the text doesn't change
    // (it relies only on the attributes of the outer square)
    yield* innerSquare().rotation(-180, 1);

    yield* all(
        innerSquare().rotation(0, 1),
        outerSquare().scale(0.5, 1),
        outerSquare().position.x(0, 1),
    )

    // now we add another signal -- we want the text to rotate according to the outer square
    text().rotation(outerSquare().rotation);

    yield* all(
        outerSquare().rotation(-90, 1),
        outerSquare().scale(2, 1),
    )
});

Note that we don’t necessarily need to only assign one signal to another – we can assign a function that takes the value of the signal and modifies it how we want, for example taking the position of an object and converting it to a text to display it:

import {Circle, Layout, makeScene2D, Rect, Txt, Node, Latex} from '@motion-canvas/2d';
import {all, createEaseInOutBack, createRef, sequence, useRandom, Vector2} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const circle = createRef<Circle>();
    const text = createRef<Txt>();

    view.add(
        <>
            <Circle ref={circle} stroke={'white'} lineWidth={5} size={300}/>
            <Latex ref={text} fill={'white'}
                // make the properties of text based on functions of the signals of the circle
                   tex={() => `p = [${circle().x().toFixed(0)}, ${circle().y().toFixed(0)}]`}
                   opacity={circle().opacity}
                   scale={circle().scale}
                   bottom={() => circle().top().addY(-30)}
            />
        </>
    );

    yield* appear(circle());

    yield* circle().position.x(100, 1);

    yield* all(
        circle().position(new Vector2(-200, -100), 1),
        circle().scale(1.25, 1),
    )

    yield* all(
        circle().position(new Vector2(0, 300), 1),
        circle().scale(.5, 1),
    )

    yield* all(
        circle().position(new Vector2(0, 0), 1),
        circle().scale(1, 1),
    )
});

A crucial detail is that signals are lazy – assuming that the value of a signal is some complicated function that relies on other signals, it is only calculated when the value is requested, making it a perfect fit for creating dependencies between properties, but not so much for e.g. a simulation function that needs to change things each frame.

For that, we have effects…

Effects

Effects are functions that are run on their dependency changes, but unlike signals are no longer lazy. This means that all of their dependencies are no longer lazy as well, so if you have many things going on at the same time, things might run a bit slow…

Effects come in two flavors; directly quoting the documentation:

For example, we could use it to create a simple particle simulation like this one:

import {Circle, makeScene2D, Spline} from '@motion-canvas/2d';
import {createDeferredEffect, createRef, createSignal, loop, sequence, useRandom, Vector2} from '@motion-canvas/core';
import {appear} from "../../utilities";
import chroma from 'chroma-js';

export default makeScene2D(function* (view) {
    let random = useRandom(0xdeadbef2);

    // random circles at random base positions
    const circles = Array.from({length: 8 ** 2}, () => createRef<Circle>());
    const basePositions = Array.from({length: 8 ** 2}, () => new Vector2(random.nextInt(-600, 600), random.nextInt(-300, 300)));

    const circle = createRef<Circle>();

    let colors = chroma.scale(['#fafa6e', '#2A4858']).mode('lch')
        .colors(circles.length)

    view.add(
        <>
            <Circle ref={circle} fill={'white'}/>
            {
                circles.map((ref, i) =>
                    <Circle
                        x={basePositions[i].x}
                        y={basePositions[i].y}
                        ref={ref}
                        stroke={colors[i]} fill={colors[i]}
                        lineWidth={5} size={15}
                    />
                )
            }
        </>
    )

    circles.forEach(ref => ref().opacity(0));
    yield* sequence(0.01, ...circles.map(ref => appear(ref())));

    // create a repulsion force around the white circle
    createDeferredEffect(() => {
        circles.forEach((ref, i) => {
            const pos = basePositions[i];

            const vector = pos.sub(circle().position())

            const direction = vector.normalized;
            const distance = vector.magnitude;

            const strength = 1 / 50;

            // we need to push at least as much as the radius of the circle (to not collide)
            const pushStrength = Math.max(
                Math.sqrt(distance) * circle().width() * strength,
                circle().width(),
            )

            const pushVector = pos.add(direction.mul(pushStrength));

            ref().position(pushVector);
        });
    })

    yield* circle().size(100, 1);

    // move it using a nice hand-crafted spline :)
    const spline = createRef<Spline>();
    const progress = createSignal(0);

    let h = 150;
    let w = 400;

    view.add(
        <Spline
            ref={spline}
            points={[[0, 0], [0, -h], [-w, -h], [-w, h], [w, h], [w, -h], [0, -h], [0, 0]]}
            smoothness={0.6}
        />
    )

    circle().position(() => spline().getPointAtPercentage(progress()).position)

    // loop the size scaling indefinitely in the background to make the dots pulse
    // do this 3 times (loop can take a number of times :)
    yield loop(3, () => circle().size(50, 1).to(100, 1))

    yield* progress(1, 10);
});

Tasks

Shuffle

Before trying to animate this, here are a few useful things:

  1. to move a circle along a nice path, you can define a spline between two points and then move along it using the getPointAtPercentage function (see the documentation page for splines)
  2. to animate a value from 0 to 1 that we can use for the percentage value, we can create and animate a new signal (more about what that is in the next Motion Canvas post)
  3. the easeInOutExpo easing curve is nicer for shuffing since it’s more sudden than the default

All of the above can be summarized in the following animation:

import {Circle, makeScene2D, Spline} from '@motion-canvas/2d';
import {createRef, createSignal, easeInOutExpo} from '@motion-canvas/core';

export default makeScene2D(function* (view) {
    const spline = createRef<Spline>();
    const progress = createSignal(0);

    view.add(
        <>
            <Spline
                ref={spline}
                lineWidth={8}
                stroke={'white'}
                points={[[-500, 0], [0, -250], [500, 0]]}
                smoothness={1}
            />
            <Circle
                size={100}
                fill={'white'}
                position={() => spline().getPointAtPercentage(progress()).position}
            />,
        </>,
    );

    yield* progress(1, 2, easeInOutExpo);
});
Author's Solution
import {Circle, Curve, Layout, makeScene2D, Spline} from '@motion-canvas/2d';
import {
    all,
    createRef,
    createSignal,
    easeInOutExpo,
    sequence,
    ThreadGenerator,
    useRandom,
    Vector2
} from '@motion-canvas/core';
import {appear} from "../../utilities";


/**
 * Swaps the positions of two layouts in a visual view by animating them along spline paths.
 *
 * @param {Layout} view - The parent layout that contains both `a` and `b`.
 * @param {Layout} a - The first layout to be swapped.
 * @param {Layout} b - The second layout to be swapped.
 * @param duration - How long should the animation be?
 */
function* swap(view: Layout, a: Layout, b: Layout, duration: number = 1): ThreadGenerator {
    let start = a.position();
    let end = b.position();

    let mid = new Vector2().add(start).add(end).div(2);

    const progress = createSignal(0);

    let s1 = createRef<Spline>();
    let s2 = createRef<Spline>();

    let yOffset = Math.abs(a.position().x - b.position().x) / 2.5;

    view.add(<Spline ref={s1} points={[start, mid.addY(yOffset), end]} smoothness={1}/>)
    view.add(<Spline ref={s2} points={[end, mid.addY(-yOffset), start]} smoothness={1}/>)

    a.position(() => s1().getPointAtPercentage(progress()).position)
    b.position(() => s2().getPointAtPercentage(progress()).position)

    yield* progress(1, duration, easeInOutExpo);  // nicer curve :)

    // don't clutter the scene!
    s1().remove();
    s2().remove();
}

/**
 * Highlight a curve object.
 */
function* highlightCircle(object: Curve): ThreadGenerator {
    yield* all(
        object.stroke('red', 1).to('white', 1),
        object.lineWidth(20, 1).to(object.lineWidth(), 1),
        object.fill('red', 1).to('white', 1)
    );
}

export default makeScene2D(function* (view) {
    const circles = Array.from({length: 5}, () => createRef<Circle>());

    const layout = createRef<Layout>();
    view.add(<Layout layout ref={layout} gap={100}>
        {circles.map(ref =>
            <Circle ref={ref} size={170} fill={'white'} stroke={'white'} opacity={0}/>
        )}
    </Layout>)

    // position them using the layout and stop using it so we can move the nodes freely
    circles.forEach(ref => ref().save())
    layout().layout(false);
    circles.forEach(ref => ref().restore())

    yield* sequence(0.15, ...circles.map(ref => appear(ref())));

    yield* highlightCircle(circles[0]());

    let swaps = 20;
    let speedStart = 1;
    let speedEnd = 0.15;

    // use MotionCanvas' RNG so that the results are seeded
    let random = useRandom(0xdeadbeef);

    for (let i = 0; i < swaps; ++i) {
        let s1 = Math.floor(random.nextFloat() * circles.length);
        let s2;
        do {
            s2 = Math.floor(random.nextFloat() * circles.length);
        } while (s2 === s1);

        let duration = speedStart - Math.abs(speedStart - speedEnd) / swaps * i;

        yield* swap(
            view,
            circles[s1](),
            circles[s2](),
            duration,
        );
    }

    yield* highlightCircle(circles[0]());
});

Triangle

Unlike Manim’s Circle.from_three_points, Motion Canvas doesn’t have a utility function for doing this. Instead, the following functions (along with a createDeferredEffect) could be useful:

/**
 * Cross product of two 3D points.
 */
function cross(a: [number, number, number], b: [number, number, number]): [number, number, number] {
    return [
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0]
    ];
}

/**
 * Calculate intersection coordinates of two lines.
 */
function lineIntersection(p1: Vector2, p2: Vector2, p3: Vector2, p4: Vector2): Vector2 {
    const pad = (point: Vector2): [number, number, number] => [point.x, point.y, 1];

    const line1Padded = [pad(p1), pad(p2)];
    const line2Padded = [pad(p3), pad(p4)];

    const line1Cross = cross(line1Padded[0], line1Padded[1]);
    const line2Cross = cross(line2Padded[0], line2Padded[1]);

    const intersection = cross(line1Cross, line2Cross);
    const [x, y, z] = intersection;

    // Return the intersection point in the xy-plane
    return new Vector2(x / z, y / z);
}

/**
 * Calculate the bisector of a given line segment.
 */
function perpendicularBisector(p1: Vector2, p2: Vector2): [Vector2, Vector2] {
    const diff = p1.sub(p2);

    let direction = cross([diff.x, diff.y, 0], [0, 0, 1]);
    let directionVector = new Vector2(direction[0], direction[1]);

    const midpoint = p1.add(p2).div(2);

    return [midpoint.add(directionVector), midpoint.sub(directionVector)];
}
Author's Solution
import {Circle, Latex, Line, makeScene2D, Txt} from '@motion-canvas/2d';
import {all, createDeferredEffect, createRef, delay, Reference, sequence, Vector2} from '@motion-canvas/core';
import {appear} from "../../utilities";

/**
 * Cross product of two 3D points.
 */
function cross(a: [number, number, number], b: [number, number, number]): [number, number, number] {
    return [
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0]
    ];
}

/**
 * Calculate intersection coordinates of two lines.
 */
function lineIntersection(p1: Vector2, p2: Vector2, p3: Vector2, p4: Vector2): Vector2 {
    const pad = (point: Vector2): [number, number, number] => [point.x, point.y, 1];

    const line1Padded = [pad(p1), pad(p2)];
    const line2Padded = [pad(p3), pad(p4)];

    const line1Cross = cross(line1Padded[0], line1Padded[1]);
    const line2Cross = cross(line2Padded[0], line2Padded[1]);

    const intersection = cross(line1Cross, line2Cross);
    const [x, y, z] = intersection;

    // Return the intersection point in the xy-plane
    return new Vector2(x / z, y / z);
}

/**
 * Calculate the bisector of a given line segment.
 */
function perpendicularBisector(p1: Vector2, p2: Vector2): [Vector2, Vector2] {
    const diff = p1.sub(p2);

    let direction = cross([diff.x, diff.y, 0], [0, 0, 1]);
    let directionVector = new Vector2(direction[0], direction[1]);

    const midpoint = p1.add(p2).div(2);

    return [midpoint.add(directionVector), midpoint.sub(directionVector)];
}

/**
 * Calculate the position for the label of the given circle to always point towards the center of the triangle.
 */
function getLabelPosition(circle: Reference<Circle>, circles: Array<Reference<Circle>>): Vector2 {
    let center = new Vector2();

    circles.forEach(c => {
        center = center.add(c().position())
    });

    center = center.div(circles.length);

    const vector = circle().position().sub(center).normalized;

    return circle().position().add(vector.mul(70));
}


export default makeScene2D(function* (view) {
    const circles = Array.from({length: 3}, () => createRef<Circle>());
    const positions = [new Vector2(0, -300), new Vector2(-300, 200), new Vector2(300, 200)];

    const labels = Array.from({length: 3}, () => createRef<Txt>());
    const labelStrings = ['a', 'b', 'c'];

    const lines = Array.from({length: 3}, () => createRef<Line>());

    view.add(
        <>
            {circles.map((ref, i) =>
                <Circle
                    ref={ref} opacity={0} size={30} fill={'white'}
                    position={positions[i]}
                />
            )}
            {lines.map((ref, i) =>
                // always keep the lines on adjacent circles
                <Line
                    points={[circles[i]().position, circles[(i + 1) % circles.length]().position]}
                    ref={ref} stroke={'white'}
                    lineWidth={8} size={30}
                    end={0}
                />
            )}
            {labels.map((ref, i) =>
                // always keep the label away from the center of the triangle
                <Latex
                    tex={labelStrings[i]}
                    ref={ref} fill={'white'}
                    opacity={circles[i]().opacity}
                    scale={circles[i]().scale}
                    position={() => getLabelPosition(circles[i], circles)}
                    fontSize={70}
                />
            )}
        </>
    )

    const circumscribedCircle = createRef<Circle>();
    view.add(
        <Circle ref={circumscribedCircle}
                size={30} lineWidth={5} start={1} stroke={'red'} zIndex={-1}/>
    )

    // change the center and size of the circumscribed circle to be within the bisectors
    createDeferredEffect(() => {
        let position = lineIntersection(
            ...perpendicularBisector(circles[0]().position(), circles[1]().position()),
            ...perpendicularBisector(circles[1]().position(), circles[2]().position()),
        );

        let size = position.sub(circles[0]().position()).magnitude;

        circumscribedCircle().position(position);
        circumscribedCircle().size(size * 2);
    })

    // nice appearing
    yield* all(
        sequence(0.25, ...circles.map(ref => appear(ref()))),
        delay(
            0.5,
            sequence(0.25, ...lines.map(ref => ref().end(1, 0.5))),
        ),

        delay(
            1.5,
            circumscribedCircle().start(0, 1),
        )
    );

    // move circles
    yield* all(
        ...circles.map((ref, i) => ref().position(circles[(i + 1) % circles.length]().position(), 1))
    );

    // move circles again
    yield* all(
        circles[0]().position(circles[0]().position().addX(-100), 1),
        circles[1]().position(circles[1]().position().addX(100).addY(-300), 1),
        circles[2]().position(circles[2]().position().addY(100), 1),
    );
});

Wave

Noting extra here, just a BFS.

Here is the text input that I used to generate the maze, if you wish to use it.

#######################################################
#  #################            ##                    #
# ##################           ####                   #
# #################            ####                   #
#  ###############             #####               # ##
#      #########               #####               ####
#         ###                  ######            ######
#          ###            ##   #####    ###       #####
#          ####      ########   ####  #####        ## #
#          #####   ##########   ###  ########       # #
#         #####   ###########        ########         #
#         ####   ###########        ##########        #
#        ##      ###########        ##########        #
#      ####     ############      #############       #
#    ######     ############     #############        #
# #########  ## ###########     #########    #        #
# ############### #########     #######               #
# ###############   ######      #####f                #
# ###############    #####       ####                 #
#   #############      #                ##            #
#     #  #######                       ########### ####
#          ###         #              #################
# ##                  ####            #################
#####                ######          ##################
######                ######         ##################
# ###      ###        #######  ###   ###############  #
#         ####         ############   ####  #######   #
#        #####          ############          ###     #
#         ###            ##########                   #
#######################################################
Author's Solution
import {Layout, makeScene2D, Rect} from '@motion-canvas/2d';
import {all, Color, createRef, delay, Reference, useRandom} from '@motion-canvas/core';
import chroma from "chroma-js";


/**
 * A nicer interpolation of colors using the 'lab' color space.
 */
export function colorLerp(from: any, to: any, value: number) {
    return Color.lerp(from, to, value, 'lab');
}

/**
 * Shuffle an array (deterministically).
 */
function shuffle<T>(array: T[]): T[] {
    let random = useRandom();

    for (let i = array.length - 1; i > 0; i--) {
        const j = Math.floor(random.nextFloat() * (i + 1));
        [array[i], array[j]] = [array[j], array[i]]; // Swap elements
    }

    return array;
}

export default makeScene2D(function* (view) {
    const inputString = `
#######################################################
#  #################            ##                    #
# ##################           ####                   #
# #################            ####                   #
#  ###############             #####               # ##
#      #########               #####               ####
#         ###                  ######            ######
#          ###            ##   #####    ###       #####
#          ####      ########   ####  #####        ## #
#          #####   ##########   ###  ########       # #
#         #####   ###########        ########         #
#         ####   ###########        ##########        #
#        ##      ###########        ##########        #
#      ####     ############      #############       #
#    ######     ############     #############        #
# #########  ## ###########     #########    #        #
# ############### #########     #######               #
# ###############   ######      ######                #
# ###############    #####       ####                 #
#   #############      #                ##            #
#     #  #######                       ########### ####
#          ###         #              #################
# ##                  ####            #################
#####                ######          ##################
######                ######         ##################
# ###      ###        #######  ###   ###############  #
#         ####         ############   ####  #######   #
#        #####          ############          ###     #
#         ###            ##########                   #
#######################################################`;

    const lines = inputString.trim().split('\n');
    const grid: string[][] = lines.map(line => line.split('').map(char => char));

    const gridReferences: Reference<Rect>[][] = grid.map(line => line.map(_ => createRef<Rect>()));

    let cols = gridReferences[0].length;
    let rows = gridReferences.length;

    let width = 1600;
    let gap = 1;

    view.add(
        <Layout layout width={width} wrap={'wrap'} gap={gap}>
            {
                ...gridReferences.flat().map(
                    (ref, i) => <Rect
                        ref={ref} size={(width - gap * (cols - 1)) / cols}
                        fill={(grid[Math.floor(i / cols)][i % cols] == '#') ? 'white' : 'black'}
                        lineWidth={gap + 1}
                        stroke={'rgb(255, 255, 255)'}
                        opacity={0}
                    />
                )
            }
        </Layout>
    )

    yield* all(
        ...shuffle(gridReferences.flat()).map((ref, i) => delay(0.0001 * i, ref().opacity(1, 1)))
    );

    const distances: number[][] = grid.map(line => line.map(_ => 0));

    const queue: [[number, number], number][] = [[[1, 1], 0]];
    const visited: Set<string> = new Set();  // what the fuck!?!?

    while (queue.length > 0) {
        const [[x, y], steps] = queue.shift()!;

        distances[y][x] = steps;

        if (visited.has(`${x},${y}`))
            continue;

        if (grid[y][x] == "#")
            continue;

        visited.add(`${x},${y}`);

        const moves = [
            [0, -1], // Up
            [0, 1], // Down
            [-1, 0], // Left
            [1, 0], // Right
        ];

        for (const [dx, dy] of moves) {
            const [nx, ny] = [x + dx, y + dy];

            if (!(nx >= 0 && nx < cols && ny >= 0 && ny < rows))
                continue

            queue.push([[nx, ny], steps + 1]);
        }
    }

    let maxDistance = distances.flat().reduce((a, b) => Math.max(a, b));

    let colors = chroma.scale(["#ef476f", "#ffd166", "#06d6a0", "#118ab2"]).colors(maxDistance + 1);

    yield* all(
        ...gridReferences.flat().map(
            (ref, i) => {
                let x = i % cols;
                let y = Math.floor(i / cols);

                let distance = distances[y][x];
                let color = grid[y][x] == "#" ? 'white' : colors[distance];

                return delay(0.05 * distance, ref().fill(color, 1, undefined, colorLerp));
            }
        )
    )
});

Hilbert

Here are some useful things:

Author's Solution
import {Knot, makeScene2D, Spline} from '@motion-canvas/2d';
import {all, createRef, Reference, Vector2} from '@motion-canvas/core';


export default makeScene2D(function* (view) {
    const getSpline = (ref: Reference<Spline>, positions: Vector2[]) => {
        return <Spline
            ref={ref}
            lineWidth={5}
            stroke={'white'}
            smoothness={0}
            end={0}
        >
            {positions.map(pos => (<Knot position={pos}/>))}
        </Spline>
    }

    let size = 300;
    let iterations = 3;

    let line = createRef<Spline>();

    let positions = [new Vector2(-size, size), new Vector2(-size, -size), new Vector2(size, -size), new Vector2(size, size)];

    view.add(getSpline(line, positions))

    yield* line().end(1, 1);

    // not pretty code, but not sure how to do this cleaner / clearer
    for (let i = 1; i <= iterations; i++) {
        let lineRef = line().clone();
        lineRef.opacity(0);
        view.add(lineRef)

        let newSegmentScale = (2 ** (i) - 1) / (2 ** (i + 1) - 1)

        yield* all(
            line().scale(newSegmentScale, 1),
            line().topLeft(line().topLeft(), 1),
            line().stroke('gray', 1)
        )

        let l2 = line().clone();
        view.add(l2);

        yield* all(
            l2.topRight(lineRef.topRight(), 1),
        )

        let l3 = line().clone();
        let l4 = l2.clone();

        view.add(l3);
        view.add(l4);

        yield* all(
            l3.bottomRight(lineRef.bottomLeft(), 1),
            l3.rotation(90, 1),
            l4.bottomLeft(lineRef.bottomRight(), 1),
            l4.rotation(-90, 1),
        )

        let newPositionKnots = [
            ...l3.children().reverse(),
            ...line().children(),
            ...l2.children(),
            ...l4.children().reverse(),
        ]

        let newPositions = newPositionKnots.map(pos => pos.absolutePosition().sub(lineRef.absolutePosition()));

        let newLine = createRef<Spline>();

        view.add(getSpline(newLine, newPositions))

        yield* newLine().end(1, 1);

        line().remove();
        l2.remove();
        l3.remove();
        l4.remove();

        line = newLine;
    }
});