lil-gui

Bezier Controller

This demonstrates how to make a custom controller blah blah blah. It controls cubic bezier curves—the kind you use for easing. The controller isn't responsible for any bezier math (we import a library for that), it just provides a useful interface for changing the 4 numbers that describe the curve.

This page:
import GUI from '../../dist/lil-gui.esm.js';
import './BezierController.js';

import Bezier from 'https://cdn.skypack.dev/cubic-bezier-easing@1.0';

const params = {
	showPreview: true,
	// The controller expects data in [ x1, y1, x2, y2 ] format
	curve: [ .85, .05, .10, .95 ],
	duration: 2.5,
};

const gui = new GUI();
const preview = document.getElementById( 'curve-preview' );

let easing;

function updateCurve() {
	easing = new Bezier( ...params.curve );
}

updateCurve();

// BezierController adds this method to GUI
gui.addBezier( params, 'curve' ).onChange( updateCurve );

gui.add( params, 'duration', 0.5, 5 );
gui.add( params, 'showPreview' )
	.onChange( v => {
		preview.style.visibility = v ? '' : 'hidden';
	} );

function animate() {

	requestAnimationFrame( animate );

	const time = Date.now() / ( params.duration * 1000 );
	let val = easing( time % 1 );
	if ( Math.floor( time ) % 2 == 0 ) val -= 1;

	preview.style.transform = `translateX( ${val * 100}% )`;

}

animate();
./BezierController.js
import { Controller, GUI, injectStyles } from '../../dist/lil-gui.esm.js';

/**
 * Controls a cubic-bezer curve represented by an array of four numbers in
 * the form of [ x1, y1, x2, y2 ].
 */
export default class BezierController extends Controller {

	constructor( parent, object, property ) {

		// The CSS selector for this controller will be `.lil-gui .controller.bezier {`
		super( parent, object, property, 'bezier' );

		// Create some DOM elements for our UI.
		const svgNS = 'http://www.w3.org/2000/svg';

		this.$svg = document.createElementNS( svgNS, 'svg' );
		this.$path = document.createElementNS( svgNS, 'path' );
		this.$line1 = document.createElementNS( svgNS, 'line' );
		this.$line2 = document.createElementNS( svgNS, 'line' );
		this.$knob1 = document.createElementNS( svgNS, 'circle' );
		this.$knob2 = document.createElementNS( svgNS, 'circle' );

		// Initialize some attributes that won't change.
		this.$svg.setAttribute( 'viewBox', '0 0 1 1' );

		this.$knob1.setAttribute( 'r', 0.05 );
		this.$knob2.setAttribute( 'r', 0.05 );

		this.$line1.setAttribute( 'x1', 0 );
		this.$line1.setAttribute( 'y1', 0 );

		this.$line2.setAttribute( 'x1', 1 );
		this.$line2.setAttribute( 'y1', 1 );

		// Add all those elements to our SVG.
		this.$svg.appendChild( this.$line1 );
		this.$svg.appendChild( this.$line2 );
		this.$svg.appendChild( this.$path );
		this.$svg.appendChild( this.$knob1 );
		this.$svg.appendChild( this.$knob2 );

		// **Finally, add our DOM to the built-in `$widget` element so it
		// actually appears in the controller**.
		this.$widget.appendChild( this.$svg );

		// The next step is adding interactivity. Since both knobs have the
		// same basic behavior, we can abstract this a bit. Touch listeners are
		// omitted for brevity.

		// `makeDraggable` will add the same event listeners to both knobs, but
		// each will do something slightly different with the events.
		const makeDraggable = ( knob, setter ) => {

			const clamp = x => Math.max( 0, Math.min( 1, x ) );

			const inverseLerp = ( t, a, b ) => ( t - a ) / ( b - a );

			knob.addEventListener( 'mousedown', () => {
				window.addEventListener( 'mousemove', onMouseMove );
				window.addEventListener( 'mouseup', onMouseUp );
			} );

			const onMouseMove = e => {

				e.preventDefault();

				// Convert the mouse coordinates to a [0-1] value within the
				// SVG's bounding rectangle.
				const rect = this.$svg.getBoundingClientRect();

				const x = inverseLerp( e.clientX, rect.left, rect.right );
				const y = inverseLerp( e.clientY, rect.bottom, rect.top );

				// Here's where the abstraction comes in: depending on which
				// knob we're dragging, we'll update the appropriate control
				// point.
				setter( clamp( x ), clamp( y ) );

			};

			// **Whenever the user is done modifying a value, we need to call
			// `_callOnFinishChange()`.**
			const onMouseUp = () => {

				this._callOnFinishChange();

				window.removeEventListener( 'mousemove', onMouseMove );
				window.removeEventListener( 'mouseup', onMouseUp );

			};

		};

		// When `$knob1` is dragged, get a reference to the array and modify the first pair of coordinates.
		makeDraggable( this.$knob1, ( x, y ) => {

			const c = this.getValue();

			c[ 0 ] = x;
			c[ 1 ] = y;

			// You should call both of these methods any time you modify the value.
			this._callOnChange();
			this.updateDisplay();

		} );

		// Do the same thing for `$knob2`, targeting the second control point.
		makeDraggable( this.$knob2, ( x, y ) => {

			const c = this.getValue();

			c[ 2 ] = x;
			c[ 3 ] = y;

			this._callOnChange();
			this.updateDisplay();

		} );

		// Make a copy of the original value. We'll use this later for `reset()`.
		this._initialValue = this.save();

		// Update the display to reflect initial values. This should be the
		// final step for every controller constructor.
		this.updateDisplay();

	} /* end constructor */

