How it works
Inspired by Bartosz Ciechanowski's fantastic interactive explanations, I decided to create my own. This article is about how I built my own explanation, which is about exponentials.
Technologies
To build the page, I used Lit (a web-component library), SVG, and Vite (a frontend development environment).
I chose Lit to display and build the diagrams and animations, using custom elements.
A custom element is an HTML element, just like <p>
or <img>
, but with behaviour defined by the developer.
Custom elements are made with the web component suite of technologies, but these only provide basic APIs for creating elements.
To remedy this, Lit builds on top of web components, giving features like reactive properties and templating.
This made it easy to create diagrams and animations, then add them to my page.
I decided on SVG as the image format for the diagrams and animations. SVG has quite a simple syntax, and because SVG is a vector graphics format, I could scale any of my diagrams as I pleased.
To help develop the project, I used Vite. Vite provides a fast development server, which made it easy to preview my site locally. To me, the greatest benefit of the server was that it had HMR (Hot Module Replacement), which is where whenever a file is saved, the browser updates to the new files. I found this cool when designing diagrams and styling content, as I could quickly see, and then iterate on my designs, instead of diverting my focus to manually reload the page.
Making the diagrams
To make the diagrams, I created a TypeScript library where I could declaratively build each SVG diagram with code.
Below is the code needed to make the above diagram.
const a = MathsFigure.createFromText("a");
const b = MathsFigure.createFromText("b");
const c = MathsFigure.createFromText("c");
const compound = a.add(b).equate(c);
The library works by putting small pieces together, which build up to make more complex graphics. I call each small piece a "chunk".
Chunks
A "chunk" is what I call each symbol or group of symbols in a diagram.
From the equation above, 'e', '=', 'm' and so on, are all examples of chunks.
Each chunk is implemented as an object with an element
property, and a layout()
method.
A chunk can also contain other chunks. From above, 'mc' is a chunk, which contains the chunks 'm' and 'c'.
I call chunks that contain other chunks "composite chunks", and chunks which don't contain other chunks "atomic chunks".
In order to position all the chunks, the chunk element
s first have to be connected to the DOM.
Before they are connected, elements don't have useable x and y coordinates, or width and height, all of which are needed to position chunks relative to one another.
I needed a system where I could first connect all the chunks to the DOM, then handle any positioning afterwards.
This is the point of the Chunk.layout()
method.
layout()
is only called when the the chunks first connect to the DOM, and its purpose is to position the Chunk.element
property.
Again from the equation above, this is positioning the 'm', next to the 'c', to make 'mc'.
But before doing any positioning, layout()
recursively calls the layout functions of any
With this system, I could first set up my final composite chunk, connect it to the DOM, and then run its layout function, thereby allowing me to use the x and y coordinates of the elements for positioning.
Figures
To programmatically position the chunks, I extended the Chunk
interface, to what I call a Figure
.
A figure, is a chunk, but with additional methods which create composite chunks.
These methods include append()
, scale()
and translate()
.
figure1.append(figure2);
Append for example, updates Figure1's layout function to position figure1.element
next to figure2.element
.
Each method returns this
which lets me chain methods:
figure1.append(figure2).append(figure3);
MathsFigure
Figure
only has generic methods for creating composite chunks.
For maths related methods, I created MathsFigure
, which extends Figure
, and added new methods, like plus()
and divide()
Displaying the diagrams
I used my SVG library within my own custom elements to create and display the diagrams.
ChunkElement
ChunkElement
is a class I made which handles the boilerplate of getting the diagrams onto the page.
It renders the diagrams, and then laying out the diagrams once they connected to the DOM.
Then to make individual diagrams, I could extend ChunkElement
and it would already have the boilerplate done for me.
LitElement
is a subclass of HTMLElement
, so has all the HTMLElement
properties and methods, but also some additional functionality like reactive properties and templating.
ChunkElement
extends LitElement
.
LitElement
is a subclass of HTMLElement
, so has all the HTMLElement
properties and methods.
This means that I can register ChunkElement
or any of its subclasses as custom HTML elements.
On top of HTMLElement
, LitElement
also provides functionality like reactive properties and templating, both of which are especially useful for creating animations.
Using my SVG library, I set up a default diagram in ChunkElement
's constructor, and set it as a property called finalComposite
.
class ChunkElement extends LitElement {
private finalComposite: Chunk;
override constructor() {
super.constructor()
this.finalComposite = createTextChunk("default");
}
...
}
Then I add finalComposite.element
to the render()
method
so that it will be displayed as part of the element.
class ChunkElement extends LitElement {
...
override render() {
return html`
<svg xmlns="http://www.w3.org/2000/svg">
${this.finalComposite.element}
</svg>
`;
}
}
render()
returns the output from a tagFunction named html
imported from the lit-html package.
html
takes a standard JS template string as input, which it uses to create an HTML template element.
The template element holds the content of the custom element.
To handle what the element does when it is connected to the DOM, I used the connectedCallback()
method.
I set the connectedCallback method to first run this.finalComposite.layout()
, in order to position all the elements in finalComposite.
Then, once the pieces have been positioned, the viewbox of the SVG needs to be updated to fit the newly layed out graphic. This is because otherwise the diagram may be cropped, off-centre, or not even in the frame.
In the following example the red rectangle represents a potential viewbox for an SVG. Everything inside the rectangle is what can be seen by the user.
A viewbox positioned like the one above, would cut off part of the graphic.
Instead, the viewbox should be positioned to fit the content of the SVG, so that the whole graphic can be seen, while also not leaving too much white-space.
This gives the correct final image.
To actually position the viewbox properly it was quite simple.
I just get the x and y coords of the graphic (the top-left corner), as well as the width and height, and then give these values to the container SVG viewBox
attribute.
Individual diagrams
To make individual diagrams, I extend ChunkElement
, then override the constructor to make the specific diagram I need.
export class APlusBEqualsC extends ChunkElement {
constructor() {
super();
const a = MathsFigure.createFromText("a");
const b = MathsFigure.createFromText("b");
const c = MathsFigure.createFromText("c");
this.finalComposite = a.plus(b).equate(c);
}
...
}
So now I have a class APlusBEqualsC
, but I want it to be useable as an element.
To do this I used a decorator provided by Lit called @customElement()
.
@customElement("a-plus-b-equals-c")
export class APlusBEqualsC extends ChunkElement {...}
What @CustomElement(...)
does in this case, is register the class with the browser, and associate it with the name "a-plus-b-equals-c".
This way, any HTML (that imports the custom element), can use <a-plus-b-equals-c>
as a valid tag.
<html>
<head>
<script type="module" src="./src/a-plus-b-equals-c"></script>
</head>
<body>
<h1>
My diagram!
</h1>
<a-plus-b-equals-c></a-plus-b-equals-c>
<p>
This diagram represents ...
</p>
</body>
</html>
Which displays:
My diagram!
This diagram represents...
Making animations
To make animations I used SVG transforms to move the elements around, with movements calculated from the value of a
.The
itself outputs a value between 0 and 1, depending on how far along the slider the handle is. I can then calculate how much to translate the element as a function of the value from the . As the handle is moved, the input to the function changes, and the amount to translate also changes. The 's value is stored as a reactive property on the custom element that holds the graphic, meaning that when the the 's value changes, the element reloads, which updates the SVG for the user.The following code calculates a circular path that an element can be translated along.
const radius = 50;
// "phase" correlates to how far round the circle the dot is
const initialPhase = (3 / 2) * Math.PI;
const phase = slideValue * (2 * Math.PI) + initialPhase;
const dx = Math.cos(phase) * radius
const dy = Math.sin(phase) * radius + radius
dx
and dy
can then be be used to translate the element, as per the example below.
Here, you can see the dot moves in a circular path, based on the value. I followed exactly the same process to move the elements made with my SVG library.
Building for production
Once I had made all the custom elements for all the diagrams and animations I wanted, I needed to build for production.
To do this, I used the vite build
tool, which handled bundling all my files for me.
A bundler looks at all the imports on each page, then recursively looks at any sub-imports that the imported files may have, and then groups the imported files into one large file. This way when a user is importing files, instead of going back and forth individually requesting all the files, the user only makes one request - for the bundled file.
Vite does this automatically, by running vite build
, with a specified output folder.
This creates a production ready output that I can copy to my server.
Final thoughts
My explanation of exponents was my first time making anything with Lit or using client-side rendering. Before this I had only used backend frameworks like Express.js and Flask in order to create websites. Being able to add interactivity and reactivity to my site easily was really cool, and not something I'd explored before. And the fact that I could reuse components was especially useful when writing this current article, as I when referencing previous diagrams I could just write their tagname into the HTML to use them again.
I plan on writing more explanations in the future, and hopefully with each iteration I'll get faster, and they'll get better.