What if you could just put graphics on your web page, or in your web app, by just writing graphics JavaScript code and then using a <graphics-element src="..."> the same way you'd put regular JavaScript on a page using a <script> tag? As it turns out, that's not a "what if", it's something that the modern web supports, so if that's something you want, or need: maybe the <graphics-element> is for you!
Also, if this made a difference in your dev life, consider (temporarily, even?) becoming a patron of my work over on my Patreon page, or send a one-time donatation to help keep this project, and others like it, funded. Any amount is appreciated!
Using the <graphics-element> is similar to writing any other HTML:
<graphics-element attr1="val1" attr2="val2" ...></graphics-element>
In addition to the standard HTML element attributes like
id
or class
, the following tag-relevant
attributes are supported:
title
- this is both the standard HTML title attribute,
as well as the text that gets used as figure caption underneath your
graphic.
Omitting this will result in a warning on the console.
(always label your figures)
src
- this is the standard HTML attribute for indicating
the source code for this element.
width
- the (unitless) width for your graphic. This value
is optional, but your graphics code must use
setSize if omitted.
height
- the (unitless) height for your graphic. This
value is optional, but your graphics code must use
setSize if omitted.
centered
- an optional "valueless" attribute that will
center your graphic on the page.
float
- an optional attribute that takes values
"left"
or "right"
, to float your graphic
inside your other page content.
safemode
- an attribute that tells the graphics element
to run its code in "safe mode", where all loops are wrapped with
"infinite loop detection" that will throw an Error once a loop runs
for too long. This attribute can either be given a number to specify
the number of iterations at which a loop is probably running
indefinitely, or can be left empty in which case that number is set to
1000 iterations.
Additionally, a <graphics-element> is just a normal HTML tag, and allows child content. This means that if you wish to add guide text, that's as easy as just adding a paragraph:
<graphics-element title="an example" src="..." width="400" height="200">
<p>This guide text will render inside the graphics-element "box".</p>
</graphics-element>
As a <graphics-element> is just an HTML element, we can also use
data-*
attributes to inject value presets:
<graphics-element title="an example" src="..." width="400" height="200" data-radius="1.5">
</graphics-element>
Note that this will set the corresponding variable in your graphics code to the value you specified, with some caveats:
let myPerfectVar;
,
must use the standard HTML data attribute naming convention on the
HTML side, e.g. data-my-perfect-var="..."
.
And because HTML attributes only support strings, data-* attribute values get automatically converted to "the type they look like they should be":
"true"
and "false"
get
automatically converted to their boolean equivalents.
parseFloat(v) == v
check) gets automatically converted to
a number.
Also note that as a <graphics-element> is just an HTML element, we
can listener for load and error events using either the legacy
onload
and onerror
event handling properties
(and emphatically not as HTML attributes, it's not 1998
anymore: JS event listening is something you add
on the JS side, not on the HTML side), or by adding an event
listener for the "load"
or "error"
events.
// wait for the graphics-element tag to be available:
customElements.whenDefined(`graphics-element`).then(() => {
// Then get our graphics element
const e = document.querySelector(`graphics-element`);
// Set up a load handler:
const loaded = (evt) => console.log(`graphics element loaded`, evt.target);
// And then either bind that the legacy way:
e.onload = loaded;
// Or the (really not even) modern way:
e.addEventListener(`load`, loaded);
// Also set up an error handler:
const failed = (evt) => console.log(`graphics element failed!`, evt.detail);
// And again, either bind that legacy style:
e.onerror = failed;
// Or using normal JS:
e.addEventListener(`error`, failed);
});
Since the <graphics-element> tag is a custom element, the browser will only load this tag in if JS is enabled. However, on the modern web the baseline assumption should be that your users run both ad-blockers and script blockers, so you'll want to have fallback images in place for when JS is disabled.
This can be done using an <img> tag with the `fallback` class, ideally with the same height and width as your <graphics-element> will take up:
<graphics-element title="an example" src="..." width="400" height="200">
<img
class="fallback"
src="..."
width="...px"
height="...px"
title="Please enable JS to interact with this graphic"
>
</graphics-element>
In order to make adding placeholders to your pages easier, the <graphics-element> code repository provides a tool that can be used with Node.js to automatically generate fallback images and update the code in an HTML page, automatically making sure the correct width and height values are set, with a placeholder text prefilled.
Graphics source code uses normal JavaScript, with all graphics API functions available as global functions. While the smallest source code is just an empty file, the recommended minimal code is:
function setup() {
setSize(123, 456);
}
function draw() {
clear(`white`);
}
And because modern JS may require await
ing asynchronous
returns, both of those can be marked async, too:
async function setup() {
setSize(123, 456);
await fetch(`http://example.com/ping`);
}
async function draw() {
clear(`white`);
const img = await downloadImage(...);
image(img);
}
Both of these functions are technically optional, but omitting them
doesn't make a lot of sense: by default the <graphics-element>
will first run setup
, and will then run draw
,
once. The setSize(width, height)
call is optional as long
as you've specified the width
and
height
attributes on the
<graphics-element>
tag itself, but must be present if
you decide to omit them (and note that setSize()
can be
called at any time to resize your graphic).
If you cannot link to a source code URL, you may also inline your code
using a
<graphics-source>
element:
<graphics-element title="an example" width="400" height="200">
<graphics-source>
function setup() {
// ...
}
function draw() {
// ...
}
</graphics-source>
</graphics-element>
Or you may inject it using a source code function if you're doing all your work in JS:
const myGraphicsElement = document.createElement(`graphics-element`);
function sourceCode() {
function setup() {
setSize(300, 100);
}
function draw() {
clear(`yellow`);
}
}
// load the code once the custom element loader is done:
customElements.whenDefined(`graphics-element`).then(() => {
myGraphicsElement.loadFromFunction(sourceCode);
});
While not available from CDN, the graphics-element project comes with a graphics-element.d.ts file that you can use to enable type hinting, autocomplete, and docs lookups in your code editor, provided it has a way to add typing support.
As an example, if you use
Visual Studio Code, you can
quickly and easily add the graphics-element.d.ts
to your
project, which allows VS Code to show type hints and documentation, as
well as perform call autocompletion, by adding a
jsconfig.json
to your project.
Say you've put all your graphics-element source files in their own
directory (which is generally a good idea to keep it from cluttering up
other kinds of client-side JS), then you can create a new file called
jsconfig.json
in the same directory, with the following
content:
{
"files": ["graphics-element.d.ts"],
"include": ["./*.js"]
}
The "files" property tells VS Code where it can find the .d.ts file, and allows you to put in relative directories (so you don't need to have the .d.ts file in the same directory), and the "include" property tells VS Code which files that .d.ts will apply to (in this case "." means "everything in this directory").
Several global variables exist to make your graphics life easier:
width
- the width of your graphic, in pixels.height
- the height of your graphic, in pixels.playing
- a boolean indicating whether this graphic is
currently running in animated mode or not.
frame
- the current frame's number. Every time
draw
runs, this number will increase by 1.
frameDelta
- the time in milliseconds since the previous
frame finished drawing.
pointer
- an object representing the mouse/stylus/touch
input "cursor" (see below).
keyboard
- an object that tracks which keys are currently
being pressed (see below).
currentMovable
- when using movable points, this will
represent the movable point under the pointer, if there is one.
Note that except for pointer
and keyboard
,
these values can be reassigned by your code, with unpredictable
results, so be careful what you name your own variables!
You can automatically declare slider-controlled variables, which adds a
slider to the on-page graphics element box, and automatically creates a
global variable for your code to use. For example, to declare a variable
radius
that can range from 0 to 1, with slider steps of
0.001 and a starting value of 0.5, you would use:
function setup() {
addSlider(`radius`, { min: 0, max: 1, step: 0.001, value: 0.5 });
}
After this, the radius
variable will be globally available,
so that this code will "just work":
function draw() {
circle(width / 2, height / 2, radius);
}
For convenience, any variable that ends in a number will have that
number shown using subscript styling, so a slider for
t1
will show "t1" rather than "t1" as its slider
label, and any variable with _
in it will subscript the
part after the underscore, so that k_p
becomes
"kp" rather than "kp". Because your code doesn't care,
but your users do, and subscripted variables are much nicer to look at.
Sometimes you want buttons to do things that you could do with
click handlers, but would be much nicer with a normal button. In those
cases, you can use the addButton
function:
let p;
function setup() {
addButton(`play`, (btn) => {
btn.textContent = togglePlay() ? `pause` : `play`;
});
p = new Point(width / 2, height / 2);
}
function draw() {
clear();
setColor(`black`);
point(p.x, p.y);
p.x += random(-2, 2);
p.y += random(-2, 2);
}
Rather than having to write your own click-drag logic, you can mark
things as "movable" by calling setMovable(...)
. This can
either be Point
instances or an array of
Point
instances. After marking them as movable, the API
does the rest. When the pointer is over movable points it will update
the global currentMovable
value and change to the typically
pointing finger icon, with click-dragging (or touch-dragging)
automatically updating your point's coordinates.
const p;
function setup() {
setSize(400, 400);
p = new Point(200, 200);
setMovable(p);
}
function draw() {
clear(`#eee`);
setColor(`#444`);
point(p.x, p.y);
}
That's all the code we need: users can now click/tap/touch-drag our point around.
Graphics interaction is based on "the pointer", which is a unified handler for mouse, stylus, and touch handling, so you don't have to care whether your code runs on a desktop computer, a laptop, a tablet, a phone, or anything else. Event handling uses five functions:
function pointerActive(trueOrFalse) {
// the pointer either entered or left the canvas region.
}
function pointerDown(x, y) {
// a mouse down, stylus down, or touch start at
// graphics coordinate x/y (not screen coordinate).
}
function pointerUp(x, y) {
// a mouse up, stylus up, or touch end at
// graphics coordinate x/y.
}
function pointerClick(x, y) {
// a shorthand function for a pointerdown followed
// by a pointerup on the same x/y coordinate
}
function pointerMove(x, y) {
// a mouse move/drag, stylus move/drag, or touch
// move/drag at graphics coordinate x/y.
}
function pointerDrag(x, y) {
// a shorthand function for a pointer move at some
// time after a pointer down, but before pointer up.
}
In addition to this, there is the global pointer
object
that can be consulted in any of your code, with the following
properties:
active
- true/false based on whether or not the pointer
is currently on/over the canvas.
x
- the current graphics x coordinate for the pointer
y
- the current graphics y coordinate for the pointer
down
- true/false based on whether the pointer is
currently down or not.
drag
- whether we're currently dragging the pointer.
mark
- when the pointer is down, this is an object
{x, y}
representing where the pointer down event
happened.
When focus is on the <graphics-element>, keyboard input will be sent into your graphics code, using the following functions:
function keyDown(key, shift, alt, ctrl, meta) {
// the "key" value is the key name being pressed,
// shift, alt, ctrl, and meta are boolean flags
}
function keyUp(key, shift, alt, ctrl, meta) {
// as above
}
Note that there is no "key typed" handler, you get to decide whether
down or up counts as "typing". There is a global
keyboard
object that tracks which keys are down: if a key
is down, it will have a corresponding
keyboard[keyName]
entry, with its value being the
Date.now()
timestamp when the key got pressed. Once the key
is released, its entry gets removed from keyboard
(not just
set to a falsey value).
The graphics element supports automatic highlighting of parts of your graphic by using color tags. For example, the following graphics code:
function draw() {
setColor(`red`);
line(0, 0, width, height);
text(`the center of the universe`, width / 2, height / 2);
}
can be combined with guide text that includes a highlighting tag for the corresponding color:
<graphics-element title="..." src="...">
<p>Let's highlight <red>the universe</red>!</p>
</graphics-element>
Now, whenever a user places their pointer (mouse, stylus, or touch) on that marked-up text, the corresponding color will get highlighted in the graphic. All named CSS colors are supported for this purpose.
The <graphics-element> element allows you to specify
multiple source files, with one "main "file indicated using the
src="..."
attribute, and additional sources through the use
of the <source>
element:
...
<p>Let's look at the base case:</p>
<graphics-element
title="multiple sources"
src="./base-code.js"
></graphics-element>
<p>We can extend this by using the standard approach:</p>
<graphics-element title="established convention" src="./base-code.js">
<source src="variation-01.js" />
</graphics-element>
<p>But there's a more interesting way to tackle this:</p>
<graphics-element title="a creative variation" src="./base-code.js">
<source src="variation-02.js" />
</graphics-element>
...
This will create a single source file, but allows you to split up your code in a way that lets you reuse the same code across multiple graphics elements with only the differences stored in each extra source file.
Your additional sources
may contain additional setup() and draw() functions,
which will be run at the end of the main setup()
and
draw()
functions, in the same order of your
<source>
elements.
You cannot use this to "redeclare" any other function, though: if the
main source file contains
function test() { ... }
and your additional source file
also specifies function test() { ... }
, then you'll see
an error in your Developer Tools "console" tab along the lines of:
Uncaught (in promise) SyntaxError: redeclaration of function test
note: Previously declared at line 123, column 45
The way additional sources are included fundamentally differs
from the standard module import mechanism: JS imports are loaded in
their own, isolated, scope and will not have
access to any of the graphics functions and constants, whereas code
loaded through a <source>
element effectively gets
packed up as a single bundle with the "main" graphics source code and so
will have access to all the graphics API
functions.
As such, if you need pure JS code imported, use an
import
statement in your source code, but if you need
something like a class that knows how to draw itself by calling graphics
API functions, you can load that same code as a
<source>
and things will work just fine.
As example, pure utility code that doesn't do any sort of drawing should always be imported:
class PID {
constructor(p, i, d, ...) {
this.kp = p;
this.ki = i;
this.kd = d;
...
}
getRecommendation(current, target) {
const error = target - current;
const P = (kp * error) / dt;
const I = ...
// ...
return P + I + D;
}
}
Nothing in the above code has anything to do with drawing things, and is pure JS utility code. As such, we can simply import it directly:
import { PID } from "./pid.js";
let pid;
function setup() {
pid = new PID(1, 0, 0);
}
// ...
However, the following kind of code should be a
<source>
inclusion:
class Airplane extends Circle {
heading = 0;
constructor(x, y, heading) {
super(x, y, 100);
this.heading = heading;
}
draw() {
save();
translate(this.x, this.y);
rotate(this.heading);
line(0, 0, 20, 0);
restore();
}
}
This code heavily relies on the graphics API, and so trying to
import
it will throw errors at runtime.
That covers the "regular" documentation, but head on over to the API documentation to learn all about which constants and functions you can use to create cool graphics!
And if this made a difference in your dev life, consider (temporarily, even?) becoming a patron of my work over on my Patreon page, or send a one-time donatation to help keep this project, and others like it, funded. Any amount is appreciated!