UNPKG

guify

Version:

A simple GUI for inspecting and changing JS variables

370 lines (297 loc) 13.7 kB
import ComponentBase from "../component-base.js"; import css from "dom-css"; import isnumeric from "is-numeric"; import "./interval.css"; import { default as LabelPartial } from "../partials/label"; import { default as ValuePartial } from "../partials/value"; import { lerp } from "../../utils/math-utils"; function clamp(x, min, max) { return Math.min(Math.max(x, min), max); } export default class Interval extends ComponentBase { constructor (root, opts, theme) { super(root, opts, theme); this.label = LabelPartial(this.container, opts.label, theme); if (!!opts.step && !!opts.steps) { throw new Error("Cannot specify both step and steps. Got step = " + opts.step + ", steps = ", opts.steps); } this.input = this.container.appendChild(document.createElement("span")); this.input.classList.add("guify-interval"); this.handle = document.createElement("span"); this.handle.classList.add("guify-interval-handle"); this.input.appendChild(this.handle); if (!Array.isArray(opts.initial)) { opts.initial = []; } this.scale = opts.scale; // Get initial value: if( opts.scale === "log" ) { // If logarithmic, we're going to set the slider to a known linear range. Then we'll // map that linear range to the user-set range using a log scale. // Check if all signs are valid if (opts.min * opts.max <= 0) { throw new Error("Log range min/max must have the same sign and not equal zero. Got min = " + opts.min + ", max = " + opts.max); } // Step is invalid for log scale slider if (isnumeric(opts.step)) { console.warn("Step is unused for log scale sliders."); } // Warn that `steps` was removed if (isnumeric(opts.steps)) { console.warn("\"steps\" option for log scale sliders has been removed."); } // Min/max are forced to a known range, and log value will be derived from slider position within. this.minPos = 0; this.maxPos = 1000000; this.min = Math.log( (isnumeric(opts.min)) ? opts.min : 0.000001 ); // This cannot be 0 this.max = Math.log( (isnumeric(opts.max)) ? opts.max : 100 ); this.precision = (isnumeric(opts.precision)) ? opts.precision : 3; this.logScale = (this.max - this.min) / (this.maxPos - this.minPos); this.initial = [ isnumeric(opts.initial[0]) ? opts.initial[0] : this.min, isnumeric(opts.initial[1]) ? opts.initial[1] : this.max, ]; } else { // If linear, this is much simpler. Pos and value can directly match. this.minPos = (isnumeric(opts.min)) ? opts.min : 0; this.maxPos = (isnumeric(opts.max)) ? opts.max : 100; this.min = this.minPos; this.max = this.maxPos; this.precision = (isnumeric(opts.precision)) ? opts.precision : 3; this.step = (isnumeric(opts.step)) ? opts.step : (10 / Math.pow(10, 3)); // Default is the lowest possible number given the precision. When precision = 3, step = 0.01. this.initial = [ isnumeric(opts.initial[0]) ? opts.initial[0] : this.min, isnumeric(opts.initial[1]) ? opts.initial[1] : this.max, ]; // Quantize the initial value to the nearest step: if (this.step != 0) { this.initial = this.initial.map((value) => { return this.min + this.step * Math.round((value - this.min) / this.step); }); } } this.value = opts.initial; // Set handle positions from value this._RefreshHandles(); // Display the values: this.lValue = ValuePartial(this.container, this.value[0], theme, "11%", true); this.rValue = ValuePartial(this.container, this.value[1], theme, "11%", false); // Add ARIA attribute to input based on label text if(opts.label) this.lValue.setAttribute("aria-label", opts.label + " lower value"); if(opts.label) this.lValue.setAttribute("aria-label", opts.label + " upper value"); // An index to track what's being dragged: this.activeIndex = -1; setTimeout(() => { this.emit("initialized", this.value); }); // // Gain focus // this.input.addEventListener("focus", () => { // this.focused = true; // }); // // Lose focus // this.input.addEventListener("blur", () => { // this.focused = false; // }); let mouseX = (ev) => { // Get mouse position in page coords relative to the container: return ev.pageX - this.input.getBoundingClientRect().left; }; let mouseMoveListener = ( ev ) => { let fraction = clamp(mouseX(ev) / this.input.offsetWidth, 0, 1); this._SetFromMousePosition(fraction); }; let mouseUpListener = ( ev ) => { let fraction = clamp(mouseX(ev) / this.input.offsetWidth, 0, 1); this._SetFromMousePosition(fraction); document.removeEventListener("mousemove", mouseMoveListener); document.removeEventListener("mouseup", mouseUpListener); this.activeIndex = -1; }; this.input.addEventListener("mousedown", (ev) => { // Get mouse position fraction: let fraction = clamp(mouseX(ev) / this.input.offsetWidth, 0, 1); let posForLeftValue = this._Position(this.value[0]); let posForRightValue = this._Position(this.value[1]); // Get the current fraction of position --> [0, 1]: let lofrac = (posForLeftValue - this.minPos) / (this.maxPos - this.minPos); let hifrac = (posForRightValue - this.minPos) / (this.maxPos - this.minPos); // This is just for making decisions, so perturb it ever // so slightly just in case the bounds are numerically equal: lofrac -= Math.abs(this.maxPos - this.minPos) * 1e-15; hifrac += Math.abs(this.maxPos - this.minPos) * 1e-15; // Figure out which is closer: let lodiff = Math.abs(lofrac - fraction); let hidiff = Math.abs(hifrac - fraction); this.activeIndex = lodiff < hidiff ? 0 : 1; console.log(this.activeIndex); // Attach this to *document* so that we can still drag if the mouse // passes outside the container: document.addEventListener("mousemove", mouseMoveListener); document.addEventListener("mouseup", mouseUpListener); }); // Defocus on mouse up (for non-accessibility users) this.input.addEventListener("mouseup", () => { this.input.blur(); }); this.input.oninput = () => { // let position = parseFloat(data.target.value); // var scaledValue = this._Value(position); // this.valueComponent.value = this._RoundNumber(scaledValue, this.precision); this.lValue.value = this.value[0]; this.rValue.value = this.value[1]; this.emit("input", this.value); }; // Handle lower bound input box changes this.lValue.onchange = () => { let rawValue = this.lValue.value; let otherValue = parseFloat(this.rValue.value); if (Number(parseFloat(rawValue)) == rawValue) { let min = (this.scale == "log") ? Math.exp(this.min) : this.min; let max = (this.scale == "log") ? Math.exp(this.max) : this.max; // Input number is valid var value = parseFloat(rawValue); // Clamp to input range value = Math.min(Math.max(value, min), max); // Map to nearest step if (this.step) { value = Math.ceil((value - min) / this.step ) * this.step + min; } // Prevent value from going beyond interval upper value value = Math.min(value, otherValue); value = this._RoundNumber(value, this.precision); this.lValue.value = value; this.value = [value, otherValue]; this.emit("input", [value, otherValue]); this._RefreshHandles([value, otherValue]); } else { // Input number is invalid // Go back to before input change this.lValue.value = this.lastValue[0]; } }; // Handle upper bound input box changes this.rValue.onchange = () => { let rawValue = this.rValue.value; let otherValue = parseFloat(this.lValue.value); if (Number(parseFloat(rawValue)) == rawValue) { let min = (this.scale == "log") ? Math.exp(this.min) : this.min; let max = (this.scale == "log") ? Math.exp(this.max) : this.max; // Input number is valid var value = parseFloat(rawValue); // Clamp to input range value = Math.min(Math.max(value, min), max); // Map to nearest step if (this.step) { value = Math.ceil((value - min) / this.step ) * this.step + min; } // Prevent value from going below interval lower value value = Math.max(value, otherValue); value = this._RoundNumber(value, this.precision); this.rValue.value = value; this.value = [otherValue, value]; this.emit("input", [otherValue, value]); this._RefreshHandles(); } else { // Input number is invalid // Go back to before input change this.rValue.value = this.lastValue[1]; } }; } /** * Calculate value from slider position */ _Value(position) { if (this.scale === "log") { // Map from slider position range to log value range // Map from slider range to min-max value range let rangePos = (position - this.minPos) * this.logScale + this.min; // Now convert to log space return Math.exp(rangePos); } else { // Position and value are equivalent return position; } } /** * Calculate slider position from value */ _Position(value) { if (this.scale === "log") { // Map from log value range to the slider's position range return this.minPos + (Math.log(value) - this.min) / this.logScale; } else { // Value and position are equivalent return value; } } /** * Updates the current value given a mouse X position normalized from 0 to 1. */ _SetFromMousePosition( fraction ) { if (this.activeIndex === -1) { return; } // Clip against the other bound: if (this.activeIndex === 0) { // Get the right side in position-space [0, 1]: let hifrac = (this._Position(this.value[1]) - this.minPos) / (this.maxPos - this.minPos); // Prevent fraction from exceeding right-side position fraction = Math.min(hifrac, fraction); } else { // Get the right side in position-space [0, 1]: let lofrac = (this._Position(this.value[0]) - this.minPos) / (this.maxPos - this.minPos); // Prevent fraction from going below left-side position fraction = Math.max(lofrac, fraction); } // Map from 0-1 scale to position-scale let position = lerp(this.minPos, this.maxPos, fraction); // Map from position-scale to value-scale and assign to values var newValue = this._Value(position); // Quantize the value if (this.step) { newValue = this.min + this.step * Math.round((newValue - this.min) / this.step); } this.value[this.activeIndex] = this._RoundNumber(newValue, this.precision); // Update and send the event: this._RefreshHandles(); this.input.oninput(); } SetValue( value ) { if(this.focused !== true) { this.lValue.value = this._RoundNumber(parseFloat(value[0]), this.precision); this.rValue.value = this._RoundNumber(parseFloat(value[1]), this.precision); this.lastValue = [ parseFloat(value[0]), parseFloat(value[1]) ]; } } // Formats the number for display. // `opts.precision` lets you customize how many decimal places you want here at most. // The default is 3. _RoundNumber(value, precision) { // https://stackoverflow.com/a/12830454/7138792 return +parseFloat(value).toFixed(precision); } GetValue() { return [ this.lValue.value, this.rValue.value ]; } _RefreshHandles() { let leftPercent = ((this._Position(this.value[0]) - this.minPos) / (this.maxPos - this.minPos)) * 100; let rightPercent = 100 - (((this._Position(this.value[1]) - this.minPos) / (this.maxPos - this.minPos)) * 100); css(this.handle, { left: `${leftPercent}%`, right: `${rightPercent}%`, }); } }