widgets/meter.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 { mergeStaticEvents } from '../widget_helpers.js';
import { defineChildWidget } from '../child_widget.js';
import { Widget } from './widget.js';
import { Label } from './label.js';
import {
  rangedEvents,
  rangedOptionsDefaults,
  rangedOptionsTypes,
  rangedRenderers,
} from '../utils/ranged.js';
import { Scale } from './scale.js';
import {
  element,
  addClass,
  toggleClass,
  removeClass,
  insertAfter,
  innerWidth,
  innerHeight,
  createID,
  applyAttribute,
} from '../utils/dom.js';
import { defineRender, defineMeasure } from '../renderer.js';

import { FORMAT } from '../utils/sprintf.js';

function vert(layout) {
  return layout === 'left' || layout === 'right';
}
function fillInterval(ctx, w, h, a, is_vertical) {
  let i;
  if (is_vertical) {
    for (i = 0; i < a.length; i += 2) {
      ctx.fillRect(0, h - a[i + 1], w, a[i + 1] - a[i]);
    }
  } else {
    for (i = 0; i < a.length; i += 2) {
      ctx.fillRect(a[i], 0, a[i + 1] - a[i], h);
    }
  }
}
function clearInterval(ctx, w, h, a, is_vertical) {
  let i;
  if (is_vertical) {
    for (i = 0; i < a.length; i += 2) {
      ctx.clearRect(0, h - a[i + 1], w, a[i + 1] - a[i]);
    }
  } else {
    for (i = 0; i < a.length; i += 2) {
      ctx.clearRect(a[i], 0, a[i + 1] - a[i], h);
    }
  }
}
function drawFull(ctx, w, h, a, is_vertical) {
  ctx.fillRect(0, 0, w, h);
  clearInterval(ctx, w, h, a, is_vertical);
}
function makeInterval(a) {
  let i, tmp, again;

  do {
    again = false;
    for (i = 0; i < a.length - 2; i += 2) {
      if (a[i] > a[i + 2]) {
        tmp = a[i];
        a[i] = a[i + 2];
        a[i + 2] = tmp;

        tmp = a[i + 1];
        a[i + 1] = a[i + 3];
        a[i + 3] = tmp;
        again = true;
      }
    }
  } while (again);

  for (i = 0; i < a.length - 2; i += 2) {
    // the first interval overlaps with the next, we merge them
    if (a[i + 1] > a[i + 2]) {
      if (a[i + 3] > a[i + 1]) {
        a[i + 1] = a[i + 3];
      }
      a.splice(i + 2, 2);
      continue;
    }
  }
}
function cmpIntervals(a, b) {
  let ret = 0;
  let i;

  for (i = 0; i < a.length; i += 2) {
    if (a[i] === b[i]) {
      if (a[i + 1] === b[i + 1]) continue;
      ret |= a[i + 1] < b[i + 1] ? 1 : 2;
    } else if (a[i + 1] === b[i + 1]) {
      ret |= a[i] > b[i] ? 1 : 2;
    } else return 4;
  }
  return ret;
}
function subtractIntervals(a, b) {
  let i;
  const ret = [];

  for (i = 0; i < a.length; i += 2) {
    if (a[i] === b[i]) {
      if (a[i + 1] <= b[i + 1]) continue;
      ret.push(b[i + 1], a[i + 1]);
    } else {
      if (a[i] > b[i]) continue;
      ret.push(a[i], b[i]);
    }
  }

  return ret;
}

function fromGradientObject(gradient) {
  const entries = [];

  for (const entry in gradient) {
    const value = parseFloat(entry);
    const color = gradient[entry];

    entries.push({ value, color });
  }

  return entries;
}

function drawGradient(element, O) {
  const {
    gradient,
    background,
    _width,
    _height,
    reverse,
    transformation,
    snap_module,
    layout,
    base,
    segment,
  } = O;

  if (!gradient) {
    const ctx = element.getContext('2d');
    ctx.fillStyle = background;
    ctx.fillRect(0, 0, _width, _height);
  } else if (typeof gradient === 'function') {
    gradient.call(this, element.getContext('2d'), O, element, _width, _height);
  } else if (typeof gradient === 'object') {
    const basePx = transformation.valueToPixel(base);
    const vert = layout === 'left' || layout === 'right';

    let entries = (Array.isArray(gradient)
      ? gradient
      : fromGradientObject(gradient)
    ).map(({ color, value }) => {
      if (isNaN(value) || !isFinite(value))
        throw new TypeError(`Malformed gradient entry '${entry}'.`);

      let coef;

      if (segment > 1) {
        const valuePx = transformation.valueToPixel(snap_module.snap(value));
        const segmentPx = Math.round(
          basePx + segment * Math.round((valuePx - basePx) / segment)
        );
        coef = transformation.valueToCoef(
          transformation.pixelToValue(segmentPx)
        );
      } else {
        coef = transformation.valueToCoef(snap_module.snap(value));
      }

      if (!(coef >= 0)) coef = 0;
      else if (!(coef <= 1)) coef = 1;

      return {
        value,
        color,
        coef: vert ? 1 - coef : coef,
      };
    });

    entries.sort(function (a, b) {
      return a.value - b.value;
    });

    const length = entries.length;

    if (length > 1 && entries[0].coef > entries[length - 1].coef)
      entries = entries.reverse();

    const ctx = element.getContext('2d');
    const grd = ctx.createLinearGradient(
      0,
      0,
      vert ? 0 : _width || 0,
      vert ? _height || 0 : 0
    );

    // Add all colors starting from the lowest coefficient
    entries.forEach(({ coef, color }) => {
      grd.addColorStop(coef, color);
    });

    ctx.fillStyle = grd;
    ctx.fillRect(0, 0, _width, _height);
  } else {
    throw new TypeError('Unexpected gradient type.');
  }
}

