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; };