/*
* 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 Graph = niviz.Graph;
var Config = niviz.Config;
var Axis = niviz.Axis;
var EventEmitter = niviz.EventEmitter;
var extend = niviz.util.extend;
var nivizNode = niviz.util.nivizNode;
/** @module niviz */
/**
* The Rangegraph class provides the basic visual means to display time
* series of data (e. g. snow profiles or meteo data). The class focuses
* on the display of the dates along the abscissa and provides methods
* to easily change the range of the dates to be displayed. No interactivity
* is provided by the Rangegraph - the children of the Rangegraph class
* shall deal with this issue.
*
* @class Rangegraph
* @extends Graph
* @constructor
*
* @param {Object} data Time series of data
* @param {Object} canvas A svg element that will be used as SnapSVG paper
* @param {Object} properties
*/
function Rangegraph (data, canvas, properties) {
Rangegraph.uber.call(this, data, canvas, properties);
EventEmitter.call(this);
if (canvas.jquery) {
this.canvasnode = canvas[0];
this.canvas = canvas;
} else {
this.canvasnode = canvas;
this.canvas = nivizNode(canvas);
}
this.canvas.empty();
this.data = data;
console.dir(this.data);
this.elements = {};
this.yzoom = { min: 0, max: 100 }; // in percent vertical zoom window
this.properties = extend({}, Rangegraph.defaults.values);
this.setProperties(properties);
}
Graph.implement(Rangegraph, 'Rangegraph', 'rangegraph');
Rangegraph.defaults = new Config('Rangegraph', [
{ name: 'fontsize', type: 'number', default: 14 },
{ name: 'grid_color', type: 'color', default: '#000' }
]);
Rangegraph.defaults.load();
/**
* Overwrite current properties with the ones passed as parameter.
*
* @method setProperties
* @param {Object} properties
*/
Rangegraph.prototype.setProperties = function (properties) {
extend(this.properties, properties);
};
/**
* Check if the object with identifier name exists in context ctx and
* try to remove it from the paper and then delete the object itself.
*
* @method remove
* @protected
* @param {Object} ctx Context
* @param {String} name Object identifier
*/
Rangegraph.prototype.remove = function (name, ctx) {
ctx = ctx || this;
if (ctx[name] && ctx[name].remove) ctx[name].remove();
delete ctx[name];
};
/**
* Check if this instance has a valid SnapSVG paper object, in case
* the size of the canvas element has changed or no paper object is present
* resize or create the paper object.
*
* @method getpaper
* @private
*/
Rangegraph.prototype.getpaper = function () {
var p = this.properties, oldheight = p.height, oldwidth = p.width;
p.height = this.canvas.height();
p.width = this.canvas.width();
if (!this.paper || p.height !== oldheight || p.width !== oldwidth) {
if (this.paper) {
this.paper.attr({
width: p.width + 'px',
height: p.height + 'px'
});
} else {
this.paper = Snap(this.canvasnode);
}
} else {
console.log('Not resizing canvas/svg');
}
};
/**
* Configure basic properties of the Rangegraph such as font, margins, height and width
* @method config
* @protected
*/
Rangegraph.prototype.config = function () {
var p = this.properties;
this.getpaper();
p.font = {
fontSize: p.fontsize + 'px',
textAnchor: 'middle',
fontFamily: 'LatoWeb, Helvetica, Arial'
};
p.space = 3 * p.fontsize;
p.tl = { x: p.space, y: p.space };
p.br = { x: this.canvas.width() - p.space, y: this.canvas.height() - p.space };
p.length = { x: p.br.x - p.tl.x, y: p.br.y - p.tl.y};
};
/**
* Synchronize this graph with another rangegraph derivate by
* registering for all the relevant events.
*
* @method synchronize
* @protected
* @param {Rangegraph} graph
*/
Rangegraph.prototype.synchronize = function (graph) {
var self = this;
graph.on('mousemove', function (current) {
self.mousemove(current);
});
graph.on('mouseout', function () {
self.mouseout();
});
graph.on('dragstart', function (current) {
self.dragstart(current);
});
graph.on('drag', function (current) {
self.drag(current);
});
graph.on('dragend', function (current, ms) {
self.dragend(current, ms);
});
};
/**
* Desynchronize this graph with another rangegraph derivate by
* registering for all the relevant events.
*
* @method desynchronize
* @protected
* @param {Rangegraph} graph
*/
Rangegraph.prototype.desynchronize = function (graph) {
graph.off();
};
/**
* This method enables/disables the mousemove event on the area spanning
* the graph. Children may implement the methods mouseout, mousemove and
* mouseoff to tap into this event.
*
* @method mouseon
* @protected
* @param {Boolean} on Whether to turn the mousemove event on or off
*/
Rangegraph.prototype.mouseon = function (on) {
var self = this, canvas = this.canvas, current;
if (on) {
this.cover.mousemove(function (e) {
current = moment.unix(self.axisx.coord(e.pageX - canvas.offset().left));
self.emit('mousemove', current);
self.mousemove(current, e);
});
this.cover.mouseout(function () {
clearTimeout(self.timer);
setTimeout( function () {
self.emit('mouseout');
self.mouseout();
}, 15);
});
} else {
self.emit('mouseoff');
self.mouseoff();
if (self.cover) self.cover.unmouseout();
if (self.cover) self.cover.unmousemove();
}
};
/**
* Method to be called when mousemove event is detected.
*
* @method mousemove
* @protected
* @param {Object} e Mousemove DOM event object
* @param {Object} offset canvas offset object
*/
Rangegraph.prototype.mousemove = function (e, offset) {
console.log('Nothing implemented here');
};
/**
* Method to be called when mouseout event is detected.
*
* @method mouseout
* @protected
*/
Rangegraph.prototype.mouseout = function () {
console.log('Nothing implemented here');
};
/**
* Method to be called when mouse events are turned off.
* Use this to implement cleanup routines.
*
* @method mouseout
* @protected
*/
Rangegraph.prototype.mouseoff = function () {
console.log('Nothing implemented here');
};
/**
* Method to be called during dragging on top of cover.
*
* @method drag
* @protected
* @param {Moment} current Date at current drag position
*/
Rangegraph.prototype.drag = function (current) {
console.log('Nothing implemented here');
};
/**
* Method to be called at the start of dragging on top of cover.
*
* @method dragstart
* @protected
* @param {Moment} current Date at current drag position
*/
Rangegraph.prototype.dragstart = function (current) {
console.log('Nothing implemented here');
};
/**
* 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
*/
Rangegraph.prototype.dragend = function (current, ms) {
console.log('Nothing implemented here');
};
/**
* Drag functions used for zooming when mouse goes down over this.cover,
* start drawing selection box.
*
* @method draggable
* @private
* @param {Element} element SnapSVG element to set up with drag functionality
*/
Rangegraph.prototype.draggable = function (element) {
var self = this, lastdx = 0, lastdy = 0, p = this.properties,
left = this.canvas.offset().left, top = this.canvas.offset().top,
start = { x: left + p.tl.x, y: top + p.tl.y },
end = { x: start.x + p.length.x, y: start.y + p.length.y },
offset = { x: self.canvas.offset().left, y: self.canvas.offset().top },
clickstart, current, startpos = { x: null, y: null },
endpos = { x: null, y: null };
element.undrag(); // cleanup
element.drag(
function (dx, dy, x, y, event) { //mousedrag
var my = this;
this.dragtimer = setTimeout( function () {
if (lastdx === dx && lastdy === dy) return;
lastdx = dx;
lastdy = dy;
if ((x >= start.x && x <= end.x) && (y >= start.y && y <= end.y)) {
my.box.transform('T' + Math.min(0, dx) + ', ' + Math.min(0, dy));
my.box.attr('width', Math.abs(dx));
my.box.attr('height', Math.abs(dy));
endpos.x = x + 1 - offset.x;
endpos.y = y + 1 - offset.y;
} else if (x >= start.x && x <= end.x) {
if (y < start.y) {
my.box.transform('T' + Math.min(0, dx) + ', ' + (p.tl.y - startpos.y + 2));
my.box.attr('width', Math.abs(dx));
my.box.attr('height', Math.abs(p.tl.y - startpos.y + 2));
endpos.y = p.tl.y;
} else {
my.box.transform('T' + Math.min(0, dx) + ', 0');
my.box.attr('width', Math.abs(dx));
my.box.attr('height', Math.abs(p.br.y - startpos.y + 1));
endpos.y = p.tl.y + p.length.y;
}
endpos.x = x + 1 - offset.x;
} else if (y >= start.y && y <= end.y) {
if (x < start.x) {
my.box.transform('T' + (p.tl.x - startpos.x + 2) + ', ' + Math.min(0, dy));
my.box.attr('width', Math.abs(p.tl.x - startpos.x + 2));
my.box.attr('height', Math.abs(dy));
endpos.x = p.tl.x;
} else {
my.box.transform('T0, ' + Math.min(0, dy));
my.box.attr('height', Math.abs(dy));
endpos.x = p.tl.x + p.length.x;
}
endpos.y = y + 1 - offset.y;
}
// else if (x > end.x) {
// endpos.x = p.tl.x + p.length.x;
// my.box.attr('width', Math.abs(p.br.x - startpos.x));
// } else if (x < start.x) {
// endpos.x = p.tl.x;
// }
// if (y >= start.y && y <= end.y) {
// // my.box.transform('T0' + Math.min(0, dy));
// // my.box.attr('height', Math.abs(dy));
// //
// // console.log('1');
// } else if (y > end.y) {
// endpos.y = p.tl.y + p.length.y;
// my.box.attr('height', Math.abs(p.br.y - startpos.y));
// console.log('2');
// } else if (y < start.y) {
// endpos.y = p.tl.y;
// console.log('3');
// my.box.attr('height', Math.abs(startpos.y - p.tl.y));
// }
current = moment.unix(self.axisx.coord(endpos.x));
self.drag(current);
self.emit('drag', current);
}, 15);
},
function (x, y, event) { //mousestart
this.box && this.box.remove();
self.mouseon(false);
p = self.properties;
offset.x = self.canvas.offset().left;
offset.y = self.canvas.offset().top;
start.x = offset.x + p.tl.x;
start.y = offset.y + p.tl.y;
end.x = start.x + p.length.x;
end.y = start.y + p.length.y;
clickstart = +new Date();
lastdx = 0;
lastdy = 0;
startpos.x = x + 1 - offset.x; // the +1 is for index calculation
startpos.y = y + 1 - offset.y;
current = moment.unix(self.axisx.coord(startpos.x));
self.dragstart(current);
self.emit('dragstart', current);
this.box = self.paper.rect(x - offset.x, y - offset.y, 0, 0)
.attr({ 'stroke': '#9999FF', 'fill': '#9999FF', 'opacity': 0.3 });
this.box.node.style['pointer-events'] = 'none';
},
function (event) { //mouseend
var r = Math.abs(self.yzoom.min - self.yzoom.max);
var min = (startpos.y > endpos.y) ? endpos.y : startpos.y;
var max = (startpos.y > endpos.y) ? startpos.y : endpos.y;
var newmax = self.yzoom.min + Math.round((max - p.tl.y) / p.length.y * r);
var newmin = self.yzoom.min + Math.round((min - p.tl.y) / p.length.y * r);
if (newmin !== newmax) {
self.yzoom.max = newmax;
self.yzoom.min = newmin;
}
self.datelabel();
this.box.remove();
self.dragend(current, (+new Date()) - clickstart);
self.emit('dragend', current, (+new Date()) - clickstart);
self.mouseon(true);
}
);
};
/**
* Set the new date range for the abscissa
*
* @method range
* @protected
* @param {Moment} start Start date of time series
* @param {Moment} end End date of time series
*/
Rangegraph.prototype.range = function (start, end) {
var p = this.properties, pstart = parseInt(start.format('X')),
pend = parseInt(end.format('X'));
this.axisx = new Axis(pstart, pend, p.tl.x, p.br.x);
this.dates = this.griddates(start, end);
this.dates.labels = this.labelsx(this.dates);
this.gridx();
};
/**
* Check the size of the date range given.
*
* @method mode
* @private
* @param {Moment} start Start date of time series
* @param {Moment} end End date of time series
* @return {String} Categorization of date range as
* 'years', 'months', 'monthweeks' or 'days'
*/
Rangegraph.prototype.mode = function (start, end) {
var span = end.diff(start, 'days');
if (span > 3000) {
return 'years';
} else if (span > 300) {
return 'months';
} else if (span > 90) {
return 'monthweeks';
} else if (span > 3) {
return 'days';
}
return 'hours';
};
/**
* Given an array of dates, create the respective labels to be shown
*
* @method labelsx
* @private
* @param {Array<Moment>} dates An array of dates as moment objects
* @return {Array<String>} Array of strings representing the passed dates
*/
Rangegraph.prototype.labelsx = function (dates) {
var i, ii = dates.length, labels = [], p = this.properties, last, year, label;
var mod = Math.ceil((8 * p.fontsize) / (p.width / dates.length));
for (i = 0; i < ii; ++i) {
if (dates.mode === 'years') {
label = year = dates[i].year();
labels.push({ text: label });
} else if (dates.mode === 'months') {
label = (i % mod === 0) ? dates[i].format('MMM') : '';
if ((label !== '') && (!year || year !== dates[i].year())) {
label = year = dates[i].year();
}
labels.push({ text: label });
} else if (dates.mode === 'days' || dates.mode === 'monthweeks') {
label = (i % mod === 0) ? dates[i].format('MMM') + ' ' + dates[i].date() : '';
if ((!year || year !== dates[i].year()) && label !== '') {
label = year = dates[i].year();
}
labels.push({ text: label });
} else if (dates.mode === 'hours') {
var current = dates[i].date();
if (i % mod === 0) {
if (current !== last) {
label = dates[i].format('MMM') + ' ' + dates[i].date();
if (!year || year !== dates[i].year()) {
label += ' \'' + dates[i].format('YY');
year = dates[i].year();
}
labels.push({ text: label });
} else {
labels.push({
text: dates[i].format('HH:mm')
});
}
last = dates[i].date();
} else {
labels.push({
text: ''
});
}
}
}
return labels;
};
/**
* Given a start and an end date return an array of dates that divide up the
* range between the start and end date into approximately even intervals
*
* @method griddates
* @private
* @param {Moment} start Start date of time series
* @param {Moment} end End date of time series
* @return {Array<Moment>} Array of dates
*/
Rangegraph.prototype.griddates = function (start, end) {
var mode = this.mode(start, end), dates = [], span, i, inc, tmp;
if (mode === 'years') {
inc = Math.ceil(end.diff(start, 'years') / 30);
for (i = start.year() + 1; i <= end.year(); i = i + inc) {
dates.push(moment('2000-01-01 00:00:00.000').year(i));
}
} else if (mode === 'months'){
span = end.diff(start, 'months');
for (i = 1; i <= span; ++i) {
tmp = moment(start).date(1).hour(0).minute(0).second(0).millisecond(0).add(i, 'M');
dates.push(tmp);
}
} else if (mode === 'monthweeks'){
span = end.diff(start, 'weeks');
var firstweek = 7 - start.day();
//console.dir(firstweek);
for (i = 0; i <= span; ++i) {
tmp = moment(start).hour(0).minute(0).second(0).millisecond(0)
.add(firstweek, 'd').add(7 * i, 'd');
dates.push(tmp);
}
} else if (mode === 'days'){
span = end.diff(start, 'days');
inc = Math.ceil(span / 30);
for (i = 1; i <= span; i = i + inc) {
tmp = moment(start).hour(0).minute(0).second(0).millisecond(0).add(i, 'd');
dates.push(tmp);
}
} else if (mode === 'hours'){
span = end.diff(start, 'hours');
inc = Math.ceil(span / 30);
for (i = 1; i <= span; i = i + inc) {
tmp = moment(start).minute(0).second(0).millisecond(0).add(i, 'h');
dates.push(tmp);
}
}
dates.mode = mode;
return dates;
};
/**
* Draw a date label below the abscissa for any given date
*
* @method datelabel
* @protected
* @param {Moment} date Date to be highlighted on the abscissa
*/
Rangegraph.prototype.datelabel = function (date) {
var p = this.properties, paper = this.paper, set = paper.g(), axis = this.axisx,
el = this.elements['datelabel'];
if (!date) {
this.remove('datelabel', this.elements);
return;
}
var start = axis.pixel(date.format('X')), width = 4.5 * p.fontsize, lx = start - width,
text = date.format("MMM DD 'YY, HH:mm");
lx = Math.max(axis.pxmin, lx);
lx = Math.min(axis.pxmax - 2 * width + 1, lx);
if (!el) {
set.add(paper.rect(lx, p.br.y, width * 2, p.fontsize * 2).attr({
fill: '#000',
strokeWidth: 0
}));
set.add(paper.text(lx + width, p.br.y + p.fontsize + 5, text)
.attr({ fill: '#FFF' }).attr(p.font)
);
if (!el) this.elements['datelabel'] = set;
} else { // transform
var bbox = el[0].getBBox();
el[1].attr({text: text});
if (bbox.x !== lx) {
el[0].attr({x: lx});
el[1].attr({x: lx + width});
}
}
};
/**
* Draw the coordinate system with dotted lines and a frame around it
* @method gridx
* @protected
*/
Rangegraph.prototype.gridx = function () {
var p = this.properties, paper = this.paper, set = paper.g(),
axis = paper.g(), i, x;
if (this.elements['axisx']) this.elements['axisx'].remove();
for (i = 0; i < this.dates.length; ++i) {
x = Math.round(this.axisx.pixel(this.dates[i].format('X')));
if (this.dates.labels[i].text) {
axis.add(paper.line(x, p.tl.y, x, p.br.y + 5));
set.add(paper.text(x + 2, p.br.y + p.fontsize + 5, this.dates.labels[i].text)
.attr(p.font));
} else {
axis.add(paper.line(x, p.tl.y, x, p.br.y));
}
}
set.add(paper.rect(p.tl.x, p.tl.y, p.length.x, p.length.y).attr({
stroke: p.grid_color,
strokeOpacity: 0.3,
fill: 'none'
}).transform('t0.5,0.5'));
axis.attr({
stroke: p.grid_color,
strokeOpacity: 0.2,
strokeDasharray: '2,2',
pointerEvents: 'none'
}).transform('t0.5,0.5');
set.add(axis);
this.elements['axisx'] = set;
if (!this.cover) {
this.cover = this.paper
.rect(p.tl.x, p.tl.y, p.length.x, p.length.y)
.attr({ opacity: 0.0, fill: '#00F' });
this.cover.appendTo(this.paper);
} else {
this.cover.attr({ x: p.tl.x, y: p.tl.y, width: p.length.x, height: p.length.y });
}
};
/**
* Show or delete the resetbutton
*
* @method resetbutton
* @protected
* @param {Boolean} show true = show button, false = hide button
* @param {Function} callback Callback function executed on click
*/
Rangegraph.prototype.resetbutton = function (show, callback) {
var paper = this.paper, p = this.properties, self = this;
this.remove('showall', this.elements);
//if (show && this.elements['showall']) return;
if (show) {
var set = paper.g(), width = 4 * p.fontsize, lines = paper.g();
var center = { x: p.br.x - 1.6 * width, y: p.tl.y + p.fontsize };
var rect = paper.rect(p.br.x - 2 * width, p.tl.y, 2 * width, 2 * p.fontsize)
.attr({ fill: '#000', 'stroke-width': 0, opacity: 0.0 });
set.add(rect);
// Magnifying glass:
set.add(paper.circle(center.x, center.y, 7.5).attr({
strokeWidth: 1.5, stroke: p.grid_color, fill: 'none', class: 'removable'
}));
lines.add(paper.line(center.x - 3, center.y, center.x + 3, center.y));
lines.add(paper.line(center.x + 5, center.y + 5, center.x + 10, center.y + 10));
set.add(lines.attr({
'stroke-width': 1.5, stroke: p.grid_color, class: 'removable'
}));
set.add(paper.text(p.br.x - 0.8 * width, center.y + p.fontsize / 2 - 2, 'Show all')
.attr(p.font).attr({ opacity: 0.9, class: 'removable' }));
set.attr('cursor', 'pointer');
set.hover(function () { rect.attr({ opacity: 0.1 }); },
function () { rect.attr({ opacity: 0.0 }); });
set.click(function () {
callback.apply(self);
});
this.elements['showall'] = set;
} else {
this.remove('showall', this.elements);
}
};
// --- Helpers ---
// --- Module Exports ---
niviz.Rangegraph = Rangegraph;
}(niviz, moment));