/**
 * Meter is a base class to build different meters from, such as {@link LevelMeter}.
 * Meter contains a {@link Scale} widget and inherits all its options.
 *
 * Note that level meters with high update frequencies can be very demanding when it comes
 * to rendering performance. These performance requirements can be reduced by increasing the
 * segment size using the <code>segment</code> option. Using a segment, the different level
 * meter positions are reduced. This widget will take advantage of that by avoiding rendering those
 * changes to the meter level, which fall into the same segment.
 *
 * The meter is drawn as a mask above a background. The mask represents the
 * inactive part of the meter. This mask is drawn into a canvas. The
 * fillstyle of this mask is initialized from the `background-color` style
 * of the canvas element with class `aux-mask`. Note that using a `background-color`
 * value with opacity will lead to rendering artifacts in the meter. Instead, set
 * the `opacity` of the mask to the desired value.
 *
 * @class Meter
 *
 * @extends Widget
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {String} [options.layout="left"] - A string describing the layout of the meter.
 *   Possible values are <code>"left"</code>, <code>"right"</code>, <code>"top"</code> and
 *   <code>"bottom"</code>. <code>"left"</code> and <code>"right"</code> are vertical
 *   layouts, where the meter is on the left or right of the scale, respectively. Similarly,
 *   <code>"top"</code> and <code>"bottom"</code> are horizontal layouts in which the meter
 *   is at the top or the bottom, respectively.
 * @property {Integer} [options.segment=1] - Segment size. Pixel positions of the meter level are
 *   rounded to multiples of this size. This can be used to give the level meter a LED effect and to
 *   reduce processor load.
 * @property {Number} [options.value=0] - Level value.
 * @property {Number} [options.base=false] - The base value of the meter. If set to <code>false</code>,
 *   the base will coincide with the minimum value <code>options.min</code>. The meter level is drawn
 *   starting from the base to the value.
 * @property {Number} [options.value_label=0] - Value to be displayed on the label.
 * @property {Function} [options.format_value=FORMAT("%.2f")] - Function for formatting the
 *   label.
 * @property {Boolean} [options.show_label=false] - If set to <code>true</code> a label is displayed.
 * @property {Number} [options.label=false] - The title of the Meter. Set to `false` to hide it.
 * @property {Boolean} [options.show_scale=true] - Set to <code>false</code> to hide the scale.
 * @property {Number} [options.value_label=0] - The value to be drawn in the value label.
 * @property {Boolean} [options.sync_value=true] - Synchronize the value on the bar with
 *   the value label using `options.format_value` function.
 * @property {String|Boolean} [options.foreground] - Color to draw the overlay. Has to be set
 *   via option for performance reasons. Use pure opaque color. If opacity is needed, set via CSS
 *   on `.aux-meter > .aux-bar > .aux-mask`.
 * @property {Object[]|Object|Boolean|Function} [options.gradient=false] - The color gradient of the meter.
 *   Set to `false` to use the `background` option. Alternatively provide a callback to draw on
 *   the canvas manually. It receives the canvas's context, the widgets options, the canvas element
 *   and its width and height. Alternatively, provide an object with numeric values as key and the
 *   color as value, or an array of objects with 'value' and 'color' entry.
 * @property {String|Boolean} [options.background] - Background color to be used if no gradient is set.
 */

export class Meter extends Widget {
  static get _options() {
    return [
      Scale.getOptionTypes(),
      {
        layout: 'string',
        segment: 'number',
        value: 'number',
        value_label: 'number',
        base: 'number|boolean',
        label: 'string|boolean',
        sync_value: 'boolean',
        format_value: 'function',
        background: 'string|boolean',
        gradient: 'object|boolean|function',
        foreground: 'string|boolean',
      },
    ];
  }

