663 lines
22 KiB
JavaScript
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();
|
|
}
|
|
});
|