utils/make_ranged.js

import {
  LinearSnapModule,
  ArraySnapModule,
  NullSnapModule,
  TrivialSnapModule,
  makePiecewiseLinearTransformation,
  makeFunctionTransformation,
  makeLinearTransformation,
  makeLogarithmicTransformation,
  makeFrequencyTransformation,
} from './transformations.js';

import { error, warn } from './log.js';

export const rangedOptionsDefaults = {
  scale: 'linear',
  reverse: false,
  basis: 1,
  clip: true,
  min: 0,
  max: 1,
  base: 0,
  step: 0,
  shift_up: 4,
  shift_down: 0.25,
  snap: 0,
  round: true /* default for Range, no dedicated option */,
  log_factor: 1,
  trafo_reverse: false /* used internally, no documentation */,
  transformation: null,
  snap_module: TrivialSnapModule,
};

export const rangedOptionsTypes = {
  scale: 'string|array|function',
  reverse: 'boolean',
  basis: 'number',
  clip: 'boolean',
  min: 'number',
  max: 'number',
  base: 'number',
  step: 'number',
  shift_up: 'number',
  shift_down: 'number',
  snap: 'number|array',
  round: 'boolean',
  log_factor: 'number',
  trafo_reverse: 'boolean',
  transformation: 'object',
  snap_module: 'object',
};

function numSort(arr) {
  arr = arr.slice(0);
  arr.sort(function (a, b) {
    return a - b;
  });
  return arr;
}
function updateSnap() {
  const O = this.options;
  let snap_module;
  // Notify that the ranged options have been modified
  if (Array.isArray(O.snap)) {
    snap_module = ArraySnapModule(O, new Float64Array(numSort(O.snap)).buffer);
  } else if (typeof O.snap === 'number' && O.snap > 0.0) {
    snap_module = LinearSnapModule({
      min: Math.min(O.min, O.max),
      max: Math.max(O.min, O.max),
      step: O.snap,
      base: O.base || 0,
      clip: O.clip,
    });
  } else if (O.clip && O.min < Infinity && O.max > -Infinity) {
    snap_module = NullSnapModule({
      min: Math.min(O.min, O.max),
      max: Math.max(O.min, O.max),
      clip: O.clip,
    });
  } else {
    snap_module = TrivialSnapModule;
  }

  this.update('snap_module', snap_module);
}

function updateTransformation() {
  const O = this.options;
  const scale = O.scale;

  let module;

  if (typeof scale === 'function') {
    module = makeFunctionTransformation(O);
  } else if (Array.isArray(scale)) {
    let i = 0;
    if (scale.length % 2) {
      error('Malformed piecewise-linear scale.');
    }

    for (i = 0; i < scale.length / 2 - 1; i++) {
      if (!(scale[i] >= 0 && scale[i] <= 1))
        error('piecewise-linear x value not in [0,1].');
      if (!(scale[i] < scale[i + 1]))
        error('piecewise-linear array not sorted.');
    }
    for (i = scale.length / 2; i < scale.length - 1; i++) {
      if (!(scale[i] < scale[i + 1]))
        error('piecewise-linear array not sorted.');
    }

    module = makePiecewiseLinearTransformation(
      O,
      new Float64Array(scale).buffer
    );
  } else {
    switch (scale) {
      case 'linear':
        module = makeLinearTransformation(O);
        break;
      case 'decibel':
        O.trafo_reverse = 1;
        module = makeLogarithmicTransformation(O);
        break;
      case 'log2':
        O.trafo_reverse = 0;
        module = makeLogarithmicTransformation(O);
        break;
      case 'frequency':
        O.trafo_reverse = 0;
        module = makeFrequencyTransformation(O);
        break;
      case 'frequency-reverse':
        O.trafo_reverse = 1;
        module = makeFrequencyTransformation(O);
        break;
      default:
        warn('Unsupported scale', scale);
    }
  }

  this.update('transformation', module);
}
function setCallback(key) {
  switch (key) {
    case 'min':
    case 'max':
    case 'snap':
    case 'clip':
      updateSnap.call(this);
    /* fall through */
    case 'log_factor':
    case 'scale':
    case 'reverse':
    case 'basis':
      updateTransformation.call(this);
      this.emit('rangedchanged');
      break;
  }
}

