/*
* 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
*/
/* jshint -W018 */
import { defineChildElement } from '../widget_helpers.js';
import { Widget } from './widget.js';
import {
rangedEvents,
rangedOptionsDefaults,
rangedOptionsTypes,
rangedRenderers,
} from '../utils/ranged.js';
import { mergeStaticEvents } from '../widget_helpers.js';
import {
setContent,
addClass,
outerWidth,
outerHeight,
element,
removeClass,
toggleClass,
innerHeight,
innerWidth,
} from '../utils/dom.js';
import { FORMAT } from '../utils/sprintf.js';
import { warn } from '../utils/log.js';
import {
defineRender,
deferRender,
deferMeasure,
combineDefer,
} from '../renderer.js';
function vert(layout) {
return layout === 'left' || layout === 'right';
}
function fillInterval(transformation, steps, i, from, to, min_gap, result) {
const to_pos = transformation.valueToPixel(to);
const from_pos = transformation.valueToPixel(from);
if (Math.abs(to_pos - from_pos) < min_gap) return;
if (!result)
result = {
values: [],
positions: [],
};
const values = result.values;
const positions = result.positions;
const displayStep = Math.sign(to_pos - from_pos) * Math.max(1, min_gap);
const step = from > to ? -steps[i] : steps[i];
let lastPos = from_pos;
let lastValue = from;
for (let x = from + step; Math.sign(to - x) === Math.sign(step); ) {
const pos = transformation.valueToPixel(x);
const diff = Math.abs(lastPos - pos);
if (Math.abs(to_pos - pos) < min_gap) break;
if (diff >= min_gap) {
if (i > 0 && diff >= min_gap * 2) {
// we have a chance to fit some more labels in
fillInterval(
transformation,
steps,
i - 1,
lastValue,
x,
min_gap,
result
);
}
values.push(x);
positions.push(pos);
lastPos = pos;
lastValue = x;
x += step;
} else {
const nextValue = transformation.pixelToValue(lastPos + displayStep);
let diff = Math.ceil((nextValue - x) / step) * step;
if (!diff) diff = step;
x += diff;
}
}
if (i > 0 && Math.abs(lastPos - to_pos) >= min_gap * 2) {
fillInterval(transformation, steps, i - 1, lastValue, to, min_gap, result);
}
return result;
}
function binaryContains(list, value) {
const length = list.length;
if (length === 0 || !(value >= list[0]) || !(value <= list[length - 1]))
return false;
for (let start = 0, end = length - 1; start <= end; ) {
const mid = start + ((end - start) >> 1);
const pivot = list[mid];
if (value === pivot) return true;
if (value < pivot) {
end = mid - 1;
} else {
start = mid + 1;
}
}
return false;
}
// remove collisions from a with b given a minimum gap
function removeCollisions(a, b, min_gap, vert) {
const pa = a.positions,
pb = b.positions;
const va = a.values;
let dim;
min_gap = +min_gap;
if (typeof vert === 'boolean') dim = vert ? b.height : b.width;
if (!(min_gap > 0)) min_gap = 1;
if (!pb.length) return a;
let i, j;
const values = [];
const positions = [];
let pos_a, pos_b;
let size;
let last_pos = +pb[0],
last_size = min_gap;
if (dim) last_size += +dim[0] / 2;
// If pb is just length 1, it does not matter
const direction = pb.length > 1 && pb[1] < last_pos ? -1 : 1;
for (i = 0, j = 0; i < pa.length && j < pb.length; ) {
pos_a = +pa[i];
pos_b = +pb[j];
size = min_gap;
if (dim) size += dim[j] / 2;
if (
Math.abs(pos_a - last_pos) < last_size ||
Math.abs(pos_a - pos_b) < size
) {
// try next position
i++;
continue;
}
if (j < pb.length - 1 && (pos_a - pos_b) * direction > 0) {
// we left the current interval, lets try the next one
last_pos = pos_b;
last_size = size;
j++;
continue;
}
values.push(+va[i]);
positions.push(pos_a);
i++;
}
return {
values: values,
positions: positions,
};
}
function createDOMNodes(data, create) {
const nodes = [];
const E = this.element;
data.nodes = nodes;
const values = data.values;
const positions = data.positions;
for (let i = 0; i < values.length; i++) {
const node = create(values[i], positions[i]);
nodes.push(node);
E.appendChild(node);
}
}
const createLabelDependencies = [
'basis',
'labels',
'layout',
'min',
'max',
'base',
];
function createLabel(value, position) {
const { basis, labels, layout, min, max, base } = this.options;
const elem = document.createElement('SPAN');
elem.className = 'aux-label';
elem.setAttribute('role', 'presentation');
if (vert(layout)) {
elem.style.bottom = (position / basis) * 100 + '%';
} else {
elem.style.left = (position / basis) * 100 + '%';
}
setContent(elem, labels(value));
const effectiveBase = Math.max(Math.min(max, base), min);
if (effectiveBase === value) addClass(elem, 'aux-base');
if (max === value) addClass(elem, 'aux-max');
if (min === value) addClass(elem, 'aux-min');
return elem;
}
function removeDotsAndLabels(node) {
Array.from(node.children)
.filter((node) => {
return (
node.classList.contains('aux-dot') ||
node.classList.contains('aux-label')
);
})
.forEach((node) => node.remove());
}
const createDotDependencies = ['basis', 'layout', 'min', 'max', 'base'];
function createDot(value, position) {
const { basis, layout, min, max, base } = this.options;
const elem = document.createElement('DIV');
elem.className = 'aux-dot';
elem.setAttribute('role', 'presentation');
if (vert(layout)) {
elem.style.bottom = (position / basis) * 100 + '%';
} else {
elem.style.left = (position / basis) * 100 + '%';
}
const effectiveBase = Math.max(Math.min(max, base), min);
if (effectiveBase === value) addClass(elem, 'aux-base');
if (max === value) addClass(elem, 'aux-max');
if (min === value) addClass(elem, 'aux-min');
return elem;
}
function measureDimensions(data) {
const nodes = data.nodes;
const width = [];
const height = [];
for (let i = 0; i < nodes.length; i++) {
width.push(outerWidth(nodes[i], false, void 0, true));
height.push(outerHeight(nodes[i], false, void 0, true));
}
data.width = width;
data.height = height;
}
function handleEnd(O, labels, i) {
const node = labels.nodes[i];
const v = labels.values[i];
if (v === O.min) {
addClass(node, 'aux-min');
} else if (v === O.max) {
addClass(node, 'aux-max');
} else return;
}
function uniq(a) {
return a.filter((item, i, a) => a.indexOf(item) === i);
}
const generateScaleDependencies = uniq(
[
'layout',
'transformation',
'show_markers',
'show_labels',
'levels',
'levels_labels',
'gap_labels',
'gap_dots',
'avoid_collisions',
'min',
'max',
'base',
].concat(createLabelDependencies, createDotDependencies)
);
function generateScale(from, to, include_from, show_to) {
const O = this.options;
const {
layout,
transformation,
show_markers,
show_labels,
levels,
levels_labels,
gap_labels,
gap_dots,
avoid_collisions,
min,
max,
base,
} = O;
let labels;
const effectiveBase = Math.min(max, Math.max(min, base));
if (show_labels || show_markers)
labels = {
values: [],
positions: [],
};
let dots = {
values: [],
positions: [],
};
const is_vert = vert(layout);
let tmp;
if (include_from) {
tmp = transformation.valueToPixel(from);
if (labels) {
labels.values.push(from);
labels.positions.push(tmp);
}
dots.values.push(from);
dots.positions.push(tmp);
}
fillInterval(
transformation,
levels,
levels.length - 1,
from,
to,
gap_dots,
dots
);
if (labels) {
const positions = levels_labels ? levels_labels : levels;
fillInterval(
transformation,
positions,
positions.length - 1,
from,
to,
gap_labels,
labels
);
tmp = transformation.valueToPixel(to);
if (
show_to ||
Math.abs(tmp - transformation.valueToPixel(from)) >= gap_labels
) {
labels.values.push(to);
labels.positions.push(tmp);
dots.values.push(to);
dots.positions.push(tmp);
}
} else {
dots.values.push(to);
dots.positions.push(transformation.valueToPixel(to));
}
if (show_labels) {
createDOMNodes.call(this, labels, createLabel.bind(this));
if (labels.values.length && labels.values[0] === effectiveBase) {
addClass(labels.nodes[0], 'aux-base');
}
}
const render_cb = () => {
let markers;
if (show_markers) {
markers = {
values: labels.values,
positions: labels.positions,
};
createDOMNodes.call(this, markers, createDot.bind(this));
for (let i = 0; i < markers.nodes.length; i++)
addClass(markers.nodes[i], 'aux-marker');
}
if (show_labels && labels.values.length > 1) {
handleEnd(O, labels, 0);
handleEnd(O, labels, labels.nodes.length - 1);
}
if (avoid_collisions && show_labels) {
dots = removeCollisions(dots, labels, gap_dots, is_vert);
} else if (markers) {
dots = removeCollisions(dots, markers, gap_dots);
}
createDOMNodes.call(this, dots, createDot.bind(this));
};
if (show_labels && avoid_collisions) {
return deferMeasure(() => {
measureDimensions(labels);
return deferRender(render_cb);
});
} else {
render_cb();
}
}
const SymBarChanged = Symbol('_bar changed');
/**
* Interface for dots passed to the `fixed_dots` option of `Scale`.
* @interface ScaleDot
* @property {number} value - The value where the dot is located at.
* @property {string|string[]} [class] - An optional class for the generated
* `div.aux-dot` element.
*/
/**
* Interface for labels passed to the `fixed_labels` option of `Scale`.
* @interface ScaleLabel
* @property {number} value - The value where the dot is located at.
* @property {string|string[]} [class] - An optional class string for the generated
* `span.aux-label` element.
* @property {string} [label] - The label string. If omitted, the
* `options.labels(value)` is used.
*/
/**
* Scale can be used to draw scales. It is used in {@link Meter} and
* {@link Fader}. Scale draws labels and markers based on its parameters
* and the available space. Scales can be drawn both vertically and horizontally.
* Scale mixes in {@link Ranged} and inherits all its options.
*
* @extends Widget
*
* @class Scale
*
* @param {Object} [options={ }] - An object containing initial options.
*
* @property {String} [options.layout="right"] - The layout of the Scale. <code>right</code> and
* <code>left</code> are vertical layouts with the labels being drawn right and left of the scale,
* respectively. <code>top</code> and <code>bottom</code> are horizontal layouts for which the
* labels are drawn on top and below the scale, respectively.
* @property {Integer} [options.division=1] - Minimal step size of the markers.
* @property {Array<Number>} [options.levels=[1]] - Array of steps for labels and markers.
* @property {Number} [options.base=false]] - Base of the scale. If set to <code>false</code> it will
* default to the minimum value.
* @property {Function} [options.labels=FORMAT("%.2f")] - Formatting function for the labels.
* @property {Integer} [options.gap_dots=4] - Minimum gap in pixels between two adjacent markers.
* @property {Integer} [options.gap_labels=40] - Minimum gap in pixels between two adjacent labels.
* @property {Boolean} [options.show_labels=true] - If <code>true</code>, labels are drawn.
* @property {Boolean} [options.show_max=true] - If <code>true</code>, display a label and a
* dot for the 'max' value.
* @property {Boolean} [options.show_min=true] - If <code>true</code>, display a label and a
* dot for the 'min' value.
* @property {Boolean} [options.show_base=true] - If <code>true</code>, display a label and a
* dot for the 'base' value.
* @property {ScaleDot[]|number[]|Boolean} [options.fixed_dots] - This option can be used to specify fixed positions
* for the markers to be drawn at. <code>false</code> disables fixed dots.
* @property {ScaleLabel[]|number[]|Boolean} [options.fixed_labels] - This option can be used to specify fixed positions
* for the labels to be drawn at. <code>false</code> disables fixed labels.
* @property {Boolean} [options.show_markers=true] - If true, every dot which is located at the same
* position as a label has the <code>.aux-marker</code> class set.
* @property {Number|Boolean} [options.pointer=false] - The value to set the pointers position to. Set to `false` to hide the pointer.
* @property {Number|Boolean} [options.bar=false] - The value to set the bars height to. Set to `false` to hide the bar.
*/
export class Scale extends Widget {
static get _options() {
return [
rangedOptionsTypes,
{
layout: 'string',
division: 'number',
levels: 'array',
levels_labels: 'array',
base: 'number',
labels: 'function',
gap_dots: 'number',
gap_labels: 'number',
show_labels: 'boolean',
show_min: 'boolean',
show_max: 'boolean',
show_base: 'boolean',
fixed_dots: 'boolean|array',
fixed_labels: 'boolean|array',
avoid_collisions: 'boolean',
show_markers: 'boolean',
bar: 'boolean|number',
pointer: 'boolean|number',
},
];
}
static get options() {
return [
rangedOptionsDefaults,
{
layout: 'right',
division: 1,
levels: [1],
base: false,
labels: FORMAT('%.2f'),
avoid_collisions: false,
gap_dots: 4,
gap_labels: 40,
show_labels: true,
show_min: true,
show_max: true,
show_base: true,
show_markers: true,
fixed_dots: false,
fixed_labels: false,
bar: false,
pointer: false,
},
];
}
static get static_events() {
return mergeStaticEvents(
{
set: function (key, value) {
switch (key) {
case 'division':
case 'levels':
case 'labels':
case 'gap_dots':
case 'gap_labels':
case 'show_labels':
/**
* Gets fired when an option the rendering depends on was changed
*
* @event Scale#scalechanged
*
* @param {string} key - The name of the option which changed the {@link Scale}.
* @param {mixed} value - The value of the option.
*/
this.emit('scalechanged', key, value);
break;
}
},
},
rangedEvents
);
}
static get renderers() {
return [
...rangedRenderers,
defineRender('reverse', function (reverse) {
toggleClass(this.element, 'aux-reverse', reverse);
}),
defineRender('layout', function (layout) {
const E = this.element;
removeClass(
E,
'aux-vertical',
'aux-horizontal',
'aux-top',
'aux-bottom',
'aux-right',
'aux-left'
);
switch (layout) {
case 'left':
addClass(E, 'aux-vertical', 'aux-left');
break;
case 'right':
addClass(E, 'aux-vertical', 'aux-right');
break;
case 'top':
addClass(E, 'aux-horizontal', 'aux-top');
break;
case 'bottom':
addClass(E, 'aux-horizontal', 'aux-bottom');
break;
default:
warn('Unsupported layout setting:', layout);
}
this.triggerResize();
}),
defineRender(
[
SymBarChanged,
'layout',
'snap_module',
'transformation',
'bar',
'base',
'basis',
],
function (layout, snap_module, transformation, bar, base, basis) {
const _bar = this._bar;
if (!_bar) return;
const tmpval = transformation.valueToPixel(snap_module.snap(bar));
const tmpbase = transformation.valueToPixel(base);
const min = Math.min(tmpval, tmpbase);
const max = Math.max(tmpval, tmpbase);
const style = _bar.style;
if (vert(layout)) {
style.top = ((basis - max) / basis) * 100 + '%';
style.bottom = (min / basis) * 100 + '%';
style.removeProperty('left');
style.removeProperty('right');
} else {
style.right = ((basis - max) / basis) * 100 + '%';
style.left = (min / basis) * 100 + '%';
style.removeProperty('top');
style.removeProperty('bottom');
}
}
),
defineRender(
[
'fixed_dots',
'fixed_labels',
'show_labels',
'show_markers',
'min',
'max',
'transformation',
'layout',
'basis',
'base',
],
function (fixed_dots, fixed_labels, show_labels, show_markers) {
if (!fixed_dots || !fixed_labels) return;
const E = this.element;
removeDotsAndLabels(E);
const dotNodes = this._createDots(fixed_dots);
if (show_labels) {
const labelNodes = this._createLabels(fixed_labels);
if (show_markers) {
this._highlightMarkers(fixed_labels, fixed_dots, dotNodes);
}
labelNodes.forEach((node) => E.appendChild(node));
}
dotNodes.forEach((node) => E.appendChild(node));
}
),
defineRender(
uniq(
[
'fixed_dots',
'fixed_labels',
'min',
'max',
'base',
'show_min',
'show_max',
].concat(generateScaleDependencies)
),
function (
fixed_dots,
fixed_labels,
min,
max,
base,
show_min,
show_max
) {
if (fixed_dots && fixed_labels) return;
removeDotsAndLabels(this.element);
return combineDefer(
base !== min
? generateScale.call(this, base, min, base === max, show_min)
: null,
base !== max
? generateScale.call(this, base, max, true, show_max)
: null
);
}
),
];
}
initialize(options) {
if (!options.element) options.element = element('div');
super.initialize(options);
/**
* @member {HTMLDivElement} Scale#element - The main DIV element. Has class <code>.aux-scale</code>
*/
}
draw(O, element) {
addClass(element, 'aux-scale');
super.draw(O, element);
}
_highlightMarkers(labels, dots, dotNodes) {
labels = labels
.map((args) => (typeof args === 'number' ? args : args.value))
.sort((a, b) => a - b);
for (let i = 0; i < dots.length; i++) {
const value = typeof dots[i] === 'number' ? dots[i] : dots[i].value;
if (!binaryContains(labels, value)) continue;
addClass(dotNodes[i], 'aux-marker');
}
}
_createDot(args) {
const { transformation, layout, basis, min, max, base } = this.options;
const node = document.createElement('DIV');
addClass(node, 'aux-dot');
node.setAttribute('role', 'presentation');
let value;
if (typeof args === 'number') {
value = args;
} else {
value = args.value;
const cl = args.class;
if (typeof cl === 'string') {
addClass(node, cl);
} else if (Array.isArray(cl)) {
for (let i = 0; i < cl.length; i++) addClass(node, cl[i]);
}
}
const position = transformation.valueToPixel(value);
if (vert(layout)) {
node.style.bottom = (position / basis) * 100 + '%';
} else {
node.style.left = (position / basis) * 100 + '%';
}
const effectiveBase = Math.max(Math.min(max, base), min);
if (effectiveBase === value) addClass(node, 'aux-base');
if (max === value) addClass(node, 'aux-max');
if (min === value) addClass(node, 'aux-min');
return node;
}
_createDots(values) {
return values.map((value) => this._createDot(value));
}
_createLabel(args) {
const {
transformation,
labels,
layout,
basis,
min,
max,
base,
} = this.options;
let position, label;
const node = document.createElement('SPAN');
addClass(node, 'aux-label');
node.setAttribute('role', 'presentation');
let value;
if (typeof args === 'number') {
value = args;
position = transformation.valueToPixel(value);
label = labels(value);
} else {
value = args.value;
position = transformation.valueToPixel(value);
const cl = args.class;
if (typeof cl === 'string') {
addClass(node, cl);
} else if (Array.isArray(cl)) {
for (let i = 0; i < cl.length; i++) addClass(node, cl[i]);
}
if (args.label === void 0) {
label = labels(value);
} else {
label = args.label;
}
}
if (vert(layout)) {
node.style.bottom = (position / basis) * 100 + '%';
} else {
node.style.left = (position / basis) * 100 + '%';
}
setContent(node, label);
const effectiveBase = Math.max(Math.min(max, base), min);
if (effectiveBase === value) addClass(node, 'aux-base');
if (max === value) addClass(node, 'aux-max');
if (min === value) addClass(node, 'aux-min');
return node;
}
_createLabels(values) {
return values.map((value) => this._createLabel(value));
}
getResizeTargets() {
return [this.element];
}
resize() {
super.resize();
const O = this.options;
const basis = vert(O.layout)
? innerHeight(this.element, undefined, true)
: innerWidth(this.element, undefined, true);
this.update('basis', basis);
}
destroy() {
removeDotsAndLabels(this.element);
super.destroy();
}
}
/**
* @member {HTMLDivElement} Fader#_pointer - The DIV element of the pointer. It can be used to e.g. visualize the value set in the backend.
*/
defineChildElement(Scale, 'pointer', {
show: false,
toggle_class: true,
option: 'pointer',
debug: true,
draw_options: ['pointer', 'transformation', 'snap_module', 'layout'],
draw: function (O) {
const { _pointer } = this;
if (!_pointer) return;
const { transformation, snap_module, pointer, layout } = O;
const tmp =
transformation.valueToCoef(snap_module.snap(pointer)) * 100 + '%';
if (vert(layout)) {
_pointer.style.bottom = tmp;
} else {
_pointer.style.left = tmp;
}
},
});
/**
* @member {HTMLDivElement} Fader#_bar - The DIV element of the bar. It can be used to e.g. visualize the value set in the backend or to draw a simple levelmeter.
*/
defineChildElement(Scale, 'bar', {
show: false,
toggle_class: true,
option: 'bar',
dependency: SymBarChanged,
});