utils/ranged.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

import {
  LinearSnapModule,
  ArraySnapModule,
  NullSnapModule,
  TrivialSnapModule,
  makePiecewiseLinearTransformation,
  makeFunctionTransformation,
  makeLinearTransformation,
  makeLogarithmicTransformation,
  makeFrequencyTransformation,
} from './transformations.js';
import { defineRender } from '../renderer.js';
import { applyAttribute } from './dom.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,
  log_factor: 1,
  format_ariavalue: (v) => (isFinite(v) ? v.toFixed(2) : ''),
  set_ariavalue: false,
};

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',
  log_factor: 'number',
  transformation: 'object',
  snap_module: 'object',
  format_ariavalue: 'function',
  set_ariavalue: 'boolean',
};

function numSort(arr) {
  arr = arr.slice(0);
  arr.sort(function (a, b) {
    return a - b;
  });
  return arr;
}

export function makeSnapModule(snap, clip, min, max, base) {
  if (Array.isArray(snap)) {
    return ArraySnapModule(
      {
        min,
        max,
        clip,
      },
      new Float64Array(numSort(snap)).buffer
    );
  } else if (typeof snap === 'number' && snap > 0.0) {
    return LinearSnapModule({
      min: Math.min(min, max),
      max: Math.max(min, max),
      step: snap,
      base: base || 0,
      clip: clip,
    });
  } else if (clip && min < Infinity && max > -Infinity) {
    return NullSnapModule({
      min: Math.min(min, max),
      max: Math.max(min, max),
      clip,
    });
  } else {
    return TrivialSnapModule;
  }
}

export function makeTransformation(
  basis,
  log_factor,
  max,
  min,
  reverse,
  scale,
  options
) {
  if (typeof scale === 'function') {
    return makeFunctionTransformation(
      {
        basis,
        scale,
        reverse,
      },
      options
    );
  } 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.');
    }

    return makePiecewiseLinearTransformation(
      { basis, reverse },
      new Float64Array(scale).buffer
    );
  } else {
    switch (scale) {
      case 'linear':
        return makeLinearTransformation({ basis, max, min, reverse });
      case 'decibel':
        return makeLogarithmicTransformation({
          basis,
          log_factor,
          max,
          min,
          reverse,
          trafo_reverse: 1,
        });
      case 'log2':
        return makeLogarithmicTransformation({
          basis,
          log_factor,
          max,
          min,
          reverse,
          trafo_reverse: 0,
        });
      case 'frequency':
        return makeFrequencyTransformation({
          basis,
          max,
          min,
          reverse,
          trafo_reverse: 0,
        });
      case 'frequency-reverse':
        return makeFrequencyTransformation({
          basis,
          max,
          min,
          reverse,
          trafo_reverse: 1,
        });
      default:
        warn('Unsupported scale', scale);
    }
  }
}

/**
 * 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'.
 * @property {Function} [options.format_ariavalue=v => v.toFixed(2)] - Function to format the aria-valuenow attribute.
 * @property {Boolean} [options.set_ariavalue=false] - Define if aria-valuemin, aria-valuemax and aria-valuenow should be set.
 *
 */
export const rangedRenderers = [
  defineRender(
    ['value', 'aria_valuenow', 'format_ariavalue', 'set_ariavalue'],
    function (value, aria_valuenow, format_ariavalue, set_ariavalue) {
      if (aria_valuenow !== void 0) return;
      if (!set_ariavalue) return;

      const targets = this.getARIATargets();

      targets.forEach((element) => {
        applyAttribute(element, 'aria-valuenow', format_ariavalue(value));
      });
    }
  ),
  defineRender(
    ['min', 'aria_valuemin', 'format_ariavalue', 'set_ariavalue'],
    function (min, aria_valuemin, format_ariavalue, set_ariavalue) {
      if (aria_valuemin !== void 0) return;
      if (!set_ariavalue) return;

      const targets = this.getARIATargets();

      targets.forEach((element) => {
        applyAttribute(element, 'aria-valuemin', format_ariavalue(min));
      });
    }
  ),
  defineRender(
    ['max', 'aria_valuemax', 'format_ariavalue', 'set_ariavalue'],
    function (max, aria_valuemax, format_ariavalue, set_ariavalue) {
      if (aria_valuemax !== void 0) return;
      if (!set_ariavalue) return;

      const targets = this.getARIATargets();

      targets.forEach((element) => {
        applyAttribute(element, 'aria-valuemax', format_ariavalue(max));
      });
    }
  ),
];

function updateSnapModule() {
  const { snap, clip, min, max, base } = this.options;
  this.update('snap_module', makeSnapModule(snap, clip, min, max, base));
}

function updateTransformation() {
  const { basis, log_factor, max, min, reverse, scale } = this.options;

  this.update(
    'transformation',
    makeTransformation(
      basis,
      log_factor,
      max,
      min,
      reverse,
      scale,
      this.options
    )
  );
}

export const rangedEvents = {
  // changes both
  set_max: [updateSnapModule, updateTransformation],
  set_min: [updateSnapModule, updateTransformation],
  // changes snap_module
  set_base: updateSnapModule,
  set_clip: updateSnapModule,
  set_snap: updateSnapModule,
  // changes transformation
  set_basis: updateTransformation,
  set_log_factor: updateTransformation,
  set_reverse: updateTransformation,
  set_scale: updateTransformation,

  initialized: [updateSnapModule, updateTransformation],
};