The <canvas> element isn't typically used for building interactive UIs, but you'll probably end up experimenting with it eventually.
I ended up implementing a basic in-canvas UI for my RallySportED project a while back; what follows in this post is a description of that implementation, with a focus on its object-oriented component logic.
Before getting into it, it's worth repeating that the focus is on the logic and in-code interface of the UI rather than on how to render it. You could use whatever method of rendering you like – for convenience, I've used this canvas renderer I wrote earlier.
You can find the full companion source code on GitHub. The code snippets in this article have been simplified for readability.
Below, you can test an abstract sample of the UI implementation we'll be looking at; the scene is fully contained inside a <canvas> element. Hover the mouse over the colored shapes to see their color change in response. Click on the shapes to make them disappear. Toggle the inline frame to reset the scene.
The above scene was constructed using the following code:
import {triangle} from "triangle.js"; import {square} from "square.js"; const ui = [ triangle({ position: translation_vector(-1, -1, 0), color: color_rgba(0, 255, 0), }), triangle({ position: translation_vector(1, 1, 0), color: color_rgba(0, 150, 255), }), square({ position: translation_vector(1, -1, 0), color: color_rgba(255, 200, 0), }), triangle({ position: translation_vector(-1, 1, 0), color: color_rgba(255, 0, 255), scale: translation_vector(0.5, 0.5, 0.5), onUpdate: function() { const timestamp = performance.now(); this.rotation = rotation_vector((timestamp / 10), 0, (timestamp / 10)); }, }), ]; (function renderLoop() { render(ui.map(component=>component())); window.requestAnimationFrame(renderLoop); })();
The triangle and square objects represent UI components that have been derived from a base component interface. As most of the logic has been encoded into the interface, defining the scene and its interactivity didn't require a whole lot of new code.
The following couple sections discuss the component system, describing how the triangle and square objects were derived from the base component interface.
The base component is a factory function that returns an instance of the base component interface, to be used in creating derived components.
import {isComponentHovered} from "./mouse-pick.js"; export function component({ onClick = ()=>{}, onEnter = ()=>{}, onLeave = ()=>{}, onUpdate = ()=>{}, } = {}) { let wasHovered = false; const self = { id: crypto.randomUUID(), update: function() { if (isComponentHovered(self.id)) { if (!wasHovered) { self.onEnter(); } wasHovered = true; } else if (wasHovered) { self.onLeave(); wasHovered = false; } this.onUpdate(); }, onClick, onEnter, onLeave, onUpdate, }; window.addEventListener("mousedown", ()=>{ isComponentHovered(self.id) && self.onClick(); }); return self; }
The factory function takes as its arguments a couple of callback functions for responding to events on the component – like the mouse cursor entering the component, or the component being clicked.
The update() function of the returned interface is to be called on each tick (e.g. requestAnimationFrame()). It acts as the component's event loop, detecting and relaying to callback functions information about events related to the component.
For the purposes of the sample scene that we saw in Section 1.1, the polygon component is a factory function that extends the base component interface to produce an n-sided polygonal mesh component.
import {component} from "component.js"; export function polygon({ vertices = [], color = color_rgba(0, 0, 0, 255), position = translation_vector(0, 0, 0), rotation = rotation_vector(0, 0, 0), scale = scaling_vector(1, 1, 1), onClick = function() { this.ngons.forEach(ngon=>ngon.material.hasFill = false); }, onEnter = function() { this.ngons.forEach(ngon=>ngon.material.color = color_rgba(180, 180, 180)); }, onLeave = function() { this.ngons.forEach(ngon=>ngon.material.color = color); }, onUpdate = ()=>{}, } = {}) { const self = component(); const polyMesh = mesh(...); self.onClick = onClick.bind(polyMesh); self.onEnter = onEnter.bind(polyMesh); self.onLeave = onLeave.bind(polyMesh); self.onUpdate = onUpdate.bind(polyMesh); return function() { self.update(); return polyMesh; }; }
The factory function takes several arguments that define the properties of the polygon – like its shape, color and position. The function also provides default handlers for mouse interaction so that they don't need to be specified explicitly by each instantiation of the component.
The factory function returns another function that will automatically update the component's event loop and return the corresponding polygonal mesh.
The event handler callbacks are bound to the polygon's mesh object to allow the mesh to be manipulated in the callback handlers (for example, to apply a different color on mouse hover).
The polygon component could be used like this (pseudocode):
const triangle1 = polygon({...}); const triangle2 = polygon({...}); while (appIsRunning) { render(triangle1(), triangle2()); }
We can derive further specialized components from the polygon component to act as shorthands for different kinds of polygons.
For example, triangles always have three vertices, so we can create a specific polygonal shape component for them:
import {polygon} from "polygon.js"; export function triangle(args = {}) { return polygon({ ...args, vertices: [ vertex(-1, -1, 0), vertex( 1, -1, 0), vertex( 1, 1, 0), ], }); }
The triangle() factory function returns an instance of a specific kind of polygon component: one with three vertices. The factory function implicitly takes the same arguments as the factory function of the polygon component, routing them to that latter function.
Shorthand components could be used like this (pseudocode):
const poly1 = triangle({color: "red"}); const poly2 = square({color: "blue"}); while (appIsRunning) { render(poly1(), poly2()); }
Recalling from Section 1.2, the base component interface provides callback functions for mouse events, such as the mouse hovering over the component.
The following sample code shows how to define hover and click handlers for an instance of a component:
const canvasEl = document.querySelector("#canvas"); let colors = ["unset", "yellow"]; const component = triangle({ onEnter: function() { canvasEl.style.cursor = "pointer"; }, onLeave: function() { canvasEl.style.cursor = "unset"; }, onClick: function() { colors.push(colors.shift()); canvasEl.style.backgroundColor = colors[0]; }, }); render(component());
The handlers will change the mouse cursor to a pointer when the cursor is hovering over the component, and toggle the background color between black and white when clicking on the component. You can give it a spin below:
The underlying implementation for detecting mouse events first triangulates the scene, then computes barycentric coordinates for each triangle to find which polygon the cursor is inside of, and finally acts according to whether the mouse is being clicked, whether the cursor has just entered or left the polygon, etc.
We've now looked at a basic implementation of a componentized in-canvas UI with mouse event handling.
It's only one of the possible ways of implementing such a UI though, and leaves open various corner cases that a full implementation would need to deal with.
Although I initially wrote it for RallySportED – and it works well enough for the purposes of that project – I may take some extra time in the future to improve/extend it. If so, I'll either write a new blog post about it or update this one.