	// **Every controller has to implement `updateDisplay`**. It should modify the
	// DOM to represent the current state of the value.
	updateDisplay() {

		const c = this.getValue();

		// Position the control point knobs.
		this.$knob1.setAttribute( 'cx', c[ 0 ] );
		this.$knob1.setAttribute( 'cy', c[ 1 ] );

		this.$knob2.setAttribute( 'cx', c[ 2 ] );
		this.$knob2.setAttribute( 'cy', c[ 3 ] );

		// Update the lines that connect them to the corner.
		this.$line1.setAttribute( 'x2', c[ 0 ] );
		this.$line1.setAttribute( 'y2', c[ 1 ] );

		this.$line2.setAttribute( 'x2', c[ 2 ] );
		this.$line2.setAttribute( 'y2', c[ 3 ] );

		// Draw a bezier curve using the `d` attribute of the `<path>` element.
		this.$path.setAttribute( 'd', `M 0 0 C ${c[ 0 ]} ${c[ 1 ]}, ${c[ 2 ]} ${c[ 3 ]}, 1 1` );

	}

	// Controllers that target non-primitive data types need to override the `save`
	// method. It should return a _copy_ of the object in its current state.
	save() {
		return Array.from( this.getValue() );
	}

	// Controllers that target non-primitive data types need to override the `load`
	// method. This should unserialize the values returned by the `save()` method, again, updating the value piece-wise.
	load( saved ) {

		const arr = this.getValue();

		arr[ 0 ] = saved[ 0 ];
		arr[ 1 ] = saved[ 1 ];
		arr[ 2 ] = saved[ 2 ];
		arr[ 3 ] = saved[ 3 ];

		// Loading is a type of change, so we need to call these methods in `load`.
		this._callOnChange();
		this._callOnFinishChange();
		this.updateDisplay();

		// `load` should return `this` to allow chaining.
		return this;

	}

	// Controllers that target non-primitive data types need to override the `reset`
	// method (the default implementation will destroy references). A good pattern is to store the original
	// value in the constructor with `save()`, then `load()` it in the reset method.
	reset() {
		return this.load( this._initialValue );
	}

}

// lil-gui exports `injectStyles` so you can define your controller's CSS alongside its class.
// This spares users from having to include a stylesheet, in keeping with the "no setup" spirit of dat.gui.
injectStyles( `
.lil-gui .controller.bezier svg {
	width: 100%;
	/* flip coordinates so that up is positive */
	transform: scaleY(-1);
	background-color: var(--widget-color);
	border-radius: var(--widget-border-radius);
}

.lil-gui .controller.bezier svg * { 
	/* let us use a tiny viewbox without huge strokes */
	vector-effect: non-scaling-stroke;
}

.lil-gui .controller.bezier path {
	stroke: var(--number-color);
	stroke-width: 2px;
	fill: none;
}

.lil-gui .controller.bezier line {
	stroke: var(--focus-color);
	stroke-width: 1px;
}

.lil-gui .controller.bezier circle {
	fill: var(--text-color);
	cursor: pointer;
}` );

// Finally, you can "register" your controller with its own method on the `GUI` class.
GUI.prototype.addBezier = function() {
	return new BezierController( this, ...arguments );
};