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 { defineChildWidget } from '../child_widget.js';
import { Widget } from './widget.js';
import { Label } from './label.js';
import {
  rangedOptionsDefaults,
  rangedOptionsTypes,
  makeRanged,
} from '../utils/make_ranged.js';
import { Scale } from './scale.js';
import { sprintf } from '../utils/sprintf.js';
import {
  element,
  addClass,
  toggleClass,
  removeClass,
  insertAfter,
  innerWidth,
  innerHeight,
} from '../utils/dom.js';

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

function vert(O) {
  return O.layout === 'left' || O.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 drawGradient(element, gradient, fallback, range) {
  const O = this.options;
  let bg = '';
  range = range || this;

  if (!gradient && !O.gradient) {
    bg = fallback || O.background;
    if (element.tagName === 'CANVAS') {
      const ctx = element.getContext('2d');
      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, O._width, O._height);
      return;
    }
  } else {
    gradient = gradient || this.options.gradient;

    let keys = Object.keys(gradient);
    for (let i = 0; i < keys.length; i++) {
      keys[i] = parseFloat(keys[i]);
    }
    keys = keys.sort(
      O.reverse
        ? function (a, b) {
            return b - a;
          }
        : function (a, b) {
            return a - b;
          }
    );

    const transformation = O.transformation;
    const snap_module = O.snap_module;

    if (element.tagName === 'CANVAS') {
      const vert = O.layout == 'left' || O.layout == 'right';
      const ctx = element.getContext('2d');
      const grd = ctx.createLinearGradient(
        0,
        0,
        vert ? 0 : O._width || 0,
        vert ? O._height || 0 : 0
      );
      for (let i = 0; i < keys.length; i++) {
        let pos = transformation.valueToCoef(snap_module.snap(keys[i]));
        pos = Math.min(1, Math.max(0, pos));
        if (vert) pos = 1 - pos;
        grd.addColorStop(pos, gradient[keys[i] + '']);
      }
      ctx.fillStyle = grd;
      ctx.fillRect(0, 0, O._width, O._height);
      return;
    }

    let m_regular = '';
    const s_regular = 'linear-gradient(%s, %s)';
    const c_regular = '%s %s%%, ';

    const d_w3c = {};
    d_w3c.sleft = 'to top';
    d_w3c.sright = 'to top';
    d_w3c.stop = 'to right';
    d_w3c.sbottom = 'to right';

    for (let i = 0; i < keys.length; i++) {
      const ps = (
        100 * transformation.valueToCoef(snap_module.snap(keys[i]))
      ).toFixed(2);
      m_regular += sprintf(c_regular, gradient[keys[i] + ''], ps);
    }
    m_regular = m_regular.substr(0, m_regular.length - 2);
    bg = sprintf(s_regular, d_w3c['s' + this.options.layout], m_regular);
  }

  if (element) {
    element.style.background = bg ? bg : void 0;
  }
  return bg;
}

/**
 * Meter is a base class to build different meters from, such as {@link LevelMeter}.
 * Meter uses {@link Gradient} and contains a {@link Scale} widget.
 * Meter inherits all options from {@link Scale}.
 *
 * 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`.
 */

export class Meter extends Widget {
  static get _options() {
    return Object.assign({}, Widget.getOptionTypes(), 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',
      foreground: 'string|boolean',
    });
  }

  static get options() {
    return Object.assign({}, 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',
    });
  }

  static get static_events() {
    return {
      set_base: function (value) {
        if (value === false) {
          const O = this.options;
          O.base = value = O.min;
        }
      },
      rangedchanged: function () {
        /* redraw the gradient, if we have any */

        const gradient = this.options.gradient;

        if (gradient) {
          this.set('gradient', gradient);
        }
      },
      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'));
      },
    };
  }

  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);

    super.draw(O, element);
  }

  redraw() {
    const I = this.invalid;
    const O = this.options;
    const E = this.element;

    if (I.reverse) {
      I.reverse = false;
      toggleClass(E, 'aux-reverse', O.reverse);
    }

    super.redraw();

    if (I.layout) {
      I.layout = false;
      removeClass(
        E,
        'aux-vertical',
        'aux-horizontal',
        'aux-left',
        'aux-right',
        'aux-top',
        'aux-bottom'
      );
      const scale = this.scale ? this.scale.element : null;
      const bar = this._bar;
      switch (O.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');
      }
    }

    if (O.foreground === false) return;

    if (I.basis && O._height > 0 && O._width > 0) {
      I.basis = false;

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

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

    if (I.gradient || I.background) {
      I.gradient = I.background = false;
      drawGradient.call(this, this._backdrop, O.gradient, O.background);
    }

    if (I.value || I.transformation || I.segment || I.foreground) {
      I.transformation = I.value = I.segment = I.foreground = false;
      this.drawMeter();
    }
  }

  resize() {
    const O = this.options;
    super.resize();
    const w = innerWidth(this._bar, void 0, true);
    const h = innerHeight(this._bar, void 0, true);
    this.set('_width', w);
    this.set('_height', h);
    const i = vert(O) ? h : w;
    this.set('basis', i);
    this._last_meters.length = 0;
    this.set('gradient', O.gradient);
  }

  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');
    ctx.fillStyle = O.foreground;
    const is_vertical = vert(O);

    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;
  }
}
makeRanged(Meter);
/**
 * @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,
});
/**
 * @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',
  },
});