  static get options() {
    return [
      rangedOptionsDefaults,
      {
        layout: 'left',
        segment: 1,
        value: 0,
        value_label: 0,
        base: false,
        label: false,
        sync_value: true,
        format_value: FORMAT('%.2f'),
        levels: [1, 5, 10], // array of steps where to draw labels
        background: false,
        gradient: false,
        foreground: 'black',
        role: 'meter',
        set_ariavalue: true,
        aria_live: 'off',
      },
    ];
  }

  static get static_events() {
    return mergeStaticEvents(rangedEvents, {
      set_base: function (value) {
        if (value === false) {
          const O = this.options;
          O.base = value = O.min;
        }
      },
      set_value: function (val) {
        if (this.options.sync_value) this.set('value_label', val);
      },
      set_value_label: function (val) {
        if (this.value) this.value.set('label', this.options.format_value(val));
      },
      set_layout: function () {
        const O = this.options;
        this.set('value', O.value);
        this.set('min', O.min);
        this.triggerResize();
      },
      initialized: function () {
        this.set('value', this.get('value'));
        this.set('base', this.get('base'));
      },
    });
  }

  static get renderers() {
    return [
      ...rangedRenderers,
      defineRender('reverse', function (reverse) {
        toggleClass(this.element, 'aux-reverse', reverse);
      }),
      defineRender('layout', function (layout) {
        const E = this.element;
        const scale = this.scale ? this.scale.element : null;
        const bar = this._bar;
        removeClass(
          E,
          'aux-vertical',
          'aux-horizontal',
          'aux-left',
          'aux-right',
          'aux-top',
          'aux-bottom'
        );
        switch (layout) {
          case 'left':
            addClass(E, 'aux-vertical', 'aux-left');
            if (scale) insertAfter(scale, bar);
            break;
          case 'right':
            addClass(E, 'aux-vertical', 'aux-right');
            if (scale) insertAfter(bar, scale);
            break;
          case 'top':
            addClass(E, 'aux-horizontal', 'aux-top');
            if (scale) insertAfter(scale, bar);
            break;
          case 'bottom':
            addClass(E, 'aux-horizontal', 'aux-bottom');
            if (scale) insertAfter(bar, scale);
            break;
          default:
            throw new Error('unsupported layout');
        }
      }),
      defineRender(['_width', '_height'], function (_width, _height) {
        const { _canvas } = this;

        if (!(_height > 0 && _width > 0)) return;

        _canvas.setAttribute('height', Math.round(_height));
        _canvas.setAttribute('width', Math.round(_width));
        /* FIXME: I am not sure why this is even necessary */
        _canvas.style.width = _width + 'px';
        _canvas.style.height = _height + 'px';

        this.invalidate('foreground');
      }),
      defineRender(
        [
          'gradient',
          'background',
          '_width',
          '_height',
          'reverse',
          'transformation',
          'snap_module',
          'layout',
        ],
        function (
          gradient,
          background,
          _width,
          _height,
          reverse,
          transformation,
          snap_module,
          layout
        ) {
          if (!(_height > 0 && _width > 0)) return;
          const { _backdrop } = this;
          _backdrop.setAttribute('height', Math.round(_height));
          _backdrop.setAttribute('width', Math.round(_width));
          /* FIXME: I am not sure why this is even necessary */
          _backdrop.style.width = _width + 'px';
          _backdrop.style.height = _height + 'px';
          drawGradient(_backdrop, this.options);
        }
      ),
      defineMeasure(['_width', '_height', 'layout'], function (
        _width,
        _height,
        layout
      ) {
        this.set('basis', vert(layout) ? _height : _width);
        this._last_meters.length = 0;
      }),
      defineRender(
        [
          'value',
          'transformation',
          'segment',
          'foreground',
          '_width',
          '_height',
        ],
        function (value, transformation) {
          return this.drawMeter();
        }
      ),
      defineRender(['label', 'aria_labelledby'], function (
        label,
        aria_labelledby
      ) {
        if (aria_labelledby !== void 0) return;

        const value = label !== false ? this.label.get('id') : null;
        applyAttribute(this.element, 'aria-labelledby', value);
      }),
    ];
  }

