mrtg/htdocs/js/rrdGraphSvg.js
2016-02-10 12:53:09 +01:00

663 lines
22 KiB
JavaScript

/* ************************************************************************
Copyrigtht: OETIKER+PARTNER AG
License: GPLv3 or later
Authors: Tobias Oetiker
Utf8Check: äöü
************************************************************************ */
/**
* Create a D3.js based, interactive chart.
*
*/
var ID;
qx.Class.define("ep.visualizer.chart.BrowserChart", {
extend : qx.ui.core.Widget,
/**
* @param instance {String} identifying the visualizer instance
* @param recId {Number} stable identifier for the extopus node
* @param chartDef {Array} chart description array
*
* <pre code='javascript'>
* { [
* { cmd: 'LINE', width: 1, color, '#ff00ff', legend: 'text' },
* { cmd: 'AREA', stack: 1, color, '#df33ff', legend: 'more text' },
* ...
* ]
* }
* </pre>
*/
construct : function(instance,recId,chartDef) {
this.base(arguments);
this._setLayout(new qx.ui.layout.VBox(10));
this.set({
instance: instance,
recId: recId
});
// the chart
var d3Obj = this.__d3Obj = new qxd3.Svg();
var margin = this.self(arguments).MARGIN;
this.__chart = d3Obj.getD3SvgNode()
.append("g")
.attr("transform", "translate("+margin.left+","+margin.top+")");
this.__d3 = d3Obj.getD3();
// add the svg object into the LoadingBox
this._add(d3Obj,{flex: 1});
if (chartDef){
this.setChartDef(chartDef);
}
// insert our global CSS once only
var CSS = this.self(arguments).BASECSS;
var selector;
for (selector in CSS){
d3Obj.addCssRule(selector, CSS[selector]);
}
this.__dataNode = [];
d3Obj.addListener('appear',this.setSize,this);
d3Obj.addListener('resize',this.setSize,this);
var timer = this.__timer = new qx.event.Timer(5 * 1000);
d3Obj.addListener('disappear', function() {
timer.stop()
}, this);
d3Obj.addListener('appear', function() {
timer.start()
}, this);
timer.addListener('interval', function() {
this.trackingRedraw();
}, this);
timer.start();
},
statics : {
/**
* Some basic css for the D3 chart <code>browserchart</code>
*/
BASECSS: {
'svg': {
"font": "10px sans-serif"
},
'.axis': {
"shape-rendering": 'crispEdges'
},
'.axis path, .axis line': {
'fill': 'none',
'stroke-width': '1px'
},
'.x.axis path': {
'stroke': '#000'
},
'.x.axis line': {
'stroke': '#000',
'stroke-opacity': '.2',
'stroke-dasharray': '1,1'
},
'.y.axis line': {
'stroke': '#000',
'stroke-opacity': '.2',
'stroke-dasharray': '1,1'
}
},
MARGIN: {
left: 50,
top: 10,
bottom: 15,
right: 10
}
},
properties : {
/**
* inbytes, outbytes ...
*/
view : {
init : null,
nullable : true
},
/**
* amount of time in the chart (in seconds)
*/
timeWidth : {
init : null,
check : 'Integer',
nullable : true
},
/**
* at what point in time is the end of the chart, if no time is give the chart will update automatically
*/
endTime : {
init : null,
check : 'Integer',
nullable : true
},
/**
* track current time
*/
trackCurrentTime: {
init : null,
check : 'Boolean',
nullable : true
},
/**
* instance name of the table. This lets us identify ourselves when requesting data from the server
*/
instance : {
init : null,
nullable : true
},
/**
* extopus Id aka recId ... to identify the chart we are talking about
*/
recId: {
init : null,
check : 'Integer',
nullable : true
},
/**
* chart defs (chart definitions)
*/
chartDef: {
init : [],
check : 'Array',
nullable : true
}
},
members : {
__timer : null,
__d3: null,
__chart: null,
__xAxisPainter: null,
__yAxisPainter: null,
__linePainter: null,
__areaPainter: null,
__xScale: null,
__yScale: null,
__zoomRectNode: null,
__xAxisNode: null,
__yAxisNode: null,
__dataNode: null,
__clipPath: null,
__chartWidth: null,
__fetchWait: null,
__fetchAgain: null,
__legendContainer: null,
__d3Obj: null,
__data: null,
getDataArea: function(){
if (this.__dataArea) return this.__dataArea;
return this.__dataArea =
this.__chart.insert("g",':first-child');
},
getXAxisPainter: function(){
if (this.__xAxisPainter) return this.__xAxisPainter;
var customTimeFormat = this.__d3.time.format.multi([
[".%L", function(d) { return d.getMilliseconds(); }],
[":%S", function(d) { return d.getSeconds(); }],
["%H:%M", function(d) { return d.getMinutes(); }],
["%H:%M", function(d) { return d.getHours(); }],
["%m-%d", function(d) { return d.getDay() && d.getDate() != 1; }],
["%Y-%m-%d", function(d) { return true }]
]);
this.__xAxisPainter = this.__d3.svg.axis()
.scale(this.getXScale())
.orient("bottom")
.tickPadding(6)
.tickFormat(customTimeFormat);
return this.__xAxisPainter;
},
getYAxisPainter: function(){
if (this.__yAxisPainter) return this.__yAxisPainter;
var si = ['p','n','y','m','','k','M','G','T','P'];
var d3 = this.__d3;
var commasFormatter = d3.format(",.1f");
this.__yAxisPainter = d3.svg.axis()
.scale(this.getYScale())
.orient("left")
.tickPadding(6)
.tickFormat(function(d){
if (d == 0){
return d;
}
var log = Math.log(d)/Math.LN10;
var idx = log < 0 ? Math.ceil(log/3) : Math.floor(log/3);
var factor = Math.pow(10,idx*3);
return commasFormatter(d/factor) + si[idx+4];
});
return this.__yAxisPainter;
},
getXScale: function(){
if (this.__xScale) return this.__xScale;
this.__xScale = this.__d3.time.scale();
this.__xScale.domain([new Date(new Date().getTime() - 24*3600*1000),new Date()]);
return this.__xScale;
},
getYScale: function(){
if (this.__yScale) return this.__yScale;
this.__yScale = this.__d3.scale.linear();
return this.__yScale;
},
getDataPainter: function(id){
switch(this.getChartDef()[id].cmd){
case 'LINE':
return this.getLinePainter();
break;
case 'AREA':
return this.getAreaPainter();
break;
default:
this.debug("invalid cmd");
break;
}
},
getLinePainter: function(){
if (this.__linePainter) return this.__linePainter;
var xScale = this.getXScale();
var yScale = this.getYScale();
this.__linePainter = this.__d3.svg.line()
.interpolate("step-before")
.x(function(d){ return xScale(d.date); })
.y(function(d){ return yScale(d.y); })
.defined(function(d){ return d.d });
return this.__linePainter;
},
getAreaPainter: function(){
if (this.__areaPainter) return this.__areaPainter;
var xScale = this.getXScale();
var yScale = this.getYScale();
this.__areaPainter = this.__d3.svg.area()
.interpolate("step-before")
.x(function(d){ return xScale(d.date); })
.y0(function(d){ return yScale(d.y0);})
.y1(function(d){ return yScale(d.y); })
.defined(function(d){ return d.d });
return this.__areaPainter;
},
getZoomRectNode: function(){
if (this.__zoomRectNode) return this.__zoomRectNode;
var that = this;
var zoomer = this.__zoomer = this.__d3.behavior.zoom()
.scaleExtent([0.0001, 1000])
.on("zoom", function (){that.redraw()});
zoomer.x(this.getXScale());
this.__zoomRectNode = this.__chart.append("rect")
.style({
cursor: 'move',
fill: 'none',
'pointer-events': 'all'
})
.call(zoomer);
return this.__zoomRectNode;
},
getXAxisNode: function(){
if (this.__xAxisNode) return this.__xAxisNode;
this.__xAxisNode = this.__chart.append("g")
.attr("class", "x axis");
return this.__xAxisNode;
},
getYAxisNode: function(){
if (this.__yAxisNode) return this.__yAxisNode;
this.__yAxisNode = this.__chart.append("g")
.attr("class", "y axis");
return this.__yAxisNode;
},
getClipPath: function(){
if (this.__clipPath) return this.__clipPath;
ID++;
this.__clipPathID = this.getInstance() + '-clipPath-' + ID;
this.__clipPath = this.__chart.append("clipPath")
.attr('id',this.__clipPathID)
.append("rect");
return this.__clipPath;
},
getDataNode: function(id){
if (this.__dataNode[id]) return this.__dataNode[id];
var chartDef = this.getChartDef()[id];
var dataArea = this.getDataArea();
switch(chartDef.cmd){
case 'LINE':
this.__dataNode[id] = dataArea.append("path")
.attr("clip-path", "url(#"+this.__clipPathID+")")
.attr('stroke', chartDef.color)
.attr('stroke-width', 2)
.attr("fill", "none")
.attr("shape-rendering", 'crispEdges');
break;
case 'AREA':
this.__dataNode[id] = dataArea.append("path")
.attr("clip-path", "url(#"+this.__clipPathID+")")
.attr("fill",chartDef.color);
break;
default:
this.debug("unsupported cmd:" + chartDef.cmd);
break;
}
return this.__dataNode[id];
},
resetChart: function(newValue, oldValue, propertyName){
if (this.__dataNode){
for (var i=0;i<this.__dataNode.length;i++){
this.__dataNode[i].remove();
}
}
this.__dataNode = [];
this.__data = null;
this.setupLegend();
},
setupLegend: function(){
var lc;
if (! this.__legendContainer ) {
var margin = this.self(arguments).MARGIN;
lc = this.__legendContainer = new qx.ui.core.Widget().set({
paddingLeft: margin.left
});
lc._setLayout(new qx.ui.layout.Flow(15,5));
this._add(lc);
}
else {
lc = this.__legendContainer;
lc._removeAll();
}
this.getChartDef().forEach(function(item){
var label = new qx.ui.core.Widget();
label._setLayout(new qx.ui.layout.HBox(5).set({alignY: 'middle'}));
var cu = qx.util.ColorUtil;
var borderColorHsb = cu.rgbToHsb(cu.hex6StringToRgb(item.color));
borderColorHsb[2] *= 0.9;
var borderColor = cu.rgbToHexString(cu.hsbToRgb(borderColorHsb));
var box = new qx.ui.core.Widget().set({
width: 12,
height: 12,
allowGrowX: false,
allowGrowY: false,
marginBottom: 6,
decorator: new qx.ui.decoration.Decorator().set({
color: [borderColor],
width: [1],
style: ['solid'],
backgroundColor: item.color
})
});
label._add(box);
var legend = new qx.ui.basic.Label().set({
value: item.legend
});
label._add(legend);
lc._add(label);
});
},
setSize: function(){
var el = this.__d3Obj.getContentElement().getDomElement();
if (!el) return;
// sync screen before we measure things
qx.html.Element.flush();
var margin = this.self(arguments).MARGIN;
var width = qx.bom.element.Dimension.getWidth(el) - margin.left - margin.right;
var height = qx.bom.element.Dimension.getHeight(el) - margin.top - margin.bottom;
this.__chartWidth = width;
var xScale = this.getXScale();
var yScale = this.getYScale();
xScale.range([0,width]);
yScale.range([height,0]);
this.getClipPath()
.attr("width",width)
.attr("height",height);
this.getYAxisPainter()
.tickSize(width)
.ticks(Math.round(height/50));
this.getXAxisPainter().tickSize(-height,0);
for (var i=0;i<this.getChartDef().length;i++){
this.getDataNode(i);
}
this.getYAxisNode()
.attr("transform", "translate(" + width + ",0)");
this.getXAxisNode()
.attr("transform", "translate(0," + height + ")");
this.getZoomRectNode()
.attr('width',width)
.attr('height',height);
this.__zoomer.size([width,height]);
this.redraw();
},
trackingRedraw: function(){
if (this.getTrackCurrentTime()){
var dates = this.getXScale().domain();
var interval = dates[1].getTime() - dates[0].getTime();
dates[1] = new Date();
dates[0] = new Date(dates[1].getTime() - interval);
this.getXScale().domain(dates);
this.redraw();
}
},
yScaleRedraw: function(){
var dates = this.getXScale().domain();
if (this.__data){
var maxValue = 0;
for(var i=0;i<this.getChartDef().length;i++){
if (!this.__data.data[i]) continue;
this.__data.data[i].forEach(function(item){
if (item.y > maxValue && item.date >= dates[0] && item.date <= dates[1]){
maxValue = item.y;
}
})
}
this.getYScale().domain([0,maxValue]).nice();
this.getYAxisNode().call(this.getYAxisPainter());
for(var i=0;i<this.getChartDef().length;i++){
var node = this.getDataNode(i);
if (node.data().length > 0){
node.attr("d",this.getDataPainter(i));
}
}
}
},
redraw: function(){
var dates = this.getXScale().domain();
var start = Math.round(dates[0].getTime()/1000);
var end = Math.round(dates[1].getTime()/1000);
var dataStep = Math.round((end-start)/ this.__chartWidth);
var extra = Math.round(end - start );
var d3 = this.__d3;
start -= extra;
end += extra;
this.yScaleRedraw();
this.getXAxisNode().call(this.getXAxisPainter());
if (this.__fetchWait){
this.__fetchAgain = 1;
return;
}
var existingData = this.dataSlicer(start,end,dataStep);
if (existingData.missingStart == existingData.missingEnd){
return;
}
var needChartDef = this.getChartDef().length == 0;
var rpc = ep.data.Server.getInstance();
var that = this;
this.__fetchWait = 1;
rpc.callAsyncSmart(function(ret){
var d3Data = that.__data = that.d3DataTransformer(ret,dataStep);
var maxValue = 0;
for(var i=0;i<ret.length;i++){
var dataNode = that.getDataNode(i);
if (existingData.prepend){
d3Data.data[i] = existingData.prepend[i].concat(d3Data.data[i],existingData.append[i]);
}
dataNode.data([d3Data.data[i]]);
}
that.yScaleRedraw();
that.__fetchWait = 0;
// if we skipped one, lets redraw again just to be sure we got it all
if (this.__fetchAgain == 1){
qx.event.Timer.once(that.redraw,that,0);
this.__fetchAgain = 0;
}
},
'visualize', this.getInstance(), {
recId : this.getRecId(),
step : dataStep,
start : existingData.missingStart,
end : existingData.missingEnd,
view : this.getView()
});
},
/* figure out what data range we are missing and keep the bits we already have */
dataSlicer: function(start,end,dataStep){
var oldData = this.__data;
var missingStart = start;
var missingEnd = end;
var prepend = [];
var append = [];
var keepData = oldData != null && typeof(oldData) == 'object' && Math.round(oldData.dataStep) == Math.round(dataStep);
if (!keepData) {
return { missingStart: missingStart, missingEnd: missingEnd};
}
var oldStart = oldData.data[0][0].date.getTime()/1000;
var oldEnd = oldData.data[0][oldData.data[0].length-1].date.getTime()/1000;
// prepend the existing data to the new data
var prependMode = oldStart <= start && oldEnd >= start;
var appendMode = oldEnd >= end && oldStart <= end;
for (var i=0;i<oldData.data.length;i++){
append[i] = [];
prepend[i] = [];
var len = oldData.data[i].length;
for (var ii=0;ii<len;ii++){
var item = oldData.data[i][ii];
var date = item.date.getTime()/1000;
if ( prependMode && date >= start ) {
prepend[i].push(item);
missingStart = date+dataStep;
}
if (appendMode && date <= end ){
append[i].push(item);
if ( date < missingEnd ) {
missingEnd = date-dataStep;
}
}
}
}
/* lets make sure don't trip over ourselves */
if (missingStart > missingEnd){
this.debug("missingStart:"+missingStart+" missingEnd:"+missingEnd);
missingEnd = missingStart;
}
return {
missingStart: missingStart,
missingEnd: missingEnd,
append: append,
prepend: prepend
}
},
d3DataTransformer: function(data,dataStep){
var d3 = this.__d3;
if (data == null || typeof(data) != 'object' || data[0] == null){
return null;
}
var minStep = 24*3600;
var minStart = (new Date).getTime()/1000;
data.forEach(function(d){
if (d.status != 'ok') return;
if (minStep == null || minStep > d.step){
minStep = d.step;
minStart = d.start;
}
});
var d3Data = [];
for (var i=0; i<data.length;i++){
d3Data[i] = [];
var chartDef = this.getChartDef()[i];
if (data[i].status != 'ok') {
data[i].values = [NaN];
data[i].step = 3600*24;
}
var stack = chartDef.stack;
var len = data[i].values.length;
var st = minStep/data[i].step;
for (var ii=0;ii*st < len ; ii++){
var y0 = 0;
if (stack && i > 0 && d3Data[i-1][ii] != null && typeof(d3Data[i-1][ii]) == 'object'){
y0 = d3Data[i-1][ii].y;
}
var yval = parseFloat(data[i].values[Math.round(ii*st)]);
d3Data[i][ii] = {
y: (isNaN(yval) ? 0 : yval) +y0,
y0: y0,
d: !isNaN(yval),
date: new Date((minStart+ii*minStep)*1000)
}
}
}
return {data:d3Data,dataStep: dataStep}
}
},
destruct : function() {
this.__timer.stop();
}
});