|
var LineChart = function () { |
|
var config = { |
|
width: null, |
|
height: null, |
|
margin: {top: 0, right: 0, bottom: 0, left: 0}, |
|
container: null, |
|
showTicksX: true, |
|
showTicksY: true, |
|
useBrush: false, |
|
suggestedXTicks: null, |
|
suggestedYTicks: null, |
|
timeFormat: d3.time.format('%H:%M:%S'), |
|
axisXHeight: 20, |
|
isMirror: false, |
|
dotSize: 4, |
|
suffix: '', |
|
resolution: 'minute', |
|
stripeCount: 4, |
|
tickFormatY: null, |
|
labelYOffset: 10, |
|
axisYStartsAtZero: true, |
|
showStripes: true, |
|
geometryType: 'line', |
|
showAxisX: true, |
|
showAxisY: true, |
|
showLabelsX: true, |
|
showLabelsY: true, |
|
progressiveRenderingRate: 300, |
|
brushThrottleWaitDuration: 150 |
|
}; |
|
var cache = { |
|
scaledData: [], |
|
bgSvg: null, |
|
axesSvg: null, |
|
geometryCanvas: null, |
|
resolutionConfigs: null, |
|
scaleX: null, |
|
scaleY: null, |
|
isMirror: false, |
|
axisXHeight: null |
|
}; |
|
var resolutionConfigs = { |
|
second: { dividerMillis: 60*1000, multiplier: 60, dateFunc: 'setSeconds', d3DateFunc: d3.time.minutes}, |
|
minute: { dividerMillis: 60*60*1000, multiplier: 60, dateFunc: 'setMinutes', d3DateFunc: d3.time.hours}, |
|
hour: { dividerMillis: 24*60*60*1000, multiplier: 24, dateFunc: 'setHours', d3DateFunc: d3.time.days} |
|
}; |
|
var brush = d3.svg.brush(), brushExtent; |
|
var data = []; |
|
var dispatch = d3.dispatch('brushChange', 'brushDragStart', 'brushDragMove', 'brushDragEnd', 'dotHover', 'dotMouseOut', 'dotClick', 'chartHover', 'chartOut', 'chartEnter'); |
|
var exports = {}; |
|
|
|
var queues = []; |
|
|
|
function init() { |
|
|
|
// Template |
|
///////////////////////////// |
|
var container = config.container; |
|
|
|
var template = d3.select('#line-chart-template').text(); |
|
var templateDOM = new DOMParser().parseFromString(template, 'text/html'); |
|
var doc = container.insertBefore(container.ownerDocument.importNode(templateDOM.body.children[0], true), container.firstChild); |
|
var root = d3.select(doc); |
|
cache.bgSvg = root.select('svg.bg'); |
|
cache.axesSvg = root.select('svg.axes'); |
|
cache.interactionSvg = root.select('svg.interaction'); |
|
cache.geometryCanvas = root.select('canvas.geometry'); |
|
root.selectAll('svg, canvas').style({position: 'absolute'}); |
|
|
|
// Scales |
|
///////////////////////////// |
|
cache.scaleX = d3.time.scale.utc(); |
|
cache.scaleY = d3.scale.linear(); |
|
|
|
// Hovering |
|
///////////////////////////// |
|
if(!config.useBrush) {setupHovering();} |
|
|
|
return this; |
|
} |
|
|
|
function setupHovering() { |
|
var hoverGroupSelection = cache.interactionSvg.select('.hover-group'); |
|
cache.interactionSvg |
|
.on('mousemove', function () { |
|
if(!data || data.length === 0) {return;} |
|
var mouseX = d3.mouse(cache.geometryCanvas.node())[0]; |
|
injectClosestPointsFromX(mouseX); |
|
hoverGroupSelection.style({visibility: 'visible'}); |
|
if (typeof data[0].closestY !== 'undefined') { |
|
exports.displayHoveredDots(); |
|
dispatch.chartHover(data); |
|
} |
|
else { |
|
exports.hideHoveredDots(); |
|
} |
|
exports.displayVerticalGuide(mouseX); |
|
}) |
|
.on('mouseenter', function () { |
|
dispatch.chartEnter(); |
|
}) |
|
.on('mouseout', function () { |
|
if(!cache.interactionSvg.node().contains(d3.event.toElement)) { |
|
hoverGroupSelection.style({visibility: 'hidden'}); |
|
dispatch.chartOut(); |
|
} |
|
}) |
|
.select('.hover-group'); |
|
} |
|
|
|
function injectClosestPointsFromX(fromPointX) { |
|
data.forEach(function (d) { |
|
if(typeof d.scaledX === 'undefined') {return;} |
|
var halfInterval = (d.scaledX[1] - d.scaledX[0]) * 0.5; |
|
var closestIndex = d3.bisect(d.scaledX, fromPointX - halfInterval); |
|
d.closestX = d.x[closestIndex]; |
|
d.closestY = d.y[closestIndex]; |
|
if (typeof d.closestY !== 'undefined'){ |
|
d.closestScaledX = d.scaledX[closestIndex]; |
|
} |
|
d.closestScaledY = d.scaledY[closestIndex]; |
|
if (cache.isMirror) { |
|
d.closestY2 = d.y2[closestIndex]; |
|
d.closestScaledY2 = d.scaledY2[closestIndex]; |
|
} |
|
}); |
|
return data; |
|
} |
|
|
|
function render() { |
|
prepareContainers(); |
|
|
|
if (data.length === 0) {return;} |
|
cache.isMirror = !!data[0].y2 || config.isMirror; |
|
|
|
if(config.showAxisY) {renderAxisY();} |
|
if(config.showAxisX) {renderAxisX();} |
|
if(config.showStripes) {showStripes();} |
|
if(config.geometryType === 'line') {renderLineGeometry();} |
|
else if(config.geometryType === 'bar') {renderBarGeometry();} |
|
setupBrush(); |
|
} |
|
|
|
function prepareContainers(){ |
|
|
|
// Calculate sizes |
|
///////////////////////////// |
|
cache.axisXHeight = (!config.showAxisX || !config.showLabelsX) ? 0 : config.axisXHeight; |
|
var containerBBox = config.container.getBoundingClientRect(); |
|
if (!config.width) {config.width = containerBBox.width;} |
|
if (!config.height) {config.height = containerBBox.height;} |
|
cache.chartW = config.width - config.margin.right - config.margin.left; |
|
cache.chartH = config.height - config.margin.top - config.margin.bottom - cache.axisXHeight; |
|
cache.scaleX.range([0, cache.chartW]); |
|
|
|
// Containers |
|
///////////////////////////// |
|
cache.bgSvg.style({height: config.height + 'px', width: config.width + 'px'}) |
|
.selectAll('.chart-group') |
|
.attr({transform: 'translate(' + [config.margin.left, config.margin.top] + ')'}); |
|
cache.axesSvg.style({height: config.height + 'px', width: config.width + 'px'}); |
|
cache.interactionSvg.style({height: config.height + 'px', width: config.width + 'px'}); |
|
|
|
// Background |
|
///////////////////////////// |
|
cache.bgSvg.select('.panel-bg').attr({width: cache.chartW, height: cache.chartH}); |
|
cache.bgSvg.select('.axis-x-bg').attr({width: cache.chartW, height: cache.axisXHeight, y: cache.chartH}); |
|
|
|
cache.resolutionConfig = resolutionConfigs[config.resolution]; |
|
|
|
} |
|
|
|
function renderAxisY(){ |
|
|
|
// Y axis |
|
///////////////////////////// |
|
if (cache.isMirror) {cache.scaleY.range([cache.chartH / 2, 0]);} |
|
else {cache.scaleY.range([cache.chartH, 0]);} |
|
|
|
var axisContainerY = cache.axesSvg.select('.axis-y1'); |
|
var bgYSelection = cache.bgSvg.select('.axis-y1'); |
|
var axisY = d3.svg.axis().scale(cache.scaleY).orient('left').tickSize(0); |
|
|
|
|
|
function renderAxisPart(axisContainerY, bgYSelection, axisY){ |
|
var ticksY = [].concat(config.suggestedYTicks); // make sure it's an array |
|
if (ticksY[0]) {axisY.ticks.apply(null, ticksY);} |
|
// labels |
|
if(config.showLabelsY){ |
|
axisContainerY.call(axisY); |
|
var texts = axisContainerY.selectAll('text').attr({transform: 'translate(' + config.labelYOffset + ',0)'}) |
|
.style({'text-anchor': 'start'}) |
|
.text(function(d){ return parseFloat(d); }) |
|
.filter(function(d, i){ return i === 0; }).text(function(){ return this.innerHTML + ' ' + config.suffix; }); |
|
if(config.tickFormatY) {texts.text(config.tickFormatY);} |
|
axisContainerY.selectAll('line').remove(); |
|
} |
|
// grid lines |
|
if (config.showTicksY) { |
|
bgYSelection.call(axisY); |
|
bgYSelection.selectAll('text').text(null); |
|
bgYSelection.selectAll('line').attr({x1: cache.chartW}) |
|
.classed('grid-line y', true); |
|
} |
|
} |
|
|
|
renderAxisPart(axisContainerY, bgYSelection, axisY); |
|
|
|
// Y2 axis |
|
///////////////////////////// |
|
if (cache.isMirror) { |
|
var axisContainerY2 = cache.axesSvg.select('.axis-y2'); |
|
var bgY2Selection = cache.bgSvg.select('.axis-y2'); |
|
cache.scaleY.range([cache.chartH / 2, cache.chartH]); |
|
|
|
renderAxisPart(axisContainerY2, bgY2Selection, axisY); |
|
} |
|
else {cache.axesSvg.select('.axis-y2').selectAll('*').remove();} |
|
|
|
// Axis background |
|
function findMaxLabelWidth(selection){ |
|
var labels = [], labelW; |
|
selection.each(function(){ |
|
labels.push(this.getBoundingClientRect().width); |
|
}); |
|
return d3.max(labels); |
|
} |
|
if (config.showTicksY) { |
|
var labels = cache.axesSvg.selectAll('.axis-y1 text, .axis-y2 text'); |
|
var maxLabelW = findMaxLabelWidth(labels); |
|
var axisYBg = cache.axesSvg.select('.axis-y-bg'); |
|
axisYBg.attr({width: maxLabelW + config.labelYOffset, height: cache.chartH, y: config.margin.top}); |
|
} |
|
|
|
} |
|
|
|
function renderAxisX(){ |
|
|
|
// X axis |
|
///////////////////////////// |
|
var axisXSelection = cache.axesSvg.select('.axis-x'); |
|
axisXSelection.attr({transform: 'translate(' + [0, cache.chartH] + ')'}); |
|
var axisX = d3.svg.axis().scale(cache.scaleX).orient('bottom').tickSize(cache.axisXHeight); |
|
|
|
// labels |
|
if(config.showLabelsX){ |
|
if (typeof config.timeFormat === 'function') { |
|
axisX.tickFormat(function (d) { return config.timeFormat(new Date(d)); }); |
|
} |
|
|
|
var ticksX = []; |
|
if(config.suggestedXTicks){ |
|
ticksX = [].concat(config.suggestedXTicks); // make sure it's an array |
|
} |
|
else if (config.resolution){ |
|
ticksX = [cache.resolutionConfig.d3DateFunc, 1]; |
|
} |
|
|
|
if (ticksX[0]) {axisX.ticks.apply(null, ticksX);} |
|
axisXSelection.call(axisX); |
|
axisXSelection.selectAll('text').attr({transform: function(){ |
|
return 'translate(3, -' + (cache.axisXHeight/2 + this.getBBox().height / 2) + ')'; |
|
}}); |
|
axisXSelection.selectAll('line').remove(); |
|
} |
|
|
|
// ticks |
|
if(config.showTicksX){ |
|
var bgXSelection = cache.bgSvg.select('.axis-x'); |
|
bgXSelection.attr({transform: 'translate(' + [0, cache.chartH] + ')'}); |
|
bgXSelection.call(axisX); |
|
bgXSelection.selectAll('text').text(null); |
|
bgXSelection.selectAll('line').attr({y1: -cache.chartH}) |
|
.classed('grid-line x', true); |
|
} |
|
|
|
} |
|
|
|
function showStripes(){ |
|
|
|
// Stripes |
|
///////////////////////////// |
|
if(data.length > 0 && data[0].x.length > 0 && cache.resolutionConfig){ |
|
var stripCountMultiplier = config.stripeCount / 2; |
|
var stripeCount = Math.round(Math.abs((data[0].x[data[0].x.length - 1].getTime() - data[0].x[0].getTime()) / |
|
(cache.resolutionConfig.dividerMillis))) * stripCountMultiplier; |
|
var discretizedDates = d3.range(stripeCount + 1).map(function(d, i){ |
|
return new Date(new Date(data[0].x[0])[cache.resolutionConfig.dateFunc](i * cache.resolutionConfig.multiplier/stripCountMultiplier)); |
|
}); |
|
var tickSpacing = cache.scaleX(discretizedDates[1]) - cache.scaleX(discretizedDates[0]); |
|
|
|
var stripesSelection = cache.bgSvg.select('.background').selectAll('rect.stripe').data(discretizedDates); |
|
stripesSelection.enter().append('rect').attr({'class': 'stripe'}); |
|
stripesSelection |
|
.attr({ |
|
x: function(d){ return cache.scaleX(d); }, |
|
y: 0, |
|
width: isNaN(tickSpacing)? 0 : tickSpacing/2, |
|
height: cache.chartH |
|
}) |
|
.style({stroke: 'none'}); |
|
stripesSelection.exit().remove(); |
|
} |
|
} |
|
|
|
function renderBarGeometry(){ |
|
|
|
// Bar geometry |
|
///////////////////////////// |
|
if (cache.isMirror) {cache.scaleY.range([cache.chartH / 2, 0]);} |
|
else {cache.scaleY.range([cache.chartH, 0]);} |
|
cache.geometryCanvas.attr({ |
|
width: cache.chartW, |
|
height: cache.chartH |
|
}) |
|
.style({ |
|
top: config.margin.top + 'px', |
|
left: config.margin.left + 'px' |
|
}); |
|
var ctx = cache.geometryCanvas.node().getContext('2d'); |
|
ctx.globalCompositeOperation = "source-over"; |
|
ctx.translate(0.5, 0.5); |
|
var i, j, lineData, scaledData; |
|
lineData = data[0]; |
|
ctx.fillStyle = lineData.color || 'silver'; |
|
var barW = cache.scaleX(lineData.x[1]) - cache.scaleX(lineData.x[0]); |
|
barW *= 0.5; |
|
for (j = 0; j < lineData.x.length; j++) { |
|
scaledData = {x: cache.scaleX(lineData.x[j]), y: cache.scaleY(lineData.y[j])}; |
|
ctx.fillRect(scaledData.x - barW/2, scaledData.y, barW, cache.chartH); |
|
} |
|
} |
|
|
|
function renderLineGeometry(){ |
|
|
|
// Line geometry |
|
///////////////////////////// |
|
// setTimeout(function(){ |
|
|
|
cache.geometryCanvas.attr({ |
|
width: cache.chartW, |
|
height: cache.chartH |
|
}) |
|
.style({ |
|
top: config.margin.top + 'px', |
|
left: config.margin.left + 'px' |
|
}); |
|
var ctx = cache.geometryCanvas.node().getContext('2d'); |
|
ctx.globalCompositeOperation = "source-over"; |
|
ctx.translate(0.5, 0.5); |
|
ctx.lineWidth = 1.5; |
|
|
|
if (cache.isMirror) {cache.scaleY.range([cache.chartH / 2, 0]);} |
|
else {cache.scaleY.range([cache.chartH, 0]);} |
|
|
|
var i, j, lineData, scaledData, lineDataZipped, queue; |
|
|
|
function renderLineSegment(scaledData){ |
|
ctx.strokeStyle = scaledData[4]; |
|
ctx.beginPath(); |
|
ctx.moveTo(scaledData[2], scaledData[3]); |
|
ctx.lineTo(scaledData[0], scaledData[1]); |
|
ctx.stroke(); |
|
} |
|
|
|
for (i = 0; i < data.length * 2; i++){ |
|
queues.push(renderQueue(renderLineSegment).rate(config.progressiveRenderingRate)); |
|
} |
|
|
|
for (i = 0; i < data.length; i++) { |
|
lineData = data[i]; |
|
lineData.scaledX = []; |
|
lineData.scaledY = []; |
|
|
|
for (j = 0; j < lineData.x.length; j++) { |
|
scaledData = {x: cache.scaleX(lineData.x[j]), y: cache.scaleY(lineData.y[j])}; |
|
lineData.scaledX.push(scaledData.x); |
|
lineData.scaledY.push(scaledData.y); |
|
} |
|
lineDataZipped = d3.zip(lineData.scaledX, lineData.scaledY, lineData.scaledX.slice(1), lineData.scaledY.slice(1)); |
|
lineDataZipped.forEach(function(d, i){ d.push(lineData.color); }); |
|
lineDataZipped.reverse(); |
|
|
|
queues[i](lineDataZipped); |
|
} |
|
|
|
|
|
if (cache.isMirror) { |
|
cache.scaleY.range([cache.chartH / 2, cache.chartH]); |
|
for (i = 0; i < data.length; i++) { |
|
lineData = data[i]; |
|
lineData.scaledY2 = []; |
|
|
|
for (j = 0; j < lineData.x.length; j++) { |
|
scaledData = {x: lineData.scaledX[j], y: cache.scaleY(lineData.y2[j])}; |
|
lineData.scaledY2.push(scaledData.y); |
|
} |
|
lineDataZipped = d3.zip(lineData.scaledX, lineData.scaledY2, lineData.scaledX.slice(1), lineData.scaledY2.slice(1)); |
|
lineDataZipped.forEach(function(d, i){ d.push(lineData.color); }); |
|
lineDataZipped.reverse(); |
|
|
|
ctx.strokeStyle = lineData.color || 'silver'; |
|
ctx.beginPath(); |
|
queues[i + data.length](lineDataZipped); |
|
ctx.stroke(); |
|
} |
|
|
|
} |
|
|
|
// }, 0); |
|
|
|
} |
|
|
|
function setupBrush(){ |
|
|
|
// Brush |
|
///////////////////////////// |
|
var brushChange = chartUtils.throttle(dispatch.brushChange, config.brushThrottleWaitDuration, {trailing: false}); |
|
var brushDragMove = chartUtils.throttle(dispatch.brushDragMove, config.brushThrottleWaitDuration, {trailing: false}); |
|
if (config.useBrush && data.length > 0 && data[0].x.length) { |
|
brush.x(cache.scaleX) |
|
.extent(brushExtent || d3.extent(data[0].x)) |
|
.on("brush", function () { |
|
brushChange(brushExtent); |
|
if (!d3.event.sourceEvent) {return;} // only on manual brush resize |
|
brushExtent = brush.extent(); |
|
brushDragMove(brushExtent); |
|
}) |
|
.on("brushstart", function(){ dispatch.brushDragStart(); }) |
|
.on("brushend", function(){ dispatch.brushDragEnd(); }); |
|
cache.interactionSvg.select('.brush-group') |
|
.call(brush) |
|
.selectAll('rect') |
|
.attr({height: cache.chartH + cache.axisXHeight, y: 0}); |
|
} |
|
} |
|
|
|
function prepareScales (_extentX, _extentY) { |
|
if (_extentX) {cache.scaleX.domain(_extentX);} |
|
if (_extentY) { |
|
var extent = config.axisYStartsAtZero ? [0, _extentY[1]] : _extentY ; |
|
cache.scaleY.domain(extent); |
|
} |
|
} |
|
|
|
exports.setConfig = function (_newConfig) { |
|
chartUtils.override(_newConfig, config); |
|
if (!cache.geometryCanvas) {init();} |
|
return this; |
|
}; |
|
|
|
exports.setZoom = function (_newExtent) { |
|
// data[0].filter(function(d, i){ return d.x.getTime() > _newExtent[0].getTime() && d.x.getTime() > _newExtent[1].getTime(); }); |
|
prepareScales(_newExtent); |
|
render(); |
|
return this; |
|
}; |
|
|
|
exports.displayHoveredDots = function () { |
|
var hoverData = data.map(function (d, i) { |
|
return { |
|
x: d.closestScaledX, |
|
y: d.closestScaledY + config.margin.top, |
|
originalData: d, |
|
idx: i |
|
}; |
|
}); |
|
if (cache.isMirror) { |
|
var hoverData2 = data.map(function (d, i) { |
|
return { |
|
x: d.closestScaledX, |
|
y: d.closestScaledY2 + config.margin.top, |
|
originalData: d, |
|
idx: i |
|
}; |
|
}); |
|
hoverData = hoverData.concat(hoverData2); |
|
} |
|
var hoveredDotsSelection = cache.interactionSvg.select('.hover-group').selectAll('circle.hovered-dots') |
|
.data(hoverData); |
|
hoveredDotsSelection.enter().append('circle').attr({'class': 'hovered-dots'}) |
|
.on('mousemove', onDotsMouseEnter) |
|
.on('mouseout', function (d) { dispatch.dotMouseOut(d.originalData); }) |
|
.on('click', function (d) { dispatch.dotClick(d.originalData); }); |
|
hoveredDotsSelection.filter(function(d, i){ return !isNaN(d.y); }) |
|
.style({ |
|
fill: function (d) { return d.originalData.color || 'silver'; } |
|
}) |
|
.attr({ |
|
r: config.dotSize, |
|
cx: function (d) { return d.x; }, |
|
cy: function (d) { return d.y; } |
|
}); |
|
hoveredDotsSelection.exit().remove(); |
|
return this; |
|
}; |
|
|
|
exports.hideHoveredDots = function () { |
|
cache.interactionSvg.select('.hover-group').selectAll('circle.hovered-dots').remove(); |
|
}; |
|
|
|
function onDotsMouseEnter(d) { |
|
var dotPos = {x: d.x, y: d.y}; |
|
dispatch.dotHover(dotPos, d.originalData); |
|
} |
|
|
|
exports.displayVerticalGuide = function (mouseX) { |
|
cache.interactionSvg.select('line.hover-guide-x') |
|
.attr({x1: mouseX, x2: mouseX, y1: 0, y2: cache.chartH}) |
|
.style({'pointer-events': 'none'}); |
|
return this; |
|
}; |
|
|
|
exports.setBrushSelection = function (_brushSelectionExtent) { |
|
if (brush) { |
|
brushExtent = _brushSelectionExtent.map(function (d) { return new Date(d); }); |
|
render(); |
|
// dispatch.brushChange(brushExtent); |
|
} |
|
return this; |
|
}; |
|
|
|
exports.brushIsFarRight = function () { |
|
if(brush.extent()) {return brush.extent()[1].getTime() === cache.scaleX.domain()[1].getTime();} |
|
}; |
|
|
|
exports.getBrushExtent = function () { |
|
if(brush.extent()) {return brush.extent();} |
|
}; |
|
exports.refresh = function () { |
|
render(); |
|
return this; |
|
}; |
|
|
|
exports.setData = function (_newData) { |
|
data = chartUtils.deepExtend([], _newData); |
|
|
|
function computeExtent(_data, _axis) { |
|
var max = Number.MIN_VALUE, min = Number.MAX_VALUE, i, j, len, len2, datum; |
|
for (i = 0, len = _data.length; i < len; i++) { |
|
for (j = 0, len2 = _data[i].x.length; j < len2; j++) { |
|
datum = _data[i][_axis][j]; |
|
if (datum > max) {max = datum;} |
|
if (datum < min) {min = datum;} |
|
} |
|
} |
|
return [min, max]; |
|
} |
|
|
|
var extentX = computeExtent(data, 'x'); |
|
var extentY = computeExtent(data, 'y'); |
|
if(!!data[0] && !!data[0].y2) { |
|
var extentY2 = computeExtent(data, 'y2'); |
|
extentY = [Math.min(extentY[0], extentY2[0]), Math.max(extentY[1], extentY2[1])]; |
|
} |
|
|
|
prepareScales(extentX, extentY); |
|
render(); |
|
return this; |
|
}; |
|
|
|
exports.getSvgNode = function () { |
|
if(cache.bgSvg) { |
|
return cache.bgSvg.node(); |
|
} |
|
return null; |
|
}; |
|
|
|
exports.getCanvasNode = function () { |
|
if(cache.geometryCanvas) { |
|
return cache.geometryCanvas.node(); |
|
} |
|
return null; |
|
}; |
|
|
|
d3.rebind(exports, dispatch, "on"); |
|
|
|
return exports; |
|
}; |