/** * A module to represent a chart drawn by d3.js into a DOM container which can * have some data bound to it and some interactivity applied to it. * @module AppChart * @exports AppChart */ define(["d3", "resizeSensor"], function() { /** * An abstract class to hold the specific algorighms to draw a chart. * These are different for point charts, line charts etc. * Supply the basic chart object as parameter. * @constructor * @abstract * @param chart the basic chart object this strategy belongs to */ AppGraphStrategy = function(chart) { this.chart = chart; }; /** * Creates a new instance of a chart factory, the parameter graphType * specifies which subtype to create * @static * @param chart the basic chart object this graph strategy belongs to * @param graphType the type of graph; supported is: "line" or "scatter" */ AppGraphStrategy.factory = function(chart, graphType) { switch (graphType) { case "line": return new AppLineGraph(chart); case "scatter": return new AppScatterGraph(chart); } }; /** * from a series of values in the chart, gets the point which is closest * to some coordinate "x". how to access the coordinate in the series of * values supplied to the function is specified by an additional accessor * function that is applied to each value of the series. * @param seriesValues an array of values representing the series to analyze * @param x the value to find the closest point to * @param accessor the accessor function to apply to each seriesValue to get * the equivalent of "x" */ AppGraphStrategy.prototype.getNearest = function(seriesValues, x, accessor) { // closest value is found by bisection into values larger and smaller // than "x", then we take the "middle" of the bisection seriesValues.sort(function(a, b) { return accessor(a) - accessor(b); }); var bisect = d3.bisector(function(d) { return accessor(d); }).left, i = bisect(seriesValues, x, 1), d0 = seriesValues[i - 1], // middle (left if odd number of elements) d1 = seriesValues[i], // middle (right if odd nummer of elements) d = (d1 === undefined ? d0 : // edge case: only 1 element (x-accessor(d0) > accessor(d1)-x ? d1 : d0)); return d; }; /** * method for drawing a number of markers to the chart. A marker is a * highlight of a datapoint on the graph canvas with a label of coordinates. * The method delegates back to the base charts drawMarkers(...) function. * @param seriesesNode the DOM node where all the data serieses are attached * with their respective "marker" property via d3's data(...) function. * @param accessor accessor function to apply to the marker values in order * to extract coordinate values to search for close datapoints with * AppGraphStrategy.prototype.getNearest(...). */ AppGraphStrategy.prototype.drawMarkers = function(seriesesNode, accessor) { var self = this; // iterate through all serieses seriesesNode.each(function(d) { // if series has markers if (d.markers) { // memorize the series's specific DOM node (should be a g-element) g = d3.select(this); // get the closest data points for each marker values = d.markers.map(function(marker) { value = self.getNearest(d.values, accessor(marker)); // but also carry over the "coords" attribute if it is set value.coords = marker.coords; return value; }); // delegate back to AppGraph.prototype.drawMarkers(...) self.chart.drawMarkers(values, g); } }); }; /** * Draws the annotation saved with each data series * The method delegates back to the base charts drawAnnotation(...) function. * @param seriesesNode the DOM node where all the data serieses are attached * with their respective "annotation" property via d3's data(...) function. */ AppGraphStrategy.prototype.drawAnnotations = function(seriesesNode) { var self = this; // iterate through all serieses seriesesNode.each(function(d) { // if the series has annotations defined if (d.annotations) { g = d3.select(this); // memorize the series's specific DOM node (should be a g-element) // delegate back to AppGraph.prototype.drawAnnotations(...) self.chart.drawAnnotations(d.annotations, g); } }); }; /** * Draws the stickers saved with each data series * The method delegates back to the base charts drawStickers(...) function. * @param seriesesNode the DOM node where all the data serieses are attached * with their respective "stickers" property via d3's data(...) function. */ AppGraphStrategy.prototype.drawStickers = function(seriesesNode) { var self = this; // iterate through all serieses seriesesNode.each(function(d) { // if the series has stickers defined if (d.stickers) { // memorize the series's specific DOM node (should be a g-element) g = d3.select(this); // delegate back to AppGraph.prototype.drawStickers(...) self.chart.drawStickers(d.stickers, g); } }); }; /** * * Draws all the serieses data points. Should be overriden by specific * AppGraphStrategy subclass but should be called from subclass, because * sets up some event listeners * @abstract * @param seriesNode the DOM node where all the data serieses are attached * with their respective datapoints in the "values" property. */ AppGraphStrategy.prototype.drawSerieses = function(seriesNode) { var self = this; // set up handler to highlight serieses on hover and change focussed X/Y // coordinate on mouse move seriesNode .on("mousemove", this.chart.mouseMovedHandler()) .on("mouseenter", function(d) { self.chart.handleSeriesHighlighted(d.id); }) .on("mouseleave", function(d) { self.chart.handleSeriesUnhighlighted(d.id); }); }; /** * A strategy to draw line graph (time series) * @constructor * @extends module:AppChart~AppGraphStrategy * @param chart the basic chart object this strategy belongs to */ AppLineGraph = function(chart) { AppGraphStrategy.call(this, chart); }; AppLineGraph.prototype = Object.create(AppGraphStrategy.prototype); /** * For a set of values, gets the closest value based on the x coordinate * @param seriesValues the values to choose the closest value from * @param x the x coordinate * @param y the y coordinate (ignored in this implementation) */ AppLineGraph.prototype.getNearest = function(seriesValues, x, y) { return AppGraphStrategy.prototype.getNearest .call(this, seriesValues, x, function (d) { return d.x; }); }; /** * @see module:AppChart~AppGraphStrategy#drawMarkers */ AppLineGraph.prototype.drawMarkers = function(seriesesNode) { // delegate to super; definitive for closeness of a data point in the // line graph is the x-coordinate AppGraphStrategy.prototype.drawMarkers.call(this, seriesesNode, function (d) { return d.x; } ); }; /** * Draws a time series (line graph) based on the seriesesNodes attached * values. Also draws markers and annotations. * @param seriesesNode the DOM node where all the data serieses are attached * with their respective datapoints in the "values" property. */ AppLineGraph.prototype.drawSerieses = function(seriesesNode) { // draw stickers first, they should be at the bottom this.drawStickers(seriesesNode); // then draw serieses AppGraphStrategy.prototype.drawSerieses.call(this, seriesesNode); var self = this; // remove all old lines seriesesNode .selectAll("path") .remove(); // append new lines (need to do this every time since line's range may // have changed because of zooming & panning) seriesesNode .append("path") .attr("d", function(d) { return self.chart.line(d.values); }) .attr("vector-effect", "non-scaling-stroke") // line not dependent on zoom level .style("fill", "transparent") .style("stroke", function(d) { return d.color ? d.color : "black"; }); // oh and also draw markers and annotations for this series this.drawMarkers(seriesesNode); this.drawAnnotations(seriesesNode); }; /** * A strategy to draw scatter plots * @constructor * @extends module:AppChart~AppGraphStrategy * @param chart the basic chart object this strategy belongs to */ AppScatterGraph = function(chart) { AppGraphStrategy.call(this, chart); }; AppScatterGraph.prototype = Object.create(AppGraphStrategy.prototype); /** * For a set of values, gets the closest value based on either the "t" value * (time value) if one value is supplied or the x and y coordinate if two * values are supplied * @param seriesValues the values to choose the closest value from * @param x_or_t the x or t coordinate * @param y the y coordinate */ AppScatterGraph.prototype.getNearest = function(values, x_or_t, y) { if (y === undefined) { // in case only one value is supplied just redirect to the superclass // function with the appropriate accessor (this is used if markers are // shown, e.g., based on fixed markers supplied as the series.markers // data) var t = x_or_t; return AppGraphStrategy.prototype.getNearest.call(this, values, t, function (d) { return d.t; } ); } else { // otherwise we must apply the eucledian distance to all the values var x = x_or_t, mindist = undefined, r = undefined, xscaled = this.chart.x(x), yscaled = this.chart.y(y); // iterate through all the values; there are better solutions than that // for large data sets (with binary trees etc.) but not today... for (i in values) { value = values[i]; dist = Math.sqrt(Math.pow(this.chart.x(value.x)-xscaled,2) + Math.pow(this.chart.y(value.y)-yscaled,2)); if (mindist === undefined || dist < mindist) { r = value; mindist = dist; } } return r; } }; /** * @see module:AppChart~AppGraphStrategy#drawMarkers */ AppScatterGraph.prototype.drawMarkers = function(seriesesNode) { AppGraphStrategy.prototype.drawMarkers.call(this, seriesesNode, function (d) { return d.t; } ); }; /** * Draws a scatter plot based on the seriesesNodes attached * values. Also draws markers and annotations. * @param seriesesNode the DOM node where all the data serieses are attached * with their respective datapoints in the "values" property. */ AppScatterGraph.prototype.drawSerieses = function(seriesesNode) { AppGraphStrategy.prototype.drawSerieses.call(this, seriesesNode); var self = this; // draw stickers first (at the bottom) this.drawStickers(seriesesNode); seriesesNode.each(function(d) { // memorize the series's element (should be <g> but whatever) g = d3.select(this); // attach all the values to circles with class point in the element circles = g.selectAll("circle.point").data(d.values); // set an alpha level if one was specified in the chart's options opacity = self.chart.options.alpha ? self.chart.options.alpha : 1.0; // remove old circles, add new circles with class, opacity and radius circles.exit().remove(); circles.enter().append("circle") .attr("r", "3") .attr("opacity", opacity) .attr("class", "point"); // re-position all the circles (need to do this since x/y range may // have changed because of zooming) circles .style("fill", function (v) { return d.color ? d.color : "black"; }) .attr("cx", function (v) { return self.chart.x(v.x); }) .attr("cy", function (v) { return self.chart.y(v.y); }); }); // oh and also draw markers and annotations this.drawMarkers(seriesesNode); this.drawAnnotations(seriesesNode); }; //==== AppChart //---- Initialization & Data Binding /** * A chart drawn using d3.js with axis, zooming & panning functionality, * display of different data serieses, highlight support etc. * @constructor * @param containerNode the DOM node to contain the chart * @param options a object of options - possible values include: <ul> * <li>colorMap - an array of pairs mapping a value to a color</li> * <li>metricId - the metricId the values stem from</li> * <li>xlabel - the caption of the x axis</li> * <li>ylabel - the caption of the y axis</li> * <li>title - the text of the graph title</li> * <li>graphType - one of "line" or "scatter"</li> * <li>baseDir - base directory to look into if urls are referenced from * the data</li> * </ul> */ AppChart = function(containerNode, options) { var colorDomain = function(d) { return d[0]; }; var colorRange = function(d) { return d[1]; }; var self = this; // store container node this.containerNode = containerNode; // read options this.options = options || {}; // set defaults if (! this.options.baseDir) this.options.baseDir = "."; // set up color map this.colorMap = this.options.colorMap; if (this.colorMap) this.colorMap = d3.scale.linear() .domain(this.colorMap.map(colorDomain)) .range(this.colorMap.map(colorRange)); // set up metric id, graph strategy and padding based on x/y label + title this.metricId = this.options.metricId; this.graphStrategy = AppGraphStrategy.factory(this, options.graphType); this.padding = { top: this.options.title ? 30 : 10, right: 20, bottom: this.options.xlabel ? 60 : 40, left: this.options.ylabel ? 60 : 40 }; // setup() will perform layout of the DOM nodes this.setup(); }; /** * Sets up the DOM nodes of the chart. Should not be called from outside, * will be called by the constructor once on creation of the chart. * All DOM nodes will be available using AppChart....Layer or * AppChart....Node after calling this method. */ AppChart.prototype.setup = function() { var self = this; // set up scales (linear in x- and y-direction) this.y = d3.scale.linear(); this.x = d3.scale.linear(); // first level: SVG top level element, xlink is used for referencing // other sub-images by URL this.svgNode = d3.select(this.containerNode).append("svg") .attr("xmlns:xlink", "http://www.w3.org/1999/xlink") .on("mouseenter", this.mouseEnterHandler()) .on("mouseleave", this.mouseLeaveHandler()); // second level: <g> element grouping all the layers this.layers = this.svgNode.append("g"); // third level: different layers // third level, first layer: the grid, containing: // ticks, labels, title and a transparent rect used to catch mouse // events for re-scaling of the axis this.gridLayer = this.layers.append("g") .attr("class", "grid layer"); this.xTicksNode = this.gridLayer.append("g") .attr("class", "x ticks"); this.yGuideNode = this.gridLayer.append("g") .attr("class", "y guide"); this.yTicksNode = this.gridLayer.append("g") .attr("class", "y ticks"); this.xAxisNode = this.gridLayer.append("line") .attr("class", "x axis"); this.yAxisNode = this.gridLayer.append("line") .attr("class", "y axis"); if (this.options.title) this.titleNode = this.gridLayer.append("text") .attr("class", "title") .text(this.options.title) .attr("dy", "-0.8em") .style("text-anchor", "middle"); if (this.options.xlabel) this.xLabelNode = this.gridLayer.append("text") .attr("class", "x label") .text(this.options.xlabel + (this.options.xunit ? " in " + this.options.xunit : "")) .attr("dy", "2.4em") .style("text-anchor", "middle"); if (this.options.ylabel) this.yLabelNode = this.gridLayer.append("text") .attr("class", "y label") .text(this.options.ylabel + (this.options.yunit ? " in " + this.options.yunit : "")) .style("text-anchor", "middle"); this.interactXAxis = this.gridLayer.append("rect") .attr("class", "interact x axis") .on("mousedown.drag", self.draggingXStartedHandler()) .on("touchstart.drag", self.draggingXStartedHandler()); this.interactYAxis = this.gridLayer.append("rect") .attr("class", "interact y axis") .on("mousedown.drag", self.draggingYStartedHandler()) .on("touchstart.drag", self.draggingYStartedHandler()); // third level, second layer: the "graph", containing several sublayers this.graphLayer = this.layers.append("svg") .attr("class", "graph layer " + this.options.graphType) .on("mousemove", self.mouseMovedHandler()) // handle mouse motion .call(d3.behavior.zoom().x(this.x).y(this.y) .on("zoom", this.zoomHandler())); // add zoomable behaviour // sublayer 1: graph layer is used for catching mouse events this.interactGraph = this.graphLayer.append("rect") .attr("class", "interact graph"); // sublayer 2: "hotspots" (little rects to tell stories in // explorative views) this.hotspotLayer = this.graphLayer.append("g") .attr("class", "hotspot layer"); // sublayer 3: layer to display the actual data this.seriesesLayer = this.graphLayer.append("g") .attr("class", "serieses layer"); // sublayer 4: shows elements that highlight areas interactively this.highlightLayer = this.graphLayer.append("g") .attr("class", "highlight layer"); this.highlightXYNode = this.highlightLayer.append("g"); // the whole container node should responde to drag events (zooming) d3.select(this.containerNode) .on("mousemove.drag", this.mouseDraggedHandler()) .on("touchmove.drag", this.mouseDraggedHandler()) .on("mouseup.drag", this.mouseReleasedHandler()) .on("touchend.drag", this.mouseReleasedHandler()); // currently we are not dragging anywhere this.draggingX = Math.NaN; this.draggingY = Math.NaN; // we also presume that we have not highlighted this graph this.highlightThis = false; // react to resize events of the surrounding container node this.resizeSensor = new ResizeSensor(this.containerNode, this.resizeHandler()); // setup any outside handlers this.seriesHighlightedHandlers = []; this.seriesUnhighlightedHandlers = []; this.zoomedPannedHandlers = []; this.mouseEnterHandlers = []; this.mouseLeaveHandlers = []; this.mouseMovedHandlers = []; }; /** * Binds data to the graph. Call this function from outside with a "serieses" * dataset and, if you like, supply a buildSeriesTransform callback which * will be applied to the serieses dataset before it is bound to the * chart. * @param serieses the dataset to bind / show in the graph; this is an * array of series objects with the following values: <ul> * <li>values: an array of points in the form * {x:...,y:...[,t:...]}</li> * <li>id: an id of the series</li> * <li>markers: an array of markers in the form {x/y/t:...,coords:... * }</li> * <li>annotations: an array of annotations to display</li> * </ul> * See the serieses documentation for more detail. * @param buildSeriesTransform the callback function to apply to every * series before it is bound to the chart */ AppChart.prototype.bind = function(serieses, buildSeriesTransform) { var self = this; if (buildSeriesTransform) serieses = buildSeriesTransform(serieses); this.serieses = serieses; var series = this.seriesesLayer.selectAll(".series") .data(this.serieses, function(d) { return d.id; }) .attr("id", function(d) { return d.id; }); var seriese = series.enter(); seriese.append("g") .attr("top", 0) .attr("left", 0) .attr("id", function(d) { return d.id; }); series.exit().remove(); this.seriesesNode = series; }; //---- Helper functions /** * Returns a series with a series ID from the "belly" of the already bound * chart data * @param seriesId the series to recall */ AppChart.prototype.getSeriesFromId = function(seriesId) { for (i in this.serieses) if (this.serieses[i].id == seriesId) return this.serieses[i]; }; /** * Returns the [xmin, xmax] domain of the chart and adds some whitespace * if requested. If no data is present, the range will be 0.0-0.1 * @param whitespace the whitespace to add in a fraction of xmin-xmax */ AppChart.prototype.getXExtent = function(whitespace) { if (whitespace === undefined) whitespace = 0.0; var minX = undefined, maxX = undefined; // Go through all the series and find the total max / min for (i in this.serieses) { extentX = d3.extent(this.serieses[i].values, function(d) { return d.x; }); if (minX === undefined || extentX[0] < minX) minX = extentX[0]; if (maxX === undefined || extentX[1] > maxX) maxX = extentX[1]; } if (minX === undefined) minX = 0.0; if (maxX === undefined) maxX = 1.0; return [minX - whitespace * (maxX-minX), maxX + whitespace * (maxX-minX)]; }; /** * Returns the [ymin, ymax] domain of the chart and adds some whitespace * if requested. If no data is present, the range will be 0.0-0.1 * @param whitespace the whitespace to add in a fraction of xmin-xmax */ AppChart.prototype.getYExtent = function(whitespace) { if (whitespace === undefined) whitespace = 0.0; var minY = undefined, maxY = undefined; // Go through all the series and find the total max / min for (i in this.serieses) { extentY = d3.extent(this.serieses[i].values, function(d) { return d.y; }); if (minY === undefined || extentY[0] < minY) minY = extentY[0]; if (maxY === undefined || extentY[1] > maxY) maxY = extentY[1]; } if (minY === undefined) minY = 0.0; if (maxY === undefined) maxY = 1.0; return [minY - whitespace * (maxY-minY), maxY + whitespace * (maxY-minY)]; }; /** * Get the nearest value for all series in a hashmap mapping seriesId to * closest point to the coordinates supplied * @param x the x coordinate to search close to * @param y the y coordinate to search close to * @param t the t coordinate (time) to search close to */ AppChart.prototype.getNearest = function(x, y, t) { hash = {}; // go through all series for (i in this.serieses) { series = this.serieses[i]; // use graph strategy to get closest value (different for line chart // and scatter plot) p = this.graphStrategy.getNearest(series.values, x, y, t); // add the value we found plus some helpful information hash[series.id] = { id: series.id, x: p.x, y: p.y, t: p.t, // color value based on color map (if any present) colorVal: this.colorMap ? this.colorMap(p.y) : undefined, // color value based on color assigned to this series color: series.color ? series.color : false }; } return hash; }; //---- Handling Re-Scaling Events /** * Pan the chart to a new x-/y-domain. ALL calls to rescale the graph to a * new domain should go here; re-drawing / re-scaling of all elements, event * firing etc. are done from here. * @param minX the new minX coordinate, supply undefined to leave unchanged * @param maxX the new maxX coordinate, supply undefined to leave unchanged * @param minY the new minY coordinate, supply undefined to leave unchanged * @param maxY the new maxY coordinate, supply undefined to leave unchanged */ AppChart.prototype.dimensions = function(minX, maxX, minY, maxY) { // memorize old dimensions var oldMinX = this.minX, oldMaxX = this.maxX, oldMinY = this.minY, oldMaxY = this.maxY; // if anything changed at all if ((oldMinX !== minX && minX !== undefined) || (oldMaxX !== maxX && maxX !== undefined) || (oldMinY !== minY && minY !== undefined) || (oldMaxY !== maxY && maxY !== undefined)) { // change all the things that should be changed if (minX !== undefined) this.minX = minX; if (maxX !== undefined) this.maxX = maxX; if (minY !== undefined) this.minY = minY; if (maxY !== undefined) this.maxY = maxY; // re-apply the x/y domain this.x.domain([this.minX, this.maxX]); this.y.domain([this.maxY, this.minY]); // rescale all fixed position DOM nodes this.scale(); // re-draw graph this.draw(); } }; /** * Re-scale all the fixed position DOM nodes. Shortcut to scaleHandler()() * @see module:AppChart~AppChart#scaleHandler */ AppChart.prototype.scale = function() { return this.scaleHandler()(); }; /** * Returns a callback that should be invoked every time the x or y dimensions * (domain or range) of the graph change. Some elements that are drawn on the * chart are fixed-position DOM elements and need re-positioning by * JavaScript. The callback returned by this function takes care of that. */ AppChart.prototype.scaleHandler = function() { var self = this; return function() { // set some internal properties memorizing the space we have available self.clientWidth = self.containerNode.clientWidth; self.clientHeight = self.containerNode.clientHeight -1; self.size = { width: self.clientWidth - self.padding.left - self.padding.right, height: self.clientHeight - self.padding.top - self.padding.bottom }; // re-scale the top level SVG node & layers group to fit the container self.svgNode .attr("width", self.clientWidth) .attr("height", self.clientHeight); self.layers .attr("width", self.clientWidth) .attr("height", self.clientHeight) // transform layers group so that pixel origin (0,0) is moved // left/right depending on padding for axis labels .attr("transform", "translate("+self.padding.left+","+self.padding.top+")"); // transform/re-scale rect for catching graph events to cover graph area self.interactGraph .attr("width", self.size.width) .attr("height", self.size.height) .attr("transform", "translate("+self.padding.left+","+self.padding.top+")"); // transform/re-scale rect for catching x axis events to cover x axis area self.interactXAxis .attr("width", self.size.width) .attr("height", self.padding.bottom) .attr("transform", "translate(0,"+self.size.height+")"); // transform/re-scale rect for catching y axis events to cover y axis area self.interactYAxis .attr("width", self.padding.left) .attr("height", self.size.height) .attr("transform", "translate(-"+self.padding.left+",0)"); // transform/re-scale graph layer (svg, see setup(...)) to show exactly // the window we have specified self.graphLayer .attr("width", self.size.width) .attr("height", self.size.height) .attr("viewBox", "0 0 " + self.size.width + " " + self.size.height); self.x = self.x.range([0, self.size.width]); self.y = self.y.range([0, self.size.height]); // move/transfrom x-axis <line> to the right place self.xAxisNode .attr("x1", 0) .attr("x2", self.size.width) .attr("transform", "translate(0,"+self.size.height+")"); // move y-axis <line> to the right place self.yAxisNode .attr("y1", 0) .attr("y2", self.size.height); // move color map guide to the right place self.yGuideNode .attr("x", self.padding.left-10) .attr("width", 10) .attr(self.size.height); // if x label present, move it to the right place if (self.xLabelNode) self.xLabelNode .attr("x", self.size.width/2) .attr("y", self.size.height+5); // if y label present, move it to the right place if (self.yLabelNode) self.yLabelNode .attr("transform", "translate("+-40+" "+self.size.height/2+") rotate(-90)"); // if title present, move it to the right place if (self.titleNode) self.titleNode .attr("x", self.size.width/2); }; }; /** * Same as scaleHandler(), but calls draw() afterwards. Callback to invoke * everytime the container size changes and not only a re-positioning of the * elements, but a complete redraw is necessary. * @see module:AppChart~AppChart#scaleHandler */ AppChart.prototype.resizeHandler = function() { var self = this; return function() { self.scaleHandler()(); self.draw(); } }; //---- Handling Highlight-Events (mouseovers, marks etc.) /** * Call this method if any part of the graph should be highlighted. Method * calls "drawHighlights(...)" to redraw highlights if anything changed. * @param changedHighlights object with some of the following properties; * Supply any property with "false" to unhighlight something: <ul> * <li>seriesId (string) - highlight any of the data serieses</li> * <li>x (float) and y (float) - highlight any of the x/y values with a * marker</li> * <li>thisChart (boolean) - highlight this chart</li> * <li>hotspots (object) - add hotspots to this chart</li> */ AppChart.prototype.highlight = function(changedHighlights) { var highlightsDirty = false; // a data series is highlighted if (changedHighlights.seriesId || changedHighlights.seriesId === false) { this.highlightedSeries = changedHighlights.seriesId; highlightsDirty = true; } // the x or y value to be highlighted changed if (changedHighlights.x || changedHighlights.x === false) { this.highlightedX = changedHighlights.x; highlightsDirty = true; } if (changedHighlights.y || changedHighlights.y === false) { this.highlightedY = changedHighlights.y; highlightsDirty = true; } // this chart is highlighted if (changedHighlights.thisChart !== undefined) { this.highlightThis = changedHighlights.thisChart; highlightsDirty = true; } // a hotspot is added if (changedHighlights.hotspots !== undefined) { this.highlightHotspots = changedHighlights.hotspots; highlightsDirty = true; } if (highlightsDirty) this.drawHighlights(); } //---- Drawing the components /** * Re-draw all the components. Shortcut to drawHandler()() * @see module:AppChart~AppChart#drawHandler */ AppChart.prototype.draw = function() { return this.drawHandler()(); }; /** * Returns a callback that should be invoked every time the graph should be * redrawn from scratch since it is re-scaled. Callback redraws all the layers * (grids, serieses & highlights) by using the respective draw<...>-methods. */ AppChart.prototype.drawHandler = function() { var self = this; return function() { self.drawGrid(); self.drawSerieses(); self.drawHighlights(); }; } /** * Redraws the graphs grid. Should be called any time the grid changed * because of a zooming / panning event or rescaling of the graph container. */ AppChart.prototype.drawGrid = function() { var self = this; // accessor methods to translate axis ticks var tx = function(d) { return "translate(" + self.x(d) + ", 0)"; }, ty = function(d) { return "translate(0, " + self.y(d) + ")"; }; // accessor method to determine the class of a grid line based on // whether it is the origin line or not var lg = function(d) { return "long " + (d ? "" : "zero"); }; // create about 10 ticks for the x and y axis var fx = this.x.tickFormat(10), fy = this.y.tickFormat(10); // 1. Regenarate <g> tags for x ticks and apply the data of the ~10 ticks var gx = this.xTicksNode.selectAll("g") .data(this.x.ticks(10), String) .attr("transform", tx); // Add new x ticks and transform them into the right position var gxe = gx.enter().insert("g", "a") .attr("transform", tx); // Add the "short" tick line on the axis gxe.append("line").attr("class", "short"); // Add the "long" grid line covering the graph gxe.append("line").attr("class", lg); // Add the text besides the tick gxe.append("text") .attr("dy", "1em") .attr("text-anchor", "middle") .text(fx); // Remove any ticks which disappeared gx.exit().remove(); // Place everyting (texts, "short" tick lines and "long" grid lines) // based on the containers dimensions. gx.selectAll("text") .attr("y", this.size.height+5); gx.selectAll("line.long") .attr("y1", 0) .attr("y2", this.size.height); gx.selectAll("line.short") .attr("y1", this.size.height) .attr("y2", this.size.height+5); // 2. Regenarate <g> tags for y ticks and apply the data of the ~10 ticks var gy = this.yTicksNode.selectAll("g") .data(this.y.ticks(10), String) .attr("transform", ty); // Add new y ticks and transform them into the right position var gye = gy.enter().insert("g", "a") .attr("transform", ty); // Add the "short" tick line on the axis gye.append("line").attr("class", "short"); // Add the "long" grid line covering the graph gye.append("line").attr("class", lg); // Add the text besides the tick gye.append("text") .attr("dy", ".35em") .attr("text-anchor", "end") .text(fy); // Remove any ticks which disappeared gy.exit().remove(); // Place everyting (texts, "short" tick lines and "long" grid lines) // based on the containers dimensions. gy.selectAll("text") .attr("x", -5); gy.selectAll("line.short") .attr("x1", -5) .attr("x2", 0); gy.selectAll("line.long") .attr("x1", 0) .attr("x2", this.size.width); // 3. Regenerate y-"guides" (the color map pallette on the y axis) if (this.colorMap) { // lets have about one shade of color every 5 pixels var numGuides = self.size.height / 5, guideTicks = this.y.ticks(numGuides); // for each of these shades we need y coordinate and original value // guideRects array will hold these values guideRects = []; for (i in guideTicks) guideRects.push([ // first rect (i == 0) should reach all the way down but not further i == 0 ? guideTicks[i] : guideTicks[i-1], guideTicks[i] ]); // last rect should reach all the way up but not further guideRects.push([guideTicks[guideTicks.length-1], this.y.invert(0)]); // create a "rect" for each guide shape gg = this.yGuideNode.selectAll("rect") .data(guideRects); // remove all guide shapes that have "disappeared" gg.exit().remove(); // add new guide shapes that have entered gge = gg.enter().append("rect"); // place, transform and fill shapes gg.attr("transform", function(d) { return "translate(-5, " + (self.y(d[1])) + ")"; }) .attr("height", function(d) { return self.y(d[0])-self.y(d[1])+1; }) .attr("fill", function(d) { return self.colorMap(d[1]); }) .attr("width", "5"); } }; /** * Redraws the graphs data serieses. Call this every time the graph container * rescales. This method largely delegates to * AppGraphStrategy.drawSerieses(...) */ AppChart.prototype.drawSerieses = function() { var self = this; // the "line" connecting all the data points (only really used in time // series visualizations) this.line = d3.svg.line() .interpolate("linear") .x(function(d,i) { return self.x(d.x); }) .y(function(d,i) { return self.y(d.y); }); // delegte to graph strategy if (this.seriesesNode) this.graphStrategy.drawSerieses(this.seriesesNode); // also let the user zoom / pan if he has the mouse on the series (not only // un the rect-area below it) this.graphLayer.call( d3.behavior.zoom().x(this.x).y(this.y).on("zoom", this.zoomHandler())); }; /** * Redraws the graphs stickers. Needs to be supplied with a sticker array. * This method is usually called from the graph strategy. * @param data the sticker data to draw on the graph - an array of * sticker objects, each with the following properties: <ul> * <li>x0, y0 - one corner to draw the sticker to</li> * <li>x1, y1 - the diagonally opposite corner to draw the sticker to</li> * <li>src - filename (relative to graphOptions.baseDir) of the * sticker</li> * </ul> * @param container the container to draw the stickers into */ AppChart.prototype.drawStickers = function(data, container) { var self = this; stickerSel = container.selectAll("image.sticker"); sticker = stickerSel.data(data); sticker.exit().remove(); stickerEnter = sticker.enter().append("image").attr("class", "sticker"); sticker .attr("x", function (d) { return Math.min(self.x(d.x0),self.x(d.x1)); }) .attr("y", function (d) { return Math.min(self.y(d.y0),self.y(d.y1)); }) .attr("width", function (d) { return Math.abs(self.x(d.x1)-self.x(d.x0)); }) .attr("height", function (d) { return Math.abs(self.y(d.y0)-self.y(d.y1)); }) .attr("xlink:href", function(d) { return self.options.baseDir + "/" + d.src; }); }; /** * Redraws the graphs annotations. Needs to be supplied with an annotation * array. This method is usually called from the graph strategy. * @param data the annotation data to draw on the graph - an array of * annotation objects, each with the following properties: <ul> * <li>lines - an array of text lines to display</li> * <li>color - a color to fill the annotation with</li> * <li>anchor - the text anchor (start, middle, end)</li> * </ul> * @param container the container to draw the data into */ AppChart.prototype.drawAnnotations = function(data, container) { var self = this; // select all annotations inside the container & apply the annotation object textSel = container.selectAll("text.annotation"); text = textSel.data(data); // remove all annotations that have dissappeared text.exit().remove(); // create new annotation objects textEnter = text.enter().append("text").attr("class", "annotation"); // re-place all annotation objects (in case the graph was rescaled) text.attr("transform", function (d) { return "translate(" + self.x(d.x) + "," + self.y(d.y) + ")"; } ); // for new annotations: fill them and add multi line text (one tspan per // text line) textLines = textEnter .attr("fill", function (d) { return d.color ? d.color : ""; }) .attr("text-anchor", function (d) { return d.anchor ? d.anchor : ""; }) .selectAll("tspan") .data(function (d) { return d.lines; }); textLines.exit().remove(); textLines.enter().append("tspan") .attr("dy", "1.2em") // lines have 1.2em distance from another .attr("x", "0") .text(function (d) { return d; }); }; /** * Redraws the graphs markers. Needs to be supplied with an marker array, a * container DOM object to specify where to draw the markers into and * a boolean stating whether this graph is highlighted or not. This method is * usually called from the graph strategy. * @param data the marker data to draw on the graph - an array of marker * objects, each with the following properties: <ul> * <li>x - the x value of the marker</li> * <li>y - the y value of the marker</li> * <li>t - the t value of the marker</li> * <li>coords - an optional array of a combination of "x", "y", "t" to * signify which labels need to be plotted next to the marker</li> * </ul> * @param container the container to draw the data into * @param highlightedSeries boolean to signify if the series is highlighted or * not */ AppChart.prototype.drawMarkers = function(data, container, highlightedSeries) { var self = this, format = d3.format(".2f"); // get all circles representing the marker points and all texts representing // the markers x, y & t values circleSel = container.selectAll("circle.marker"); xTextSel = container.selectAll("text.x"); yTextSel = container.selectAll("text.y"); tTextSel = container.selectAll("text.t"); circles = circleSel.data(data); // remove all disappeared, add all appeared circles circles.exit().remove(); circles.enter().append("circle").attr("r", "5"); // define class, position, stroke, filling and highlight / unhighlight // behaviour circles // class should capture if the series is highlighted, and if the series // has a color map attached .attr("class", function (d) { return (highlightedSeries === true || d.id == highlightedSeries ? "highlighted" : "") + " " + (d.colorVal ? "with-color-map" : "") + " marker"; }) // coordinates .attr("cx", function (d) { return self.x(d.x); }) .attr("cy", function (d) { return self.y(d.y); }) // stroke is the color of the series, if any is defined, if not, then // it is the color resolved from the value-color-map, if any is present, // otherwise it is just black .attr("stroke", function (d) { return d.color ? d.color : d.colorVal ? d.colorVal : "black"; }) // fill is the color resoled from the value-color-map, if any is present .attr("fill", function (d) { return d.colorVal ? d.colorVal : "transparent"; }) // also highlight / unhighlight the series if hovering over the marker, // (the same way we do if we hover over the series itself) .on("mouseenter", function(d) { self.handleSeriesHighlighted(d.id); }) .on("mouseleave", function(d) { self.handleSeriesUnhighlighted(d.id); }); // add x value labels if (xTextSel) { // apply markers xTexts = xTextSel.data(data); // add appeared marker labels, remove disappeared ones xTexts.exit().remove(); xTexts.enter().append("text").attr("class", "x"); // define the text to display, it's position, anchor and class xTexts .text(function(d) { // only display text, if present & d.coords doesn't forbid if ((d.coords && d.coords.indexOf("x")==-1) || d.x===undefined) return ""; // display text as x value + unit return format(d.x) + (self.options.xunit ? self.options.xunit : ""); }) .attr("x", function(d) { return self.x(d.x); }) .attr("y", function(d) { return self.y(d.y)+20; }) .attr("text-anchor", "middle") // class should represent if series is highlighted .attr("class", function (d) { return (highlightedSeries === true || d.id == highlightedSeries ? "highlighted " : "") + "x"; }); } // add y value labels if (yTextSel) { // apply markers yTexts = yTextSel.data(data); // add appeared marker labels, remove disappeared ones yTexts.exit().remove(); yTexts.enter().append("text").attr("class", "y"); // define the text to display, it's position, anchor and class yTexts .text(function(d) { // only display text, if present & d.coords doesn't forbid if ((d.coords && d.coords.indexOf("y")==-1) || d.y === undefined) return ""; // display text as y value + unit return format(d.y) + (self.options.yunit ? self.options.yunit : ""); }) .attr("x", function(d) { return self.x(d.x)-10; }) .attr("y", function(d) { return self.y(d.y)+3; }) .attr("text-anchor", "end") // class should represent if series is highlighted .attr("class", function (d) { return (highlightedSeries === true || d.id == highlightedSeries ? "highlighted " : "") + "y"; }); } // add t value labels if (tTextSel) { // apply markers tTexts = tTextSel.data(data); // add appeared marker labels, remove disappeared ones tTexts.exit().remove(); tTexts.enter().append("text").attr("class", "t"); // define the text to display, it's position, anchor and class tTexts .text(function(d) { // only display text, if present & d.coords doesn't forbid if ((d.coords && d.coords.indexOf("t")==-1) || d.t === undefined) return ""; // display text as t value + unit return format(d.t) + (self.options.tunit ? self.options.tunit : ""); }) .attr("x", function(d) { return self.x(d.x)+10; }) .attr("y", function(d) { return self.y(d.y)+3; }) // class should represent if series is highlighted .attr("class", function (d) { return (highlightedSeries === true || d.id == highlightedSeries ? "highlighted " : "") + "t"; }); } }; /** * Redraws the graphs hotspots. Needs to be supplied with an hotspot array and * a container DOM object where to draw the objects into. This method is * called from drawHighlights(...) since hotspots are considered highlights. * @param data the hotspot data to draw on the graph - an array of hotspot * objects, each with the properties x0, y0, x1, y1 to specify the area * that the hotspot may cover and the property "seriesId" signifying the * series this hotspot belongs to. * @param container the container to draw the hotspots into */ AppChart.prototype.drawHotspots = function(data, container) { var self = this; // select all rects with hotspot class hotspotSel = container.selectAll("rect.hotspot") // if no hotspots are defined just safely assume an empty array if (!data) data = []; // filter the array to extract only the series we are currently plotting if (this.seriesId) data = data.filter(function (d) { return (d.seriesId == self.seriesId); }); // apply the data to the DOM nodes hotspots = hotspotSel.data(data); // remove all disappeared hotspots, add all appeared hotspots hotspots.exit().remove(); hotspots.enter().append("rect") .attr("class", "hotspot") .attr("rx", "5") .attr("ry", "5"); // place the hotspots correctly hotspots .attr("x", function (d) { return Math.min(self.x(d.x0),self.x(d.x1)); }) .attr("y", function (d) { return Math.min(self.y(d.y0),self.y(d.y1)); }) .attr("width", function (d) { return Math.abs(self.x(d.x1)-self.x(d.x0)); }) .attr("height", function (d) { return Math.abs(self.y(d.y0)-self.y(d.y1)); }); }; /** * Redraws the graphs highlights based on the internal highlight state */ AppChart.prototype.drawHighlights = function() { var self = this; // highlight any points close to the specified "highlightX" / "highlightY" // location and draw the associated markers hash = this.getNearest(this.highlightedX, this.highlightedY); data = []; for (k in hash) data.push(hash[k]); if (this.highlightedX !== false) { this.drawMarkers(data, this.highlightXYNode, this.highlightedSeries); } else if (this.highlightedX === false) { this.drawMarkers([], this.highlightXYNode, this.highlightedSeries); } // highlight this graph if "highlightThis" is true (e.g. on mouseover) this.layers.attr("class", (this.highlightThis ? "highlighted" : "unhighlighted") + " app-chart"); // highlight any series if "highlightedSeries" is specified if (this.seriesNode) this.seriesNode.attr("class", function(d) { return (d.id == self.highlightedSeries ? "highlighted " : "unhighlighted") + " series"; }); // draw hotspots if "highlightHotspots" is defined this.drawHotspots(this.highlightHotspots, this.hotspotLayer); }; //---- Internal callbacks (functions consuming events) /** * Return internal callback invoked if started dragging on the x axis */ AppChart.prototype.draggingXStartedHandler = function() { var self = this; return function() { document.onselectstart = function() { return false; }; var p = d3.mouse(self.layers[0][0]); // safe where we started dragging self.draggingX = self.x.invert(p[0]); }; }; /** * Return internal callback invoked if started dragging on the y axis */ AppChart.prototype.draggingYStartedHandler = function() { var self = this; return function() { document.onselectstart = function() { return false; }; var p = d3.mouse(self.layers[0][0]); // safe where we started dragging self.draggingY = self.y.invert(p[1]); }; }; /** * Return internal callback invoked if moving the mouse while dragging. */ AppChart.prototype.mouseDraggedHandler = function() { var self = this; return function() { var p = d3.mouse(self.layers[0][0]), // mouse coordinates t = d3.event.changedTouches, x = self.x.invert(p[0]), // x value from domain y = self.y.invert(p[1]), // y value from domain newMaxX = undefined, newMaxY = undefined; // we are currently dragging the x axis if (self.draggingX !== undefined) { if (x != 0) { var changeX = self.draggingX / x; newMaxX = self.minX + (self.x.domain()[1] - self.minX) * changeX; } d3.event.preventDefault(); d3.event.stopPropagation(); } // we are currently dragging the y axis if (self.draggingY !== undefined) { if (y != 0) { var changeY = self.draggingY / y; newMaxY = self.minY + (self.y.domain()[0] - self.minY) * changeY; } d3.event.preventDefault(); d3.event.stopPropagation(); } // if we dragged on x and why and we didn't end up with strange // coordinates, rescale the graph if (newMaxX !== undefined || newMaxY !== undefined) { self.dimensions(undefined, newMaxX, undefined, newMaxY); // also invoke all zoom/pan handlers self.handleZoomedPanned(); } }; }; /** * Return internal callback invoked if moving the mouse. */ AppChart.prototype.mouseMovedHandler = function() { var self = this; return function() { var p = d3.mouse(self.layers[0][0]), x = self.x.invert(p[0]), y = self.y.invert(p[1]); // invoke any external callbacks self.handleMouseMoved(x, y); return false; }; }; /** * Return internal callback invoked if the graph was zoomed by the d3.js * internal zooming behaviour */ AppChart.prototype.zoomHandler = function() { var self = this; return function() { // get the new dimentsions (which are automatically set by d3.js) minX = self.x.domain()[0]; maxX = self.x.domain()[1]; maxY = self.y.domain()[0]; minY = self.y.domain()[1]; // apply those dimensions how we are doing it self.dimensions(minX, maxX, minY, maxY); // invoke external callbacks self.handleZoomedPanned(); }; }; /** * Return internal callback invoked if the mouse is released after a dragging * event */ AppChart.prototype.mouseReleasedHandler = function() { var self = this; return function() { document.onselectstart = function() { return true; }; // reset internal state if (!isNaN(self.draggingX)) { self.draw(); self.draggingX = Math.NaN; d3.event.preventDefault(); d3.event.stopPropagation(); } if (!isNaN(self.draggingY)) { self.draw(); self.draggingY = Math.NaN; d3.event.preventDefault(); d3.event.stopPropagation(); } }; }; /** * Return internal callback invoked if the mouse enters the graph area */ AppChart.prototype.mouseEnterHandler = function(f) { var self = this; return function() { // invoke external callback self.handleMouseEnter(); }; }; /** * Return internal callback invoked if the mouse leaves the graph area */ AppChart.prototype.mouseLeaveHandler = function(f) { var self = this; return function() { // invoke external callback self.handleMouseLeave(); }; }; //---- Attaching external callbacks /** * Attach a callback to be invoked every time a series gets highlighted * @param f the callback function - can take one argument, the seriesId */ AppChart.prototype.attachSeriesHighlightedHandler = function(f) { this.seriesHighlightedHandlers.push(f); }; /** * Attach a callback to be invoked every time a series gets unhighlighted * @param f the callback function - can take one argument, the seriesId */ AppChart.prototype.attachSeriesUnhighlightedHandler = function(f) { this.seriesUnhighlightedHandlers.push(f); }; /** * Attach a callback to be invoked every time the graph is zoomed/panned * @param f the callback function - can take 4 arguments: minX, maxX, minY, * maxY */ AppChart.prototype.attachZoomedPannedHandler = function(f) { this.zoomedPannedHandlers.push(f); }; /** * Attach a callback to be invoked every time the mouse enters the graph * @param f the callback function */ AppChart.prototype.attachMouseEnterHandler = function(f) { this.mouseEnterHandlers.push(f); }; /** * Attach a callback to be invoked every time the mouse leaves the graph * @param f the callback function */ AppChart.prototype.attachMouseLeaveHandler = function(f) { this.mouseLeaveHandlers.push(f); }; /** * Attach a callback to be invoked every time the mouse is moved inside the * graph * @param f the callback function */ AppChart.prototype.attachMouseMovedHandler = function(f) { this.mouseMovedHandlers.push(f); }; //---- Invoking external callbacks /** * Invoke all callbacks to handle the event of a highlighted series * @param id the series that was highlighted */ AppChart.prototype.handleSeriesHighlighted = function(id) { for (i in this.seriesHighlightedHandlers) { this.seriesHighlightedHandlers[i](id); } }; /** * Invoke all callbacks to handle the event of a unhighlighted series * @param id the series that was unhighlighted */ AppChart.prototype.handleSeriesUnhighlighted = function(id) { for (i in this.seriesHighlightedHandlers) { this.seriesUnhighlightedHandlers[i](id); } }; /** * Invoke all callbacks to handle the event of a graph zoom/pan action */ AppChart.prototype.handleZoomedPanned = function() { for (i in this.zoomedPannedHandlers) { this.zoomedPannedHandlers[i]( this.minX, this.maxX, this.minY, this.maxY ); } }; /** * Invoke all callbacks to handle the event of a mouse enter */ AppChart.prototype.handleMouseEnter = function() { for (i in this.mouseEnterHandlers) { this.mouseEnterHandlers[i](); } }; /** * Invoke all callbacks to handle the event of a mouse leave */ AppChart.prototype.handleMouseLeave = function() { for (i in this.mouseLeaveHandlers) { this.mouseLeaveHandlers[i](); } }; /** * Invoke all callbacks to handle the event of a mouse move */ AppChart.prototype.handleMouseMoved = function(x, y) { for (i in this.mouseMovedHandlers) { this.mouseMovedHandlers[i](x, y); } }; return AppChart; });