function initializedCallback() {
  const O = this.options;
  if (!(O.min <= O.max))
    warn(
      'Ranged needs min <= max. min: ',
      O.min,
      ', max:',
      O.max,
      ', options:',
      O
    );
  updateSnap.call(this);
  updateTransformation.call(this);
}

/**
 * Making a widget ranged combines functionality for two distinct purposes.
 * Firstly, Ranged can be used to snap values to a virtual grid.
 * This grid is defined by the options <code>snap</code>,
 * <code>step</code>, <code>min</code>, <code>max</code> and <code>base</code>.
 * The second feature of anged is that it allows transforming values between coordinate systems.
 * This can be used to transform values from and to linear scales in which they are displayed on the
 * screen. It is used inside of AUX to translate values (e.g. in Hz or dB) to pixel positions or
 * percentages, for instance in widgets such as {@link Scale}, {@link Meter} or
 * {@link Graph}.
 *
 * Ranged features several types of coordinate systems which are often used in audio applications.
 * They can be configured using the <code>options.scale</code> option, possible values are:
 * <ul>
 *  <li><code>linear</code> for linear coordinates,
 *  <li><code>decibel</code> for linear coordinates,
 *  <li><code>log2</code> for linear coordinates,
 *  <li><code>frequency</code> for linear coordinates or
 *  <li><code>frequency-reverse"</code> for linear coordinates.
 * </ul>
 * If <code>options.scale</code> is a function, it is used as the coordinate transformation.
 * Its signature is {@link Ranged~scale_cb}. This allows the definition of custom
 * coordinate transformations, which go beyond the standard types.
 *
 * @param {Widget} The widget to make ranged.
 *
 * @property {String|Array<Number>|Function} [options.scale="linear"] -
 *  The type of the scale. Either one of <code>linear</code>, <code>decibel</code>, <code>log2</code>,
 *  <code>frequency</code> or <code>frequency-reverse</code>; or an array containing a
 *  piece-wise linear scale;
 *  or a callback function of type {@link Ranged~scale_cb}.
 * @property {Boolean} [options.reverse=false] - Reverse the scale of the range.
 * @property {Number} [options.basis=1] - The size of the linear scale. Set to pixel width or height
 * if used for drawing purposes or to 100 for percentages.
 * @property {Boolean} [options.clip=true] - If true, snap() will clip values
 *  into the interval between min and max.
 * @property {Number} [options.min=0] - Minimum value of the range.
 * @property {Number} [options.max=1] - Maximum value of the range.
 * @property {Number} [options.log_factor=1] - Used to overexpand logarithmic curves. 1 keeps the
 *  natural curve while values above 1 will overbend.
 * @property {Number|Array.<number>} [options.snap=0] -
 *  Defines a virtual grid.
 *  If <code>options.snap</code> is a positive number, it is interpreted as the distance of
 *  grid points.
 *  Then, inside of the interval <code>options.min</code> ... <code>options.max</code> the grid
 *  points are <code> options.base + n * options.snap </code> where <code>n</code> is any
 *  integer. Any values outside of that interval are rounded to the biggest or smallest grid
 *  point, respectively.
 *  In order to define grids with non-uniform spacing, set <code>options.snap</code> to an Array
 *  of grid points.
 * @property {Number} [options.base=0] - Base point. Used e.g. to mark 0dB on a fader from -96dB to 12dB.
 * @property {Number} [options.step=0] - Step size. Used for instance by {@link ScrollValue}
 *  as the step size.
 * @property {Number} [options.shift_up=4] - Multiplier for increased stepping speed, e.g. used by
 *  {@link ScrollValue} when simultaneously pressing 'shift'.
 * @property {Number} [options.shift_down=0.25] - Multiplier for decreased stepping speed, e.g. used by
 *  {@link ScrollValue} when simultaneously pressing 'shift' and 'ctrl'.
 *
 * @function makeRanged
 */
export function makeRanged(widget) {
  widget.addStaticEvent('set', setCallback);
  widget.addStaticEvent('initialized', initializedCallback);
}