Skip to content
Open
256 changes: 210 additions & 46 deletions src/core/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,238 @@
* @private
*
* Handles the logic for creating a loading indicator.
* Currently, the loading indicator is basic and can be extended in the future.
*/

/**
* Creates a loading indicator when the sketch's setup() function is running.
* It is called and removed automatically using the presetup and postsetup lifecycles hooks.
* It is called and removed automatically using the presetup and postsetup lifecycle hooks.
* Registers loading indicator decorators for createCanvas(), resizeCanvas(), and noCanvas()
* to manage the loading indicator overlay.
*
* @param {*} p5 The p5 constructor
* @param {*} fn The p5 prototype object
* @param {*} lifecycles Lifecycle hooks for the sketch
*/
export default function loading(p5, fn, lifecycles) {
lifecycles.presetup = function () {
if (typeof window === 'undefined' || this._loadingIndicator) {
return;
}

const canvasParent = this.canvas?.parentElement;
let container = this._userNode || canvasParent || document.body;
p5.registerDecorator('p5.prototype.createCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.resizeCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false));

if (typeof container === 'string') {
container = document.getElementById(container) || document.body;
}
/**
* Sets a custom loading animation for the current sketch.
*
* Calling `loadingAnimation()` without a callback disables the loading
* animation. Calling it with a callback replaces the default animation.
* The callback receives the overlay's 2D rendering context, its width,
* its height, and the current animation frame. The callback's `this` value
* is the current p5 instance.
*
* @method loadingAnimation
* @param {Function} [callback] Function used to draw each animation frame.
* @chainable
*/
fn.loadingAnimation = function (callback) {
this._hasCustomLoading = true;
this._loadingAnimation = callback;
_removeLoadingOverlay(this);
return this;
};

this._loadingIndicator = createLoadingIndicator(container);
lifecycles.presetup = function () {
this._isSketchLoading = true;
};

lifecycles.postsetup = function () {
if (this._loadingIndicator) {
this._loadingIndicator.remove();
this._loadingIndicator = null;
}
this._isSketchLoading = false;
_removeLoadingOverlay(this);
};
}

/**
* Creates and stylizes the loading indicator.
* As a helper function, it can be extensible and modified in future versions.
* Creates the loading canvas to directly overlay the sketch canvas
* and starts the spinning logo animation loop.
*
* @private
* @param {HTMLElement} container The HTML element to append the indicator to
* @returns {HTMLElement} The loading indicator div element
* @param {p5} pInst The p5 instance.
*/
function createLoadingIndicator(container) {
if (!document.getElementById('p5-loading-style')) {
const loadingStyle = document.createElement('style');
loadingStyle.id = 'p5-loading-style';
loadingStyle.textContent =
'@keyframes p5-loading-spin { to { transform: rotate(360deg); } }';
document.head.appendChild(loadingStyle);
function _createLoadingOverlay(pInst) {
if (pInst._hasCustomLoading && typeof pInst._loadingAnimation !== 'function') {
return;
}

const actualCanvas = pInst.canvas?.elt || pInst.canvas;
if (!actualCanvas) return;

let overlay = pInst._loadingOverlay;

// If overlay doesn't exist yet, create it and animate it
if (!overlay) {
overlay = document.createElement('canvas');
overlay.id = `${actualCanvas.id || 'defaultCanvas0'}_loadingOverlay`;
overlay.classList.add('loading-indicator');
pInst._loadingOverlay = overlay;

const ctx = overlay.getContext('2d');
let frameCount = 0;

const animate = () => {
if (!pInst._isSketchLoading) {
return;
}

ctx.clearRect(0, 0, overlay.width, overlay.height);
const frame = frameCount++;
if (pInst._hasCustomLoading) {
pInst._loadingAnimation.call(
pInst,
ctx,
overlay.width,
overlay.height,
frame
);
}
else {
_drawLoadingIndicator(
ctx,
overlay.width / 2,
overlay.height / 2,
frame
);
}

pInst._loadingOverlayFrame = requestAnimationFrame(animate);
};

animate();
}

// Positions the loading indicator to overlay the sketch canvas
_positionCanvas(overlay, actualCanvas);

if (overlay.parentNode !== actualCanvas.parentNode) {
actualCanvas.parentNode.insertBefore(overlay, actualCanvas.nextSibling);
}
}

/**
* Matches the size and position of the loading canvas to the user's sketch canvas.
*
* @private
* @param {HTMLCanvasElement} loadingCanvas The overlay canvas element.
* @param {HTMLCanvasElement} actualCanvas The sketch canvas element.
*/
function _positionCanvas(loadingCanvas, actualCanvas) {
loadingCanvas.width = actualCanvas.width;
loadingCanvas.height = actualCanvas.height;

const width = actualCanvas.style.width || `${actualCanvas.offsetWidth || actualCanvas.width}px`;
const height = actualCanvas.style.height || `${actualCanvas.offsetHeight || actualCanvas.height}px`;

const indicator = document.createElement('div');
indicator.className = 'loading-indicator';
indicator.style.cssText = `
position: fixed;
inset: 0;
margin: auto;
width: 30px;
height: 30px;
border-radius: 50%;

border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: rgba(0, 0, 0, 0.8);
animation: p5-loading-spin 1s linear infinite;
z-index: 9999;
`;

container.appendChild(indicator);
return indicator;
Object.assign(loadingCanvas.style, {
width,
height,
position: 'absolute',
top: `${actualCanvas.offsetTop}px`,
left: `${actualCanvas.offsetLeft}px`,
margin: '0',
padding: '0',
pointerEvents: 'none',
zIndex: '9999'
});
}

/**
* Stops the loading indicator animation and removes the overlay canvas from the DO
*
* @private
* @param {p5} pInst The p5 instance.
*/
function _removeLoadingOverlay(pInst) {
if (pInst._loadingOverlay) {
cancelAnimationFrame(pInst._loadingOverlayFrame);
pInst._loadingOverlay.remove();
pInst._loadingOverlay = null;
}
}

/**
* Draws a canvas-based animated loading indicator.
* The loading indicator is a spinning p5 logo.
*
* Credits to Raphaël de Courville for creating the p5 logo sketch
*
* @private
* @param {CanvasRenderingContext2D} ctx The 2D canvas context to draw on.
* @param {Number} x The x-coordinate for the logo center.
* @param {Number} y The y-coordinate for the logo center.
* @param {Number} t The frame count used to calculate rotation.
*/
function _drawLoadingIndicator(ctx, x, y, t) {
let rotationSpeed = 3.25;
let indicatorSize = 1.5;

// Semi-transparent gray background
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

ctx.save();
ctx.translate(x, y);
ctx.scale(indicatorSize, indicatorSize);

ctx.rotate((t * rotationSpeed * Math.PI) / 180);
ctx.translate(-14, -14);

ctx.fillStyle = '#ED225D';
ctx.beginPath();

ctx.moveTo(16.909, 10.259);
ctx.lineTo(25.442, 7.683);
ctx.lineTo(27.118, 12.839);
ctx.lineTo(18.62, 15.738);
ctx.lineTo(23.895, 23.218);
ctx.lineTo(19.448, 26.443);
ctx.lineTo(13.895, 19.095);
ctx.lineTo(8.487, 26.25);
ctx.lineTo(4.169, 22.961);
ctx.lineTo(9.444, 15.738);
ctx.lineTo(0.88, 12.647);
ctx.lineTo(2.558, 7.487);
ctx.lineTo(11.156, 10.258);
ctx.lineTo(11.156, 1.364);
ctx.lineTo(16.91, 1.364);

ctx.closePath();
ctx.fill();
ctx.restore();
}

/**
* Intercepts canvas methods to create, update, or remove the loading indicator
*
* @private
* @internal
*
* @param {Boolean} isLoading True to show the loading indicator; false to hide it.
* @return {Function} A decorator function for the target canvas method.
*/
export function _handleLoadingIndicator(isLoading) {
return function (target) {
return function (...args) {
const result = target.call(this, ...args);

// Create loading overlay if canvas is loading
if (isLoading) {
if (this._isSketchLoading) {
_createLoadingOverlay(this);
}
}

// Remove loading overlay if canvas isn't loading
else {
_removeLoadingOverlay(this);
}

return result;
};
};
}
14 changes: 11 additions & 3 deletions src/core/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,14 @@ class p5 {
this._millisStart = globalThis.performance.now();

const context = this._isGlobal ? window : this;
if (typeof context.setup === 'function') {
await context.setup();
try {
if (typeof context.setup === 'function') {
await context.setup();
}
}
catch (error) {
await this._runLifecycleHook('postsetup');
throw error;
}
if (this.hitCriticalError) return;

Expand Down Expand Up @@ -654,7 +660,9 @@ p5.registerAddon(rendering);
p5.registerAddon(renderer);
p5.registerAddon(renderer2D);
p5.registerAddon(graphics);
p5.registerAddon(loading);
if (typeof window !== 'undefined') {
p5.registerAddon(loading);
}

export default p5;

Expand Down
Loading