/** * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Create and draw a new line-graph. * * Arguments: * containerId => id of container to insert SVG into [REQUIRED] * marginTop => Number of pixels for top margin. [OPTIONAL => Default: 20] * marginRight => Number of pixels for right margin. [OPTIONAL => Default: 20] * marginBottom => Number of pixels for bottom margin. [OPTIONAL => Default: 35] * marginLeft => Number of pixels for left margin. [OPTIONAL => Default: 90] * data => a dictionary containing the following keys [REQUIRED] * values => The data array of arrays to graph. [REQUIRED] * start => The start time in milliseconds since epoch of the data. [REQUIRED] * end => The end time in milliseconds since epoch of the data. [REQUIRED] * step => The time in milliseconds between each data value. [REQUIRED] * names => The metric name for each array of data. [REQUIRED] * displayNames => Display name for each metric. [OPTIONAL => Default: same as 'names' argument] * Example: ['MetricA', 'MetricB'] * axis => Which axis (left/right) to put each metric on. [OPTIONAL => Default: Display all values on single axis] * Example: ['left', 'right', 'right'] to display first metric on left axis, next two on right axis. * colors => What color to use for each metric. [OPTIONAL => Default: black] * Example: ['blue', 'red'] to display first metric in blue and second in red. * scale => What scale to display the graph with. [OPTIONAL => Default: linear] * Possible Values: linear, pow, log * rounding => How many decimal points to round each metric to. [OPTIONAL => Default: Numbers are rounded to whole numbers (0 decimals)] * Example: [2, 1] to display first metric with 2 decimals and second metric with 1. * numAxisLabelsPowerScale => Hint for how many labels should be displayed for the Y-axis in Power scale. [OPTIONAL => Default: 6] * numAxisLabelsLinearScale => Hint for how many labels should be displayed for the Y-axis in Linear scale. [OPTIONAL => Default: 6] * * Events (fired from container): * LineGraph:dataModification => whenever data is changed * LineGraph:configModification => whenever config is changed */ function LineGraph(argsMap) { /* *************************************************************** */ /* public methods */ /* *************************************************************** */ var self = this; /** * This allows appending new data points to the end of the lines and sliding them within the time window: * - x-axis will slide to new range * - new data will be added to the end of the lines * - equivalent number of data points will be removed from beginning of lines * - lines will be transitioned through horizontoal slide to show progression over time */ this.slideData = function(newData) { // validate data var tempData = processDataMap(newData); debug("Existing startTime: " + data.startTime + " endTime: " + data.endTime); debug("New startTime: " + tempData.startTime + " endTime: " + tempData.endTime); // validate step is the same on each if(tempData.step != newData.step) { throw new Error("The step size on appended data must be the same as the existing data => " + data.step + " != " + tempData.step); } if(tempData.values[0].length == 0) { throw new Error("There is no data to append."); } var numSteps = tempData.values[0].length; console.log("slide => add num new values: " + numSteps); console.log(tempData.values[0]) tempData.values.forEach(function(dataArrays, i) { var existingDataArrayForIndex = data.values[i]; dataArrays.forEach(function(v) { console.log("slide => add new value: " + v); // push each new value onto the existing data array existingDataArrayForIndex.push(v); // shift the front value off to compensate for what we just added existingDataArrayForIndex.shift(); }) }) // shift domain by number of data elements we just added // == numElements * step data.startTime = new Date(data.startTime.getTime() + (data.step * numSteps)); data.endTime = tempData.endTime; debug("Updated startTime: " + data.startTime + " endTime: " + data.endTime); /* * The following transition implementation was learned from examples at http://bost.ocks.org/mike/path/ * In particular, view the HTML source for the last example on the page inside the tick() function. */ // redraw each of the lines // Transitions are turned off on this since the small steps we're taking // don't actually look good when animated and it uses unnecessary CPU // The quick-steps look cleaner, and keep the axis/line in-sync instead of jittering redrawAxes(false); redrawLines(false); // slide the lines left graph.selectAll("g .lines path") .attr("transform", "translate(-" + x(numSteps*data.step) + ")"); handleDataUpdate(); // fire an event that data was updated $(container).trigger('LineGraph:dataModification') } /** * This does a full refresh of the data: * - x-axis will slide to new range * - lines will change in place */ this.updateData = function(newData) { // data is being replaced, not appended so we re-assign 'data' data = processDataMap(newData); // and then we rebind data.values to the lines graph.selectAll("g .lines path").data(data.values) // redraw (with transition) redrawAxes(true); // transition is 'false' for lines because the transition is really weird when the data significantly changes // such as going from 700 points to 150 to 400 // and because of that we rebind the data anyways which doesn't work with transitions very well at all redrawLines(false); handleDataUpdate(); // fire an event that data was updated $(container).trigger('LineGraph:dataModification') } this.switchToPowerScale = function() { yScale = 'pow'; redrawAxes(true); redrawLines(true); // fire an event that config was changed $(container).trigger('LineGraph:configModification') } this.switchToLogScale = function() { yScale = 'log'; redrawAxes(true); redrawLines(true); // fire an event that config was changed $(container).trigger('LineGraph:configModification') } this.switchToLinearScale = function() { yScale = 'linear'; redrawAxes(true); redrawLines(true); // fire an event that config was changed $(container).trigger('LineGraph:configModification') } /** * Return the current scale value: pow, log or linear */ this.getScale = function() { return yScale; } /* *************************************************************** */ /* private variables */ /* *************************************************************** */ // the div we insert the graph into var containerId; var container; // functions we use to display and interact with the graphs and lines var graph, x, yLeft, yRight, xAxis, yAxisLeft, yAxisRight, yAxisLeftDomainStart, linesGroup, linesGroupText, lines, lineFunction, lineFunctionSeriesIndex = -1; var yScale = 'linear'; // can be pow, log, linear var scales = [['linear','Linear'], ['pow','Power'], ['log','Log']]; var hoverContainer, hoverLine, hoverLineXOffset, hoverLineYOffset, hoverLineGroup; var legendFontSize = 12; // we can resize dynamically to make fit so we remember it here // instance storage of data to be displayed var data; // define dimensions of graph var margin = [-1, -1, -1, -1]; // margins (top, right, bottom, left) var w, h; // width & height var transitionDuration = 300; var formatNumber = d3.format(",.0f") // for formatting integers var tickFormatForLogScale = function(d) { return formatNumber(d) }; // used to track if the user is interacting via mouse/finger instead of trying to determine // by analyzing various element class names to see if they are visible or not var userCurrentlyInteracting = false; var currentUserPositionX = -1; /* *************************************************************** */ /* initialization and validation */ /* *************************************************************** */ var _init = function() { // required variables that we'll throw an error on if we don't find containerId = getRequiredVar(argsMap, 'containerId'); container = document.querySelector('#' + containerId); // margins with defaults (do this before processDataMap since it can modify the margins) margin[0] = getOptionalVar(argsMap, 'marginTop', 20) // marginTop allows fitting the actions, date and top of axis labels margin[1] = getOptionalVar(argsMap, 'marginRight', 20) margin[2] = getOptionalVar(argsMap, 'marginBottom', 35) // marginBottom allows fitting the legend along the bottom margin[3] = getOptionalVar(argsMap, 'marginLeft', 90) // marginLeft allows fitting the axis labels // assign instance vars from dataMap data = processDataMap(getRequiredVar(argsMap, 'data')); /* set the default scale */ yScale = data.scale; // do this after processing margins and executing processDataMap above initDimensions(); createGraph() //debug("Initialization successful for container: " + containerId) // window resize listener // de-dupe logic from http://stackoverflow.com/questions/667426/javascript-resize-event-firing-multiple-times-while-dragging-the-resize-handle/668185#668185 var TO = false; $(window).resize(function(){ if(TO !== false) clearTimeout(TO); TO = setTimeout(handleWindowResizeEvent, 200); // time in miliseconds }); } /* *************************************************************** */ /* private methods */ /* *************************************************************** */ /* * Return a validated data map * * Expects a map like this: * {"start": 1335035400000, "end": 1335294600000, "step": 300000, "values": [[28,22,45,65,34], [45,23,23,45,65]]} */ var processDataMap = function(dataMap) { // assign data values to plot over time var dataValues = getRequiredVar(dataMap, 'values', "The data object must contain a 'values' value with a data array.") var startTime = new Date(getRequiredVar(dataMap, 'start', "The data object must contain a 'start' value with the start time in milliseconds since epoch.")) var endTime = new Date(getRequiredVar(dataMap, 'end', "The data object must contain an 'end' value with the end time in milliseconds since epoch.")) var step = getRequiredVar(dataMap, 'step', "The data object must contain a 'step' value with the time in milliseconds between each data value.") var names = getRequiredVar(dataMap, 'names', "The data object must contain a 'names' array with the same length as 'values' with a name for each data value array.") var displayNames = getOptionalVar(dataMap, 'displayNames', names); var numAxisLabelsPowerScale = getOptionalVar(dataMap, 'numAxisLabelsPowerScale', 6); var numAxisLabelsLinearScale = getOptionalVar(dataMap, 'numAxisLabelsLinearScale', 6); var axis = getOptionalVar(dataMap, 'axis', []); // default axis values if(axis.length == 0) { displayNames.forEach(function (v, i) { // set the default to left axis axis[i] = "left"; }) } else { var hasRightAxis = false; axis.forEach(function(v) { if(v == 'right') { hasRightAxis = true; } }) if(hasRightAxis) { // add space to right margin margin[1] = margin[1] + 50; } } var colors = getOptionalVar(dataMap, 'colors', []); // default colors values if(colors.length == 0) { displayNames.forEach(function (v, i) { // set the default colors[i] = "black"; }) } var maxValues = []; var rounding = getOptionalVar(dataMap, 'rounding', []); // default rounding values if(rounding.length == 0) { displayNames.forEach(function (v, i) { // set the default to 0 decimals rounding[i] = 0; }) } /* copy the dataValues array, do NOT assign the reference otherwise we modify the original source when we shift/push data */ var newDataValues = []; dataValues.forEach(function (v, i) { newDataValues[i] = v.slice(0); maxValues[i] = d3.max(newDataValues[i]) }) return { "values" : newDataValues, "startTime" : startTime, "endTime" : endTime, "step" : step, "names" : names, "displayNames": displayNames, "axis" : axis, "colors": colors, "scale" : getOptionalVar(dataMap, 'scale', yScale), "maxValues" : maxValues, "rounding" : rounding, "numAxisLabelsLinearScale": numAxisLabelsLinearScale, "numAxisLabelsPowerScale": numAxisLabelsPowerScale } } var redrawAxes = function(withTransition) { initY(); initX(); if(withTransition) { // slide x-axis to updated location graph.selectAll("g .x.axis").transition() .duration(transitionDuration) .ease("linear") .call(xAxis) // slide y-axis to updated location graph.selectAll("g .y.axis.left").transition() .duration(transitionDuration) .ease("linear") .call(yAxisLeft) if(yAxisRight != undefined) { // slide y-axis to updated location graph.selectAll("g .y.axis.right").transition() .duration(transitionDuration) .ease("linear") .call(yAxisRight) } } else { // slide x-axis to updated location graph.selectAll("g .x.axis") .call(xAxis) // slide y-axis to updated location graph.selectAll("g .y.axis.left") .call(yAxisLeft) if(yAxisRight != undefined) { // slide y-axis to updated location graph.selectAll("g .y.axis.right") .call(yAxisRight) } } } var redrawLines = function(withTransition) { /** * This is a hack to deal with the left/right axis. * * See createGraph for a larger comment explaining this. * * Yes, it's ugly. If you can suggest a better solution please do. */ lineFunctionSeriesIndex =-1; // redraw lines if(withTransition) { graph.selectAll("g .lines path") .transition() .duration(transitionDuration) .ease("linear") .attr("d", lineFunction) .attr("transform", null); } else { graph.selectAll("g .lines path") .attr("d", lineFunction) .attr("transform", null); } } /* * Allow re-initializing the y function at any time. * - it will properly determine what scale is being used based on last user choice (via public switchScale methods) */ var initY = function() { initYleft(); initYright(); } var initYleft = function() { var maxYscaleLeft = calculateMaxY(data, 'left') //debug("initY => maxYscale: " + maxYscaleLeft); var numAxisLabels = 6; if(yScale == 'pow') { yLeft = d3.scale.pow().exponent(0.3).domain([0, maxYscaleLeft]).range([h, 0]).nice(); numAxisLabels = data.numAxisLabelsPowerScale; } else if(yScale == 'log') { // we can't have 0 so will represent 0 with a very small number // 0.1 works to represent 0, 0.01 breaks the tickFormatter yLeft = d3.scale.log().domain([0.1, maxYscaleLeft]).range([h, 0]).nice(); } else if(yScale == 'linear') { yLeft = d3.scale.linear().domain([0, maxYscaleLeft]).range([h, 0]).nice(); numAxisLabels = data.numAxisLabelsLinearScale; } yAxisLeft = d3.svg.axis().scale(yLeft).ticks(numAxisLabels, tickFormatForLogScale).orient("left"); } var initYright = function() { var maxYscaleRight = calculateMaxY(data, 'right') // only create the right axis if it has values if(maxYscaleRight != undefined) { //debug("initY => maxYscale: " + maxYscaleRight); var numAxisLabels = 6; if(yScale == 'pow') { yRight = d3.scale.pow().exponent(0.3).domain([0, maxYscaleRight]).range([h, 0]).nice(); numAxisLabels = data.numAxisLabelsPowerScale; } else if(yScale == 'log') { // we can't have 0 so will represent 0 with a very small number // 0.1 works to represent 0, 0.01 breaks the tickFormatter yRight = d3.scale.log().domain([0.1, maxYscaleRight]).range([h, 0]).nice(); } else if(yScale == 'linear') { yRight = d3.scale.linear().domain([0, maxYscaleRight]).range([h, 0]).nice(); numAxisLabels = data.numAxisLabelsLinearScale; } yAxisRight = d3.svg.axis().scale(yRight).ticks(numAxisLabels, tickFormatForLogScale).orient("right"); } } /* * Whenever we add/update data we want to re-calculate if the max Y scale has changed */ var calculateMaxY = function(data, whichAxis) { // Y scale will fit values from 0-10 within pixels h-0 (Note the inverted domain for the y-scale: bigger is up!) // we get the max of the max of values for the given index since we expect an array of arrays // we can shortcut to using data.maxValues since we've already calculated the max of each series in processDataMap var maxValuesForAxis = []; data.maxValues.forEach(function(v, i) { if(data.axis[i] == whichAxis) { maxValuesForAxis.push(v); } }) // we now have the max values for the axis we're interested in so get the max of them return d3.max(maxValuesForAxis); } /* * Allow re-initializing the x function at any time. */ var initX = function() { // X scale starts at epoch time 1335035400000, ends at 1335294600000 with 300s increments x = d3.time.scale().domain([data.startTime, data.endTime]).range([0, w]); // create yAxis (with ticks) xAxis = d3.svg.axis().scale(x).tickSize(-h).tickSubdivide(1); // without ticks //xAxis = d3.svg.axis().scale(x); } /** * Creates the SVG elements and displays the line graph. * * Expects to be called once during instance initialization. */ var createGraph = function() { // Add an SVG element with the desired dimensions and margin. graph = d3.select("#" + containerId).append("svg:svg") .attr("class", "line-graph") .attr("width", w + margin[1] + margin[3]) .attr("height", h + margin[0] + margin[2]) .append("svg:g") .attr("transform", "translate(" + margin[3] + "," + margin[0] + ")"); initX() // Add the x-axis. graph.append("svg:g") .attr("class", "x axis") .attr("transform", "translate(0," + h + ")") .call(xAxis); // y is all done in initY because we need to re-assign vars quite often to change scales initY(); // Add the y-axis to the left graph.append("svg:g") .attr("class", "y axis left") .attr("transform", "translate(-10,0)") .call(yAxisLeft); if(yAxisRight != undefined) { // Add the y-axis to the right if we need one graph.append("svg:g") .attr("class", "y axis right") .attr("transform", "translate(" + (w+10) + ",0)") .call(yAxisRight); } // create line function used to plot our data lineFunction = d3.svg.line() // assign the X function to plot our line as we wish .x(function(d,i) { /* * Our x value is defined by time and since our data doesn't have per-metric timestamps * we calculate time as (startTime + the step between metrics * the index) * * We also reach out to the persisted 'data' object for time * since the 'd' passed in here is one of the children, not the parent object */ var _x = x(data.startTime.getTime() + (data.step*i)); // verbose logging to show what's actually being done //debug("Line X => index: " + i + " scale: " + _x) // return the X coordinate where we want to plot this datapoint return _x; }) .y(function(d, i) { if(yScale == 'log' && d < 0.1) { // log scale can't have 0s, so we set it to the smallest value we set on y d = 0.1; } /** * This is a hack that relies on: * a) the single-threaded nature of javascript that this will not be interleaved * b) that lineFunction will always be passed the data[] for all lines in the same way each time * * We then use an external variable to track each time we move from one series to the next * so that we can have its seriesIndex to access information in the data[] object, particularly * so we can determine what axis this data is supposed to be on. * * I didn't want to split the line function into left and right lineFunctions as that would really * complicate the data binding. * * Also ... I can't figure out nested functions to keep it scoped so I had to put lineFunctionSeriesIndex * as a variable in the same scope as lineFunction. Ugly. And worse ... reset it in redrawAxes. * * Anyone reading this who knows a better solution please let me know. */ if(i == 0) { lineFunctionSeriesIndex++; } var axis = data.axis[lineFunctionSeriesIndex]; var _y; if(axis == 'right') { _y = yRight(d); } else { _y = yLeft(d); } // verbose logging to show what's actually being done //debug("Line Y => data: " + d + " scale: " + _y) // return the Y coordinate where we want to plot this datapoint return _y; }) .defined(function(d) { // handle missing data gracefully // feature added in https://github.com/mbostock/d3/pull/594 return d >= 0; }); // append a group to contain all lines lines = graph.append("svg:g") .attr("class", "lines") .selectAll("path") .data(data.values); // bind the array of arrays // persist this reference so we don't do the selector every mouse event hoverContainer = container.querySelector('g .lines'); $(container).mouseleave(function(event) { handleMouseOutGraph(event); }) $(container).mousemove(function(event) { handleMouseOverGraph(event); }) // add a line group for each array of values (it will iterate the array of arrays bound to the data function above) linesGroup = lines.enter().append("g") .attr("class", function(d, i) { return "line_group series_" + i; }); // add path (the actual line) to line group linesGroup.append("path") .attr("class", function(d, i) { //debug("Appending line [" + containerId + "]: " + i) return "line series_" + i; }) .attr("fill", "none") .attr("stroke", function(d, i) { return data.colors[i]; }) .attr("d", lineFunction) // use the 'lineFunction' to create the data points in the correct x,y axis .on('mouseover', function(d, i) { handleMouseOverLine(d, i); }); // add line label to line group linesGroupText = linesGroup.append("svg:text"); linesGroupText.attr("class", function(d, i) { //debug("Appending line [" + containerId + "]: " + i) return "line_label series_" + i; }) .text(function(d, i) { return ""; }); // add a 'hover' line that we'll show as a user moves their mouse (or finger) // so we can use it to show detailed values of each line hoverLineGroup = graph.append("svg:g") .attr("class", "hover-line"); // add the line to the group hoverLine = hoverLineGroup .append("svg:line") .attr("x1", 10).attr("x2", 10) // vertical line so same value on each .attr("y1", 0).attr("y2", h); // top to bottom // hide it by default hoverLine.classed("hide", true); createScaleButtons(); createDateLabel(); createLegend(); setValueLabelsToLatest(); } /** * Create a legend that displays the name of each line with appropriate color coding * and allows for showing the current value when doing a mouseOver */ var createLegend = function() { // append a group to contain all lines var legendLabelGroup = graph.append("svg:g") .attr("class", "legend-group") .selectAll("g") .data(data.displayNames) .enter().append("g") .attr("class", "legend-labels"); legendLabelGroup.append("svg:text") .attr("class", "legend name") .text(function(d, i) { return d; }) .attr("font-size", legendFontSize) .attr("fill", function(d, i) { // return the color for this row return data.colors[i]; }) .attr("y", function(d, i) { return h+28; }) // put in placeholders with 0 width that we'll populate and resize dynamically legendLabelGroup.append("svg:text") .attr("class", "legend value") .attr("font-size", legendFontSize) .attr("fill", function(d, i) { return data.colors[i]; }) .attr("y", function(d, i) { return h+28; }) // x values are not defined here since those get dynamically calculated when data is set in displayValueLabelsForPositionX() } var redrawLegendPosition = function(animate) { var legendText = graph.selectAll('g.legend-group text'); if(animate) { legendText.transition() .duration(transitionDuration) .ease("linear") .attr("y", function(d, i) { return h+28; }); } else { legendText.attr("y", function(d, i) { return h+28; }); } } /** * Create scale buttons for switching the y-axis */ var createScaleButtons = function() { var cumulativeWidth = 0; // append a group to contain all lines var buttonGroup = graph.append("svg:g") .attr("class", "scale-button-group") .selectAll("g") .data(scales) .enter().append("g") .attr("class", "scale-buttons") .append("svg:text") .attr("class", "scale-button") .text(function(d, i) { return d[1]; }) .attr("font-size", "12") // this must be before "x" which dynamically determines width .attr("fill", function(d) { if(d[0] == yScale) { return "black"; } else { return "blue"; } }) .classed("selected", function(d) { if(d[0] == yScale) { return true; } else { return false; } }) .attr("x", function(d, i) { // return it at the width of previous labels (where the last one ends) var returnX = cumulativeWidth; // increment cumulative to include this one cumulativeWidth += this.getComputedTextLength()+5; return returnX; }) .attr("y", -4) .on('click', function(d, i) { handleMouseClickScaleButton(this, d, i); }); } var handleMouseClickScaleButton = function(button, buttonData, index) { if(index == 0) { self.switchToLinearScale(); } else if(index == 1) { self.switchToPowerScale(); } else if(index == 2) { self.switchToLogScale(); } // change text decoration graph.selectAll('.scale-button') .attr("fill", function(d) { if(d[0] == yScale) { return "black"; } else { return "blue"; } }) .classed("selected", function(d) { if(d[0] == yScale) { return true; } else { return false; } }) } /** * Create a data label */ var createDateLabel = function() { var date = new Date(); // placeholder just so we can calculate a valid width // create the date label to the left of the scaleButtons group var buttonGroup = graph.append("svg:g") .attr("class", "date-label-group") .append("svg:text") .attr("class", "date-label") .attr("text-anchor", "end") // set at end so we can position at far right edge and add text from right to left .attr("font-size", "10") .attr("y", -4) .attr("x", w) .text(date.toDateString() + " " + date.toLocaleTimeString()) } /** * Called when a user mouses over a line. */ var handleMouseOverLine = function(lineData, index) { //debug("MouseOver line [" + containerId + "] => " + index) // user is interacting userCurrentlyInteracting = true; } /** * Called when a user mouses over the graph. */ var handleMouseOverGraph = function(event) { var mouseX = event.pageX-hoverLineXOffset; var mouseY = event.pageY-hoverLineYOffset; //debug("MouseOver graph [" + containerId + "] => x: " + mouseX + " y: " + mouseY + " height: " + h + " event.clientY: " + event.clientY + " offsetY: " + event.offsetY + " pageY: " + event.pageY + " hoverLineYOffset: " + hoverLineYOffset) if(mouseX >= 0 && mouseX <= w && mouseY >= 0 && mouseY <= h) { // show the hover line hoverLine.classed("hide", false); // set position of hoverLine hoverLine.attr("x1", mouseX).attr("x2", mouseX) displayValueLabelsForPositionX(mouseX) // user is interacting userCurrentlyInteracting = true; currentUserPositionX = mouseX; } else { // proactively act as if we've left the area since we're out of the bounds we want handleMouseOutGraph(event) } } var handleMouseOutGraph = function(event) { // hide the hover-line hoverLine.classed("hide", true); setValueLabelsToLatest(); //debug("MouseOut graph [" + containerId + "] => " + mouseX + ", " + mouseY) // user is no longer interacting userCurrentlyInteracting = false; currentUserPositionX = -1; } /* // if we need to support older browsers without pageX/pageY we can use this var getMousePositionFromEvent = function(e, element) { var posx = 0; var posy = 0; if (!e) var e = window.event; if (e.pageX || e.pageY) { posx = e.pageX; posy = e.pageY; } else if (e.clientX || e.clientY) { posx = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; posy = e.clientY + document.body.scrollTop + document.documentElement.scrollTop; } return {x: posx, y: posy}; } */ /* * Handler for when data is updated. */ var handleDataUpdate = function() { if(userCurrentlyInteracting) { // user is interacting, so let's update values to wherever the mouse/finger is on the updated data if(currentUserPositionX > -1) { displayValueLabelsForPositionX(currentUserPositionX) } } else { // the user is not interacting with the graph, so we'll update the labels to the latest setValueLabelsToLatest(); } } /** * Display the data values at position X in the legend value labels. */ var displayValueLabelsForPositionX = function(xPosition, withTransition) { var animate = false; if(withTransition != undefined) { if(withTransition) { animate = true; } } var dateToShow; var labelValueWidths = []; graph.selectAll("text.legend.value") .text(function(d, i) { var valuesForX = getValueForPositionXFromData(xPosition, i); dateToShow = valuesForX.date; return valuesForX.value; }) .attr("x", function(d, i) { labelValueWidths[i] = this.getComputedTextLength(); }) // position label names var cumulativeWidth = 0; var labelNameEnd = []; graph.selectAll("text.legend.name") .attr("x", function(d, i) { // return it at the width of previous labels (where the last one ends) var returnX = cumulativeWidth; // increment cumulative to include this one + the value label at this index cumulativeWidth += this.getComputedTextLength()+4+labelValueWidths[i]+8; // store where this ends labelNameEnd[i] = returnX + this.getComputedTextLength()+5; return returnX; }) // remove last bit of padding from cumulativeWidth cumulativeWidth = cumulativeWidth - 8; if(cumulativeWidth > w) { // decrease font-size to make fit legendFontSize = legendFontSize-1; //debug("making legend fit by decreasing font size to: " + legendFontSize) graph.selectAll("text.legend.name") .attr("font-size", legendFontSize); graph.selectAll("text.legend.value") .attr("font-size", legendFontSize); // recursively call until we get ourselves fitting displayValueLabelsForPositionX(xPosition); return; } // position label values graph.selectAll("text.legend.value") .attr("x", function(d, i) { return labelNameEnd[i]; }) // show the date graph.select('text.date-label').text(dateToShow.toDateString() + " " + dateToShow.toLocaleTimeString()) // move the group of labels to the right side if(animate) { graph.selectAll("g.legend-group g") .transition() .duration(transitionDuration) .ease("linear") .attr("transform", "translate(" + (w-cumulativeWidth) +",0)") } else { graph.selectAll("g.legend-group g") .attr("transform", "translate(" + (w-cumulativeWidth) +",0)") } } /** * Set the value labels to whatever the latest data point is. */ var setValueLabelsToLatest = function(withTransition) { displayValueLabelsForPositionX(w, withTransition); } /** * Convert back from an X position on the graph to a data value from the given array (one of the lines) * Return {value: value, date, date} */ var getValueForPositionXFromData = function(xPosition, dataSeriesIndex) { var d = data.values[dataSeriesIndex] // get the date on x-axis for the current location var xValue = x.invert(xPosition); // Calculate the value from this date by determining the 'index' // within the data array that applies to this value var index = (xValue.getTime() - data.startTime) / data.step; if(index >= d.length) { index = d.length-1; } // The date we're given is interpolated so we have to round off to get the nearest // index in the data array for the xValue we're given. // Once we have the index, we then retrieve the data from the d[] array index = Math.round(index); // bucketDate is the date rounded to the correct 'step' instead of interpolated var bucketDate = new Date(data.startTime.getTime() + data.step * (index+1)); // index+1 as it is 0 based but we need 1-based for this math var v = d[index]; var roundToNumDecimals = data.rounding[dataSeriesIndex]; return {value: roundNumber(v, roundToNumDecimals), date: bucketDate}; } /** * Called when the window is resized to redraw graph accordingly. */ var handleWindowResizeEvent = function() { //debug("Window Resize Event [" + containerId + "] => resizing graph") initDimensions(); initX(); // reset width/height of SVG d3.select("#" + containerId + " svg") .attr("width", w + margin[1] + margin[3]) .attr("height", h + margin[0] + margin[2]); // reset transform of x axis graph.selectAll("g .x.axis") .attr("transform", "translate(0," + h + ")"); if(yAxisRight != undefined) { // Reset the y-axisRight transform if it exists graph.selectAll("g .y.axis.right") .attr("transform", "translate(" + (w+10) + ",0)"); } // reset legendFontSize on window resize so it has a chance to re-calculate to a bigger size if it can now fit legendFontSize = 12; //debug("making legend fit by decreasing font size to: " + legendFontSize) graph.selectAll("text.legend.name") .attr("font-size", legendFontSize); graph.selectAll("text.legend.value") .attr("font-size", legendFontSize); // move date label graph.select('text.date-label') .transition() .duration(transitionDuration) .ease("linear") .attr("x", w) // redraw the graph with new dimensions redrawAxes(true); redrawLines(true); // reposition legend if necessary redrawLegendPosition(true); // force legend to redraw values setValueLabelsToLatest(true); } /** * Set height/width dimensions based on container. */ var initDimensions = function() { // automatically size to the container using JQuery to get width/height w = $("#" + containerId).width() - margin[1] - margin[3]; // width h = $("#" + containerId).height() - margin[0] - margin[2]; // height // make sure to use offset() and not position() as we want it relative to the document, not its parent hoverLineXOffset = margin[3]+$(container).offset().left; hoverLineYOffset = margin[0]+$(container).offset().top; } /** * Return the value from argsMap for key or throw error if no value found */ var getRequiredVar = function(argsMap, key, message) { if(!argsMap[key]) { if(!message) { throw new Error(key + " is required") } else { throw new Error(message) } } else { return argsMap[key] } } /** * Return the value from argsMap for key or defaultValue if no value found */ var getOptionalVar = function(argsMap, key, defaultValue) { if(!argsMap[key]) { return defaultValue } else { return argsMap[key] } } var error = function(message) { console.log("ERROR: " + message) } var debug = function(message) { console.log("DEBUG: " + message) } /* round a number to X digits: num => the number to round, dec => the number of decimals */ /* private */ function roundNumber(num, dec) { var result = Math.round(num*Math.pow(10,dec))/Math.pow(10,dec); var resultAsString = result.toString(); if(dec > 0) { if(resultAsString.indexOf('.') == -1) { resultAsString = resultAsString + '.'; } // make sure we have a decimal and pad with 0s to match the number we were asked for var indexOfDecimal = resultAsString.indexOf('.'); while(resultAsString.length <= (indexOfDecimal+dec)) { resultAsString = resultAsString + '0'; } } return resultAsString; }; /* *************************************************************** */ /* execute init now that everything is defined */ /* *************************************************************** */ _init(); };