Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Creating Linked Slider Inputs Constrained To A Given Total In Angular 9.0.0-rc.5

By Ben Nadel on

Last weekend, I suddenly got inspired to create a set of linked slider / range input controls in Angular, where adjusting one slider would implicitly change the values of the related sliders. It seemed like a fun little code-kata; but, when I sat down to implement it, the challenge was more, well, challenging than I had expected it to be. After a few mornings - and a few different approaches - I finally got something working in Angular 9.0.0-rc.5.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

When I first started putting this code-kata together, I had expected the Slider / Range component to be the most challenging part. And, to be clear, my Slider implementation is very minimalist in functionality. What I quickly realized, however, was that the true complexity in this demo was in how the related sliders were to be kept in lock-step.

For this particular demo, I have three sliders: Good, Fast, and Cheap. But, three is arbitrary; I wanted a solution that would work with two sliders or with four sliders, or with however many sliders I might need for a particular problem-space. It's one thing to create a solution that works with a concrete problem, it's another thing to create a solution that works with an abstract concept.

At first, I coded the value distribution logic into the App component that contained the sliders. But, as the logic grew and began to demand some state management, I decided to move the value distribution complexity into a Class that would encapsulate the complexity of it all.

I ended up creating a Class called Equalizer. The Equalizer class takes a minimum value, a maximum value, and an array of values. The array of values can be of arbitrary length (greater than 1); and, when collection of values is summed together, it must be equal to the maximum value. The assertion that the Equalizer makes is that, as the values are adjusted, they will always sum to the maximum value.

The Equalizer class exposes a single public method: setValue():

setValue( targetIndex: number, newValue: number ) : number[]

The idea being that we ask the Equalizer to set one of the individual values to a new magnitude. The Equalizer then reacts by detracting that magnitude from the other values in such a way that the magnitude is distributed equally among the non-targeted indices.

To be clear, my implementation is just one of the ways in which you could do this. My approach uses a "round robin" technique in which the Equalizer loops over the value indices when distributing the inverse-magnitude. It maintains this round robin reference across calls to .setValue() such that multiple calls maintain a continuous loop over the index-space.

For each increment / step that needs to be detracted, the Equalizer checks one index; and, if the value at the given index is not a local maxima, the value is changed. If, however, the value at the given index cannot absorb the change, the Equalizer moves onto the next index and attempts to apply the change there.

Once the Equalizer applies the change in the .setValue() method, it returns an array of numbers which contain the read-only results.

With that said, let's look at the code for the Equalizer class. The bulk of the logic is in the .setValue() method body:

export class Equalizer {

	private lastIncrementedIndex: number;
	private lastTargetIndex: number;
	private minValue: number;
	private maxValue: number;
	private values: number[];

