/*
* niViz -- snow profiles visualization
* Copyright (C) 2015 WSL/SLF - Fluelastrasse 11 - 7260 Davos Dorf - Switzerland.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
(function (niviz, moment) {
'use strict';
// --- Module Dependencies ---
var defprops = Object.defineProperties;
var Graph = niviz.Graph;
var Config = niviz.Config;
var Axis = niviz.Axis;
var Grid = niviz.Grid;
var round = niviz.util.round;
var header = niviz.Header;
var extend = niviz.util.extend;
/** @module niviz */
/**
* Visualization of a time series of parameters present in Meteo objects.
* Multiple such parameters and groups thereof may be displayed simultanously.
*
* @class Meteograph
* @constructor
* @extends Rangegraph
*
* @param {Meteo} data Time series of data
* @param {Object} canvas A svg element that will be used as SnapSVG paper
* @param {Object} properties
*/
function Meteograph (data, canvas, properties) {
this.last_highlighted_index = 0; // See meteographer UI
this.last_keyboard_highlighting_timeout = null; // See meteographer UI
this.is_keyboard_highlighting = false; // See meteographer UI
Meteograph.uber.call(this, data, canvas, properties);
this.axisy = {};
extend(this.properties, Meteograph.defaults.values);
this.setProperties(extend(properties, { autoscale: false, use_settings: true }));
this.parameters = [];
if (data.fields.length > 1) this.parameters.push(data.fields[1]);
this.draw();
}
Graph.implement(Meteograph, 'Meteograph', 'meteo', 'Rangegraph');
Meteograph.defaults = new Config('Meteo', [
niviz.Meteo.params,
niviz.Meteo.group
], 'Time Series');
Meteograph.defaults.load();
defprops(Meteograph.prototype, {
/**
* Get all selectable parameters and parameter groups
*
* @property all
* @type Array<String>
*/
all: {
get: function () {
var arrays = this.data.fields.slice(1);
this.properties.group.forEach(function (item) {
arrays.push(item.name);
}, this);
return arrays;
}
}
});
function convert (c, value) {
if (c && c.convert) return c.convert(value);
return value;
}
/**
* Setup all axes and configurations for all curves in this.curves. Every curve
* may consist of one or more actual parameters, all configurations for every
* parameter and an axis for every curve needs to be instantiated.
*
* @method setup
* @private
* @chainable
*/
Meteograph.prototype.setup = function () {
var p = this.properties;
this.configurations = {};
this.getcurves();
// A few adjustments to use all available space
p.tl.y = p.fontsize * 2;
p.br.y = this.canvas.height() - p.fontsize * 2;
p.length.y = p.br.y - p.tl.y;
this.curves.forEach(function (curve) {
var name = curve.name, min, max,
c = this.configurations[name] = this.data.getconfig(name, p.params, p.group, this.properties.use_settings);
curve.parameters.forEach(function (param) {
var cc = this.data.getconfig(param, p.params, p.group, this.properties.use_settings);
var datamin = convert(cc, this.data.min[param]),
datamax = convert(cc, this.data.max[param]);
if (min === undefined ) min = datamin;
else if (min > datamin) min = datamin;
if (max === undefined ) max = datamax;
else if (max < datamax) max = datamax;
}, this);
var minmax = Grid.minmax(min, max, p.br.y - p.tl.y);
if (!this.properties.autoscale) {
if (c.min !== undefined) minmax.min = c.min;
if (c.max !== undefined) minmax.max = c.max;
}
var r = Math.abs(minmax.min - minmax.max);
var max = minmax.max - r * this.yzoom.min / 100;
var min = minmax.max - r * this.yzoom.max / 100;
this.axisy[name] = new Axis(min, max, p.br.y, p.tl.y, c.log);
curve.parameters.forEach(function (param) {
this.configurations[param] = this.data.getconfig(param, p.params, p.group, this.properties.use_settings);
}, this);
}, this);
return this.gridy().header();
};
/**
* Parameters and parameter groups are mashed together in an object called 'curves'.
* The 'curves' object associates names (e. g. ta or tsurf) with a parameters array.
* @method getcurves
* @private
*/
Meteograph.prototype.getcurves = function () {
var p = this.properties;
this.curves = [];
this.parameters.forEach(function (param) {
if (this.data.fields.indexOf(param) > -1)
this.curves.push({ name: param, parameters: [param] });
}, this);
p.group.forEach(function (obj) {
this.curves.push(
{ name: obj.name,
parameters: obj.group.trim().replace(/\s+/g, '').split(',').filter(function (el) {
return this.data.fields.indexOf(el) > -1;
}, this)
});
}, this);
};
/**
* Prepare the ordinate axis (axes) that shall have a legend (up to two). Calculate
* the grid points (call Grid.gridify). Estimate the pixel width of the axis annotation
* and position the axis label accordingly. Call Meteograph:drawgrid on all legends
* to be drawn.
*
* @method gridy
* @chainable
* @private
*/
Meteograph.prototype.gridy = function () {
var types = [], counter = 0, p = this.properties, legends = [], paper = this.paper;
this.remove('gridy', this.elements);
this.elements['gridy'] = paper.g();
this.parameters.forEach(function (parameter) {
var c = this.configurations[parameter], type = c.name,
axis = this.axisy[parameter], grid;
if (type && types.indexOf(type) === -1 && counter < 2) {
grid = Grid.gridify(axis);
var longest = (grid.heights.reduce(function (a, b) {
var t1 = a + '', t2 = b + '';
return t1.length > t2.length ? a : b;
}) + '').length;
if (!counter) {
p.tl.x = p.fontsize + longest * p.fontsize;
} else {
p.br.x = this.canvas.width() - p.fontsize - longest * p.fontsize;
}
p.length.x = p.br.x - p.tl.x;
legends.push({ c: c, h: grid.heights, a: axis, pos: counter ? 'right' : 'left' });
counter++;
}
}, this);
legends.forEach(function (l) {
this.elements['gridy'].add(this.drawgrid(l.c, l.h, l.a, l.pos));
}, this);
p.cliprect = [p.tl.x, p.tl.y, p.length.x, p.length.y];
return this;
};
/**
* Draw the ordinate legend and grid for a specific parameter / parameter group.
*
* @method drawgrid
* @private
* @param {Object} config Configuration for a parameter / parameter group
* @param {Array<Number>} heights Ordinate points to draw grid at
* @param {Axis} axis The axis of the ordinate to calculate coords to pixels
* @param {String} position 'left' if the legend should be placed on the left side
* @return {Object} svg group container comprising legend and grid lines
*/
Meteograph.prototype.drawgrid = function (config, heights, axis, position) {
var p = this.properties, paper = this.paper, path = paper.g(), set = paper.g(), i, ii;
for (i = 0, ii = heights.length; i < ii; ++i) {
var height = heights[i], y = Math.round(axis.pixel(height)), lbly = y + p.fontsize / 2 - 2;
if (i && i !== (ii - 1) && position === 'left')
path.add(paper.line(p.tl.x - 5, y, p.br.x + 5, y));
set.add(paper.text(position === 'left' ? p.tl.x - 7 : p.br.x + 7, lbly, height + '')
.attr(p.font).attr({
textAnchor: position === 'left' ? 'end' : 'start'
}));
}
var label = config.name + (config.unit ? ' [' + config.unit + ']' : '');
set.add(paper.text((position === 'left' ? p.fontsize / 2 + 2 : this.canvas.width() - p.fontsize / 2 - 2),
p.tl.y + p.length.y / 2, label).attr(p.font)
.transform('r270').attr({ fill: config.color || '#000' }));
set.add(path.attr({
stroke: p.grid_color,
strokeOpacity: 0.2,
strokeDasharray: '2,2',
pointerEvents: 'none'
}).transform('t0.5,0.5'));
return set;
};
/**
* Draw a header on the left top of the graph.
* @method header
* @private
* @chainable
*/
Meteograph.prototype.header = function () {
var p = this.properties, paper = this.paper, meteo = this.data, text = '',
position = meteo.position;
this.remove('header', this.elements);
if (meteo.name) text += meteo.name;
if (position) {
if (position.link) {
text += (' (' + round(position.latitude, 4) + '° N '
+ round(position.longitude, 4) + '° E)');
}
if (position.altitude) text += (', ' + position.altitude + position.uom);
}
var el = paper.text(p.tl.x, p.tl.y - p.fontsize, text).attr(p.font).attr({
textAnchor: 'start'
});
header.link(el, position.link, p.font_color);
this.elements['header'] = el;
return this;
};
/**
* Method to be called when mouseout event is detected. Delete the currently
* displayed date label (unhighlight) and display the date label for the
* current indicator position, if the indicator is set.
*
* @method mouseout
* @protected
*/
Meteograph.prototype.mouseout = function () {
if(this.is_keyboard_highlighting) {
return;
}
this.unhighlight();
};
/**
* Method to be called when mouse events are turned off.
*
* @method mouseout
* @protected
*/
Meteograph.prototype.mouseoff = function () {
clearTimeout(this.timer);
};
/**
* Method to be called when mousemove event is detected. It calls the method
* Meteograph:highlight in turn.
*
* @method mousemove
* @private
* @param {Object} e Mousemove DOM event object
* @param {Object} canvas offset object
*/
Meteograph.prototype.mousemove = function (currentdate, e) {
if(this.is_keyboard_highlighting) {
return;
}
var self = this, current;
clearTimeout(this.timer);
this.timer = setTimeout(function () {
// 2020-10-07: Commenting out the following line, reason:
// don't understand why the stardragdate should always
// be recalculated when the mouse moves and there is already a startdragdate
// self.startdragdate = moment.utc(currentdate);
current = self.data.index(currentdate);
self.highlight(current);
}, 5);
};
/**
* Method to be called during dragging on top of cover.
*
* @method drag
* @protected
* @param {Moment} current Date at current drag position
*/
Meteograph.prototype.drag = function (current) {
current = this.data.index(current);
this.highlight(current);
};
/**
* Method to be called at the start of dragging on top of cover.
*
* @method dragstart
* @protected
* @param {Moment} current Date at current drag position
*/
Meteograph.prototype.dragstart = function (current) {
this.startdragdate = moment.utc(current);
};
/**
* Method to be called at the end of dragging on top of cover.
*
* @method dragend
* @protected
* @param {Moment} current Date at current drag position
* @param {Number} ms Duration of drag in ms
*/
Meteograph.prototype.dragend = function (current, ms) {
var startdate, enddate;
if (ms > 200 || ms === undefined) { // click at least 200ms long
startdate = moment.utc(this.startdragdate);
enddate = moment.utc(current);
if (enddate.isBefore(startdate)) enddate = [startdate, startdate = enddate][0];
this.clip(startdate, enddate);
this.unhighlight();
}
};
/**
* Display the values for the parameters in the graph at the current position of
* the mouse cursor at the top left corner of the graph.
*
* @method legend
* @private
* @param {MeteoData} meteo The meteo object at the current cursor position
*/
Meteograph.prototype.legend = function (meteo) {
var p = this.properties, paper = this.paper, c, text;
if (!this.legends) {
var set = paper.g(), center = { x: p.tl.x + p.fontsize, y: p.tl.y + p.fontsize },
half = p.fontsize / 2;
this.legends = {};
this.parameters.forEach(function (curvename) {
this.curves.forEach(function (curve) {
if (curve.name !== curvename) return;
curve.parameters.forEach(function (parameter) {
c = this.configurations[parameter];
set.add(paper.circle(center.x, center.y, 6).attr({
'fill': c.color || '#000', 'stroke-width': 0
}));
var txtlbl = paper.text(center.x + 6 + half, center.y + half - 3, parameter)
.attr(p.font).attr({
'font-weight': 'bold', 'text-anchor': 'start', 'fill': c.color || '#000'
});
var txtval = paper.text(txtlbl.getBBox().x2 + half, center.y + half - 3,
round(convert(c, meteo[parameter]), 2)).attr(p.font).attr({
'text-anchor': 'start', 'font': '12px Helvetica, Arial', 'fill': c.color || '#000'
});
center.x = txtlbl.getBBox().x2
+ 4 * p.fontsize + (c.unit ? c.unit.length * p.fontsize / 2 : 0);
set.add(txtlbl);
set.add(txtval);
this.legends[curve.name + '_ ' + parameter] = txtval;
}, this);
}, this);
}, this);
this.elements['legends'] = set;
} else {
this.parameters.forEach(function (curvename) {
this.curves.forEach(function (curve) {
if (curve.name !== curvename) return;
curve.parameters.forEach(function (parameter) {
c = this.configurations[parameter];
if (meteo[parameter] !== null) {
text = round(convert(c, meteo[parameter]), 2);
if (c.unit) text += ' ' + c.unit;
} else {
text = 'NULL';
}
this.legends[curve.name + '_ ' + parameter].attr({ text: text });
}, this);
}, this);
}, this);
}
};
/**
* Highlight the current cursor position by adding dots at the x-coordinate of
* the cursor and display the values for the parameters at that position in the
* top left corner of the graph (actual drawing of the values is done by
* Meteograph:legend).
*
* @method highlight
* @private
* @param {Number} current Index of current data in meteo data array
*/
Meteograph.prototype.highlight = function (current) {
if (current >= this.data.data.length || current < 0) {
return;
}
this.last_highlighted_index = current;
var paper = this.paper, set, meteo = this.data.data[current], p = this.properties;
this.datelabel(meteo.timestamp);
if (!this.dots) {
this.dots = {};
set = this.elements['dots'] = paper.g();
}
var x = this.axisx.pixel(parseInt(meteo.timestamp.format('X')));
this.parameters.forEach(function (curvename) {
this.curves.forEach(function (curve) {
if (curve.name !== curvename) return;
curve.parameters.forEach(function (parameter) {
var name = curve.name + '_' + parameter, c = this.configurations[parameter],
y = this.axisy[curve.name].pixel(convert(c, meteo[parameter]));
if (!this.dots[name]) {
var dot = paper.circle(x, y, 4).attr({
fill: c.color || '#000', strokeWidth: 0,
clip: paper.rect(p.tl.x, p.tl.y, p.length.x, p.length.y)
});
dot.node.style['pointer-events'] = 'none';
this.dots[name] = dot;
set.add(dot);
} else {
if (meteo[parameter] !== null) this.dots[name].attr({ cx: x, cy: y });
}
}, this);
}, this);
}, this);
this.legend(meteo);
};
/**
* Remove all dots on the curves and the in-graph legend with the values
* of the parameters at the current cursor position.
* @method unhighlight
* @private
*/
Meteograph.prototype.unhighlight = function () {
this.datelabel();
if (this.dots) {
this.remove('dots', this.elements);
delete this.dots;
}
if (this.elements['legends']) {
this.remove('legends', this.elements);
delete this.legends;
}
};
/**
* Show a part (or all) of the data within a start and an end date.
*
* @method clip
* @private
* @param {Moment} start Start date
* @param {Moment} end End date
*/
Meteograph.prototype.clip = function (start, end) {
this.setup();
if (start && end) {
this.$range = { start: moment.utc(start), end: moment.utc(end) };
} else if (this.$range) {
start = this.$range.start;
end = this.$range.end;
}
this.range(start, end);
this.indices(start, end);
};
/**
* Show all the data from start to end and remove the 'Show all' button.
* @method reset
* @private
*/
Meteograph.prototype.reset = function () {
this.yzoom = { min: 0, max: 100 };
this.emit('dragstart', this.data.data[0].timestamp);
this.emit('dragend', this.data.data[this.data.length - 1].timestamp);
if (this.data && this.data.length)
this.clip(this.data.data[0].timestamp, this.data.data[this.data.length - 1].timestamp);
this.resetbutton(false);
};
/**
* Given a start and end index (for the data property data in the Meteo object)
* draw all parameters and parameter groups, removing any previously present curves.
* In case the start index is not 0 and the end index is not the last index
* show the resetbutton.
*
* @method indices
* @private
* @param {Moment} startdate Start date of range
* @param {Moment} enddate End date of range
*/
Meteograph.prototype.indices = function (startdate, enddate) {
var start = this.data.index(startdate), end = this.data.index(enddate);
this.resetbutton(start !== 0 || end !== this.data.length - 1, this.reset);
if (this.data.data[start].timestamp.isAfter(this.$range.start)) start--;
if (this.data.data[end].timestamp.isBefore(this.$range.end))
end = Math.min(end + 1, this.data.length - 1);
start = Math.max(start, 0);
end = Math.max(end, 0);
this.$indices = { start: start, end: end };
this.parameters.forEach(function (parameter) {
this.curves.forEach(function (curve) {
if (curve.name !== parameter) return;
this.remove(curve.name, this.elements);
this.elements[curve.name] = this.paper.g();
curve.parameters.forEach(function (p) {
this.show(p, curve.name, this.elements[curve.name], start, end);
}, this);
}, this);
}, this);
};
/**
* Given an Axis object, draw a single curve for one parameter with
* its ordinate values calculated on that axis.
*
* @method show
* @private
* @param {String} parameter Name of the parameter to be drawn (not a group)
* @param {Axis} axis Axis object, the reference system for the ordinate values
* @param {Object} svg group container object to append curve to
* @param {Number} start Index of first data point to be displayed
* @param {Number} end Index of last data point to be displayed
*/
Meteograph.prototype.show = function (parameter, axis, set, start, end) {
var paper = this.paper, p = this.properties, c = this.configurations[parameter],
curve = [], curves = paper.g(), axisy = this.axisy[axis], i, pushm = true, path;
for (i = start; i <= end; ++i) {
if (i < 0) continue;
var tmp = this.data.data[i], x = this.axisx.pixel(tmp.timestamp.format('X')), y;
if (tmp[parameter] === null) { // nodata point
pushm = true;
continue;
}
y = axisy.pixel(convert(c, tmp[parameter]));
if (pushm) {
if (curve.length) { // draw last segment
curves.add(paper.polyline(curve));
curve = [];
}
curve.push(x, y); // start new segment
pushm = false;
} else {
curve.push(x, y);
}
}
if (curve.length) curves.add(paper.polyline(curve));
path = curves.attr({
strokeWidth: 1,
strokeOpacity: 0.9,
fill: 'none',
stroke: c.color || '#000',
pointerEvents: 'none',
clip: paper.rect(p.tl.x, p.tl.y, p.length.x, p.length.y)
});
set.add(path);
};
/**
* Set the set of parameters or group of parameters that shall be displayed.
* The method removes all highlights and curves previously displayed and then
* triggers a draw with the new set of parameters (and groups).
*
* @method set
* @chainable
* @param {Array<String>} parameters Array of parameter and group names
*/
Meteograph.prototype.set = function (parameters) {
this.unhighlight();
this.setProperties(Meteograph.defaults.values);
this.parameters.forEach(function (parameter) {
this.remove(parameter, this.elements);
}, this);
this.remove('showall', this.elements);
this.remove('cover');
this.parameters = parameters;
return this;
};
/**
* Draw the Meteograph and set up the mouse events.
*
* @method draw
* @chainable
*/
Meteograph.prototype.draw = function () {
this.config();
this.setup();
if (!this.$range) {
this.reset();
} else {
this.clip();
}
this.mouseon(true);
this.draggable(this.cover);
return this;
};
Meteograph.prototype.set_y_range = function (curve_name, min_value, max_value) {
// this.properties.params.forEach(function (p) {
// if(p.name === curve_name) {
// p.min = min;
// }
// });
// NOTE: The above code would be the equivalent of changing
// the settings from the GUI, which is a more reliable way;
// but something overrides the changes later in the execution.
var ax = this.axisy[curve_name];
if (!ax) {
throw new Error('Axis not found.');
}
var p = this.properties;
var max = ax.pixel(min_value);
var min = ax.pixel(max_value);
if (max < min) {
var tmp = max;
max = min;
min = tmp;
}
// Formulas from Rangegraph.prototype.draggable > mouseend (line 420)
var r = Math.abs(this.yzoom.min - this.yzoom.max);
var newmax = this.yzoom.min + ((max - p.tl.y) / p.length.y * r);
var newmin = this.yzoom.min + ((min - p.tl.y) / p.length.y * r);
if (newmax !== newmin) {
this.yzoom.max = newmax;
this.yzoom.min = newmin;
}
// Render like in Rangegraph.prototype.draggable > mouseend (line 420)
this.mouseon(true);
};
// --- Helpers ---
// --- Module Exports ---
niviz.Meteograph = Meteograph;
}(niviz, moment));