  initialize(options) {
    if (!options.element) options.element = element('div');
    super.initialize(options);
    const O = this.options;
    /**
     * @member {HTMLDivElement} Meter#element - The main DIV container.
     *   Has class <code>.aux-meter</code>.
     */
    this._bar = element('div', 'aux-bar');
    /**
     * @member {HTMLCanvas} Meter#_backdrop - The canvas element drawing the background.
     *   Has class <code>.aux-backdrop</code>.
     */
    this._backdrop = document.createElement('canvas');
    addClass(this._backdrop, 'aux-backdrop');
    /**
     * @member {HTMLCanvas} Meter#_canvas - The canvas element drawing the mask.
     *   Has class <code>.aux-mask</code>.
     */
    this._canvas = document.createElement('canvas');
    addClass(this._canvas, 'aux-mask');

    this._bar.appendChild(this._backdrop);
    this._bar.appendChild(this._canvas);

    /**
     * @member {HTMLDivElement} Meter#_bar - The DIV element containing the masks
     *      and drawing the background. Has class <code>.aux-bar</code>.
     */
    this._last_meters = [];
    this._current_meters = [];

    this.set('label', O.label);
    this.set('base', O.base);
  }

  destroy() {
    this._bar.remove();
    super.destroy();
  }

  draw(O, element) {
    addClass(element, 'aux-meter');
    element.appendChild(this._bar);
    this.set('aria_live', O.aria_live);

    super.draw(O, element);
  }

  getResizeTargets() {
    return [this._bar];
  }

  resize() {
    super.resize();

    const _bar = this._bar;

    const w = innerWidth(_bar, undefined, true);
    const h = innerHeight(_bar, undefined, true);

    this.set('_width', w);
    this.set('_height', h);
  }

  calculateMeter(to, value, i) {
    const O = this.options;
    // Set the mask elements according to options.value to show a value in
    // the meter bar
    const base = O.base;
    const segment = O.segment | 0;
    const transformation = O.transformation;

    /* At this point the whole meter bar is filled. We now want
     * to clear the area between base and value.
     */

    /* canvas coordinates are reversed */
    const v1 = transformation.valueToPixel(base) | 0;
    let v2 = transformation.valueToPixel(value) | 0;

    if (segment !== 1) {
      v2 = (v1 + segment * Math.round((v2 - v1) / segment)) | 0;
    }

    if (v2 < v1) {
      to[i++] = v2;
      to[i++] = v1;
    } else {
      to[i++] = v1;
      to[i++] = v2;
    }

    return i;
  }

  drawMeter() {
    const O = this.options;
    const w = Math.round(O._width);
    const h = Math.round(O._height);

    if (!(w > 0 && h > 0)) return;

    const a = this._current_meters;
    const tmp = this._last_meters;

    const i = this.calculateMeter(a, O.value, 0);
    if (i < a.length) a.length = i;
    makeInterval(a);

    this._last_meters = a;
    this._current_meters = tmp;

    let diff;

    if (tmp.length === a.length) {
      diff = cmpIntervals(tmp, a) | 0;
    } else diff = 4;

    if (!diff) return;

    // FIXME:
    //  - diff === 1 is currently broken for some reason
    // Note: Safari has a rendering bug, it leads to rendering artifacts
    // in certain situations. It is unclear what triggers this issue, simply
    // drawing the full meter is a valid workaround for the issue.
    if (diff === 1 || 'safari' in window) diff = 4;

    const ctx = this._canvas.getContext('2d');

    if (ctx.fillStyle !== O.foreground) {
      ctx.fillStyle = O.foreground;
      diff = 4;
    }

    const is_vertical = vert(O.layout);

    if (diff === 1) {
      /* a - tmp is non-empty */
      clearInterval(ctx, w, h, subtractIntervals(a, tmp), is_vertical);
      return;
    }
    if (diff === 2) {
      /* tmp - a is non-empty */
      fillInterval(ctx, w, h, subtractIntervals(tmp, a), is_vertical);
      return;
    }

    drawFull(ctx, w, h, a, is_vertical);
  }

  hasBase() {
    const O = this.options;
    return O.base > O.min;
  }
}
/**
 * @member {Scale} Meter#scale - The {@link Scale} of the meter.
 */
defineChildWidget(Meter, 'scale', {
  create: Scale,
  default_options: {
    labels: FORMAT('%.2f'),
  },
  inherit_options: true,
  show: true,
  toggle_class: true,
  static_events: {
    set: function (key, value) {
      const p = this.parent;
      if (p) p.emit('scalechanged', key, value);
    },
  },
});
/**
 * @member {Label} Meter#label - The {@link Label} displaying the title.
 *   Has class <code>.aux-label</code>.
 */
defineChildWidget(Meter, 'label', {
  create: Label,
  show: false,
  option: 'label',
  map_options: { label: 'label' },
  toggle_class: true,
  static_events: {
    initialized: function () {
      if (!this.get('id')) {
        this.set('id', createID('aux-label-'));
      }
    },
  },
});
/**
 * @member {Label} Meter#value - The {@link Label} displaying the value.
 */
defineChildWidget(Meter, 'value', {
  create: Label,
  show: false,
  toggle_class: true,
  default_options: {
    class: 'aux-value',
    role: 'status',
  },
});