	// I initialize the equalizer.
	constructor(
		minValue: number,
		maxValue: number,
		initialValues: number[]
		) {

		// Validate the possible range of values.
		if ( minValue >= maxValue ) {

			throw( new Error( "Min value must be less than Max value." ) );

		}

		// Validate the number of values.
		if ( initialValues.length === 1 ) {

			throw( new Error( "Initial values must have a length greater than 1." ) );

		}

		// Validate the initial state of the values. Since the point of the equalizer is
		// to maintain a total across the distribution, the values must start out as the
		// summation of the max value.
		if ( this.sum( initialValues ) !== maxValue ) {

			throw( new Error( "Initial values don't sum to max value." ) );

		}

		this.minValue = minValue;
		this.maxValue = maxValue;
		this.values = initialValues;

		this.lastIncrementedIndex = -1;
		this.lastTargetIndex = -1;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I set the given index to the given value and return the resultant state of the
	// equalizer values.
	public setValue( targetIndex: number, newValue: number ) : number[] {

		// If the target index has changed, let's reset our distribution references.
		if ( targetIndex !== this.lastTargetIndex ) {

			this.lastTargetIndex = targetIndex;
			this.lastIncrementedIndex = targetIndex;

		}

		var currentValue = this.values[ targetIndex ];
		// Constrain the application of the new value to the target index.
		var nextValue = this.constrain( newValue );

		// Get the portion of the new value that was actually consumed.
		var delta = ( nextValue - currentValue );

		// If no portion of the new value was actually consumed, there's nothing left to
		// do.
		if ( ! delta ) {

			// NOTE: This probably shouldn't happen. Smells like developer-error.
			return( this.values.slice() );

		}

		// At this point, we've validated the new value against the target value, we can
		// apply the new value back to the collection.
		this.values[ targetIndex ] = nextValue;

		// Now, we have to distribute the INVERSE of the delta to the rest of the values
		// in the equalizer. We want to distribute the delta equally across all of the
		// other facets, so let's keep looping and handing out a single step.
		var deltaToDistribute = Math.abs( delta );
		var step = ( delta > 0 )
			? -1
			: 1
		;

		// Since we know that the equalizer values will always maintain a fixed sum, we
		// know that it is safe to keep looping until the delta has been fully consumed.
		while ( deltaToDistribute ) {

			// Increment and constrain the next index.
			if ( ++this.lastIncrementedIndex >= this.values.length ) {

				this.lastIncrementedIndex = 0;

			}

			// As we distribute the inverse delta, always skip the target index as this
			// index received the whole of the new value above.
			if ( this.lastIncrementedIndex === this.lastTargetIndex ) {

				continue;

			}

			var currentValue = this.values[ this.lastIncrementedIndex ];
			// Constrain the application of the STEP to the current index. It's possible
			// that this index has already reached a local maximum and cannot be updated.
			var nextValue = this.constrain( currentValue + step );

			if ( nextValue !== currentValue ) {

				this.values[ this.lastIncrementedIndex ] = nextValue;
				deltaToDistribute--;

			}

		}

		return( this.values.slice() );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I constrain the given value to be within the min-max range.
	private constrain( value: number ) : number {

		value = Math.max( value, this.minValue );
		value = Math.min( value, this.maxValue );

		return( value );

	}


	// I sum the given collection of numbers.
	private sum( values: number[] ) : number {

		var total = values.reduce(
			( total, value ) => {

				return( total + value );

			}
		);

		return( total );

	}

}

As you can see, the Equalizer loops over the values using the lastIncrementedIndex variable to maintain a consistent round robin distribution. This is an instance variable of the Equalizer and is used across calls to the .setValue() method.

Now that we see how the values are linked together, let's look at the App component to see how they are consumed. This demo has three sliders for Good, Fast, and Cheap (the age-old problem). These there sliders / range inputs are kept in lock-step using a unidirectional data-flow that is ultimately powered by an instance of Equalizer:

// Import the core angular services.
import { Component } from "@angular/core";

// Import the application components and services.
import { Equalizer } from "./equalizer";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			Good, Fast, Cheap ... Pick 2
		</p>

		<div class="dimension">
			<app-slider
				[value]="goodValue"
				(valueChange)="handleGoodValue( $event )"
				[min]="minValue"
				[max]="maxValue"
				class="control">
			</app-slider>
			<div class="reading">
				<strong>Good</strong>: {{ goodValue }}
			</div>
		</div>

		<div class="dimension">
			<app-slider
				[value]="fastValue"
				(valueChange)="handleFastValue( $event )"
				[min]="minValue"
				[max]="maxValue"
				class="control">
			</app-slider>
			<div class="reading">
				<strong>Fast</strong>: {{ fastValue }}
			</div>
		</div>

		<div class="dimension">
			<app-slider
				[value]="cheapValue"
				(valueChange)="handleCheapValue( $event )"
				[min]="minValue"
				[max]="maxValue"
				class="control">
			</app-slider>
			<div class="reading">
				<strong>Cheap</strong>: {{ cheapValue }}
			</div>
		</div>
	`
})
export class AppComponent {

	public cheapValue: number;
	public fastValue: number;
	public goodValue: number;
	public minValue: number;
	public maxValue: number;

	private equalizer: Equalizer;

	// I initialize the app component.
	constructor() {

		this.cheapValue = 5;
		this.fastValue = 17;
		this.goodValue = 78;
		this.minValue = 0;
		this.maxValue = 100;

		// Linking multiple values together within a constraint is actually quite
		// challening. As such, we're going to off-load this responsibility to an
		// equalizer that is built to maintain a distributed sum.
		this.equalizer = new Equalizer(
			this.minValue,
			this.maxValue,
			[ this.goodValue, this.fastValue, this.cheapValue ]
		);

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I handle the change-request to the "cheap" value.
	public handleCheapValue( newValue: number ) : void {

		var results = this.equalizer.setValue( 2, newValue );
		this.goodValue = results[ 0 ];
		this.fastValue = results[ 1 ];
		this.cheapValue = results[ 2 ];

	}


	// I handle the change-request to the "fast" value.
	public handleFastValue( newValue: number ) : void {

		var results = this.equalizer.setValue( 1, newValue );
		this.goodValue = results[ 0 ];
		this.fastValue = results[ 1 ];
		this.cheapValue = results[ 2 ];
		
	}


	// I handle the change-request to the "good" value.
	public handleGoodValue( newValue: number ) : void {

		var results = this.equalizer.setValue( 0, newValue );
		this.goodValue = results[ 0 ];
		this.fastValue = results[ 1 ];
		this.cheapValue = results[ 2 ];

	}

}

As you can see, the App component doesn't really contain any logic in and of itself. It just listens for change-events, wires those into the Equalizer, and then applies the results back to the view-model. And, when we run this Angular app in the browser, we get the following output:

Good, fast, and cheap sliders are kept in lock-step using Angular 9.0.0-rc.5.

As you can see, the three different sliders are all kept in lock-step, summing to 100 at all times.

When I sat down to put this code-kata together, I had assumed the slider component would be the most interesting. But, I quickly realized the interesting part of the Equalizer. As such, I just implemented the most simple Slider I could. It assumes integers and only allows for 1-based steps. But, it gets the job done for now:

// Import the core angular services.
import { Component } from "@angular/core";
import { ElementRef } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { ViewChild } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "app-slider",
	inputs: [
		"value",
		"min",
		"max"
	],
	outputs: [
		"valueChangeEvents: valueChange"
	],
	host: {
		"[title]": "value"
	},
	queries: {
		"trackRef": new ViewChild( "trackRef" )
	},
	styleUrls: [ "./slider.component.less" ],
	template:
	`
		<div #trackRef class="track">
			<div class="value" [style.left.%]="( value / max * 100 )">
				<div (mousedown)="startDrag( $event )" class="thumb">
					<br />
				</div>
			</div>
		</div>
	`
})
export class SliderComponent {

	public max!: number;
	public min!: number;
	public trackRef!: ElementRef;
	public value!: number;
	public valueChangeEvents: EventEmitter<number>;

	// I initialize the slider component.
	constructor() {

		this.valueChangeEvents = new EventEmitter();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I start tracking the mouse movements on the slider in order to calculate the
	// desired change in value.
	public startDrag( event: MouseEvent ) : void {

		event.preventDefault();

		// In order to map the drag-event to changes in value, we need to look at the
		// physical size of the slider. This way, we know where, within the bounds of the
		// slider, the mouse is moving.
		var trackRect = this.trackRef.nativeElement.getBoundingClientRect();
		var minClientX = Math.floor( trackRect.left );
		var maxClientX = Math.floor( trackRect.right );
		var clientX = event.clientX;

		// On mouse-move, calculate and emit the next value.
		var handleMousemove = ( event: MouseEvent ) => {

			// Calculate the next horizontal position, constrained within the track.
			var nextClientX = Math.floor( event.clientX );
			nextClientX = Math.max( nextClientX, minClientX );
			nextClientX = Math.min( nextClientX, maxClientX );

			// Figure out how that mouse position translates into value.
			var percentClientX = (
				( nextClientX - minClientX ) /
				( maxClientX - minClientX )
			);
			// NOTE: For the purposes of this demo, I am assuming that all values are
			// integers. Allowing for floats would make this more challenging.
			var nextValue = Math.round( ( this.max - this.min ) * percentClientX );
			nextValue = Math.max( nextValue, this.min );
			nextValue = Math.min( nextValue, this.max );

			this.valueChangeEvents.emit( nextValue );

		};

		// On mouse-up, tear-down drag events.
		var handleMouseup = () => {

			window.removeEventListener( "mousemove", handleMousemove );
			window.removeEventListener( "mouseup", handleMouseup );

		};

		window.addEventListener( "mousemove", handleMousemove );
		window.addEventListener( "mouseup", handleMouseup );

	}

}

This slider implementation just looks at the mouse position, checks to see how that mouse position falls within the physical bounds of the Slider on screen, and then, translates that position into some percentage of the min-max range. This gets the job done; but, isn't particularly robust or flexible. Like I said, the real meat of this demo quickly became the Equalizer.

This was a really fun problem to work on in Angular 9.0.0-rc.5. When I sat down to start it, I thought the interesting bits would be in the Slider component; but, it quickly became apparent that the real "kata" here was in the implementation of the linked value-set. I am pleased with my implementation of the Equalizer; but, I can't help but wonder if there is a simple solution that I am just not seeing. Let me know if you have any suggestions.


Reader Comments

What has two thumbs and hopes you leave a comment? This Guy! (Ben Nadel).

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.