From 80be397c6428c0015c21f4a4ac96e222eb51f67e Mon Sep 17 00:00:00 2001 From: Dan Vanderkam Date: Sat, 22 Nov 2014 00:59:00 -0500 Subject: [PATCH] Hairlines/Super-annotations plugins + test --- extras/hairlines.js | 459 ++++++++++++++++++++++++++++++++++++++++++ extras/super-annotations.js | 474 ++++++++++++++++++++++++++++++++++++++++++++ tests/hairlines.html | 342 ++++++++++++++++++++++++++++++++ 3 files changed, 1275 insertions(+) create mode 100644 extras/hairlines.js create mode 100644 extras/super-annotations.js create mode 100644 tests/hairlines.html diff --git a/extras/hairlines.js b/extras/hairlines.js new file mode 100644 index 0000000..904e432 --- /dev/null +++ b/extras/hairlines.js @@ -0,0 +1,459 @@ +/** + * @license + * Copyright 2013 Dan Vanderkam (danvdk@gmail.com) + * MIT-licensed (http://opensource.org/licenses/MIT) + * + * Note: This plugin requires jQuery and jQuery UI Draggable. + * + * See high-level documentation at + * https://docs.google.com/document/d/1OHNE8BNNmMtFlRQ969DACIYIJ9VVJ7w3dSPRJDEeIew/edit# + */ + +/*global Dygraph:false */ + +Dygraph.Plugins.Hairlines = (function() { + +"use strict"; + +/** + * @typedef { + * xval: number, // x-value (i.e. millis or a raw number) + * interpolated: bool, // alternative is to snap to closest + * lineDiv: !Element // vertical hairline div + * infoDiv: !Element // div containing info about the nearest points + * selected: boolean // whether this hairline is selected + * } Hairline + */ + +// We have to wait a few ms after clicks to give the user a chance to +// double-click to unzoom. This sets that delay period. +var CLICK_DELAY_MS = 300; + +var hairlines = function(opt_options) { + /* @type {!Array.} */ + this.hairlines_ = []; + + // Used to detect resizes (which require the divs to be repositioned). + this.lastWidth_ = -1; + this.lastHeight = -1; + this.dygraph_ = null; + + this.addTimer_ = null; + opt_options = opt_options || {}; + + this.divFiller_ = opt_options['divFiller'] || null; +}; + +hairlines.prototype.toString = function() { + return "Hairlines Plugin"; +}; + +hairlines.prototype.activate = function(g) { + this.dygraph_ = g; + this.hairlines_ = []; + + return { + didDrawChart: this.didDrawChart, + click: this.click, + dblclick: this.dblclick, + dataDidUpdate: this.dataDidUpdate + }; +}; + +hairlines.prototype.detachLabels = function() { + for (var i = 0; i < this.hairlines_.length; i++) { + var h = this.hairlines_[i]; + $(h.lineDiv).remove(); + $(h.infoDiv).remove(); + this.hairlines_[i] = null; + } + this.hairlines_ = []; +}; + +hairlines.prototype.hairlineWasDragged = function(h, event, ui) { + var area = this.dygraph_.getArea(); + var oldXVal = h.xval; + h.xval = this.dygraph_.toDataXCoord(ui.position.left); + this.moveHairlineToTop(h); + this.updateHairlineDivPositions(); + this.updateHairlineInfo(); + this.updateHairlineStyles(); + $(this).triggerHandler('hairlineMoved', { + oldXVal: oldXVal, + newXVal: h.xval + }); + $(this).triggerHandler('hairlinesChanged', {}); +}; + +// This creates the hairline object and returns it. +// It does not position it and does not attach it to the chart. +hairlines.prototype.createHairline = function(props) { + var h; + var self = this; + + var $lineContainerDiv = $('
').css({ + 'width': '6px', + 'margin-left': '-3px', + 'position': 'absolute', + 'z-index': '10' + }) + .addClass('dygraph-hairline'); + + var $lineDiv = $('
').css({ + 'width': '1px', + 'position': 'relative', + 'left': '3px', + 'background': 'black', + 'height': '100%' + }); + $lineDiv.appendTo($lineContainerDiv); + + var $infoDiv = $('#hairline-template').clone().removeAttr('id').css({ + 'position': 'absolute' + }) + .show(); + + // Surely there's a more jQuery-ish way to do this! + $([$infoDiv.get(0), $lineContainerDiv.get(0)]) + .draggable({ + 'axis': 'x', + 'drag': function(event, ui) { + self.hairlineWasDragged(h, event, ui); + } + // TODO(danvk): set cursor here + }); + + h = $.extend({ + interpolated: true, + selected: false, + lineDiv: $lineContainerDiv.get(0), + infoDiv: $infoDiv.get(0) + }, props); + + var that = this; + $infoDiv.on('click', '.hairline-kill-button', function(e) { + that.removeHairline(h); + $(that).triggerHandler('hairlineDeleted', { + xval: h.xval + }); + $(that).triggerHandler('hairlinesChanged', {}); + e.stopPropagation(); // don't want .click() to trigger, below. + }).on('click', function() { + that.moveHairlineToTop(h); + }); + + return h; +}; + +// Moves a hairline's divs to the top of the z-ordering. +hairlines.prototype.moveHairlineToTop = function(h) { + var div = this.dygraph_.graphDiv; + $(h.infoDiv).appendTo(div); + $(h.lineDiv).appendTo(div); + + var idx = this.hairlines_.indexOf(h); + this.hairlines_.splice(idx, 1); + this.hairlines_.push(h); +}; + +// Positions existing hairline divs. +hairlines.prototype.updateHairlineDivPositions = function() { + var g = this.dygraph_; + var layout = this.dygraph_.getArea(); + var chartLeft = layout.x, chartRight = layout.x + layout.w; + var div = this.dygraph_.graphDiv; + var pos = Dygraph.findPos(div); + var box = [layout.x + pos.x, layout.y + pos.y]; + box.push(box[0] + layout.w); + box.push(box[1] + layout.h); + + $.each(this.hairlines_, function(idx, h) { + var left = g.toDomXCoord(h.xval); + h.domX = left; // See comments in this.dataDidUpdate + $(h.lineDiv).css({ + 'left': left + 'px', + 'top': layout.y + 'px', + 'height': layout.h + 'px' + }); // .draggable("option", "containment", box); + $(h.infoDiv).css({ + 'left': left + 'px', + 'top': layout.y + 'px', + }).draggable("option", "containment", box); + + var visible = (left >= chartLeft && left <= chartRight); + $([h.infoDiv, h.lineDiv]).toggle(visible); + }); +}; + +// Sets styles on the hairline (i.e. "selected") +hairlines.prototype.updateHairlineStyles = function() { + $.each(this.hairlines_, function(idx, h) { + $([h.infoDiv, h.lineDiv]).toggleClass('selected', h.selected); + }); +}; + +// Find prevRow and nextRow such that +// g.getValue(prevRow, 0) <= xval +// g.getValue(nextRow, 0) >= xval +// g.getValue({prev,next}Row, col) != null, NaN or undefined +// and there's no other row such that: +// g.getValue(prevRow, 0) < g.getValue(row, 0) < g.getValue(nextRow, 0) +// g.getValue(row, col) != null, NaN or undefined. +// Returns [prevRow, nextRow]. Either can be null (but not both). +hairlines.findPrevNextRows = function(g, xval, col) { + var prevRow = null, nextRow = null; + var numRows = g.numRows(); + for (var row = 0; row < numRows; row++) { + var yval = g.getValue(row, col); + if (yval === null || yval === undefined || isNaN(yval)) continue; + + var rowXval = g.getValue(row, 0); + if (rowXval <= xval) prevRow = row; + + if (rowXval >= xval) { + nextRow = row; + break; + } + } + + return [prevRow, nextRow]; +}; + +// Fills out the info div based on current coordinates. +hairlines.prototype.updateHairlineInfo = function() { + var mode = 'closest'; + + var g = this.dygraph_; + var xRange = g.xAxisRange(); + var that = this; + $.each(this.hairlines_, function(idx, h) { + // To use generateLegendHTML, we synthesize an array of selected points. + var selPoints = []; + var labels = g.getLabels(); + var row, prevRow, nextRow; + + if (!h.interpolated) { + // "closest point" mode. + // TODO(danvk): make findClosestRow method public + row = g.findClosestRow(g.toDomXCoord(h.xval)); + for (var i = 1; i < g.numColumns(); i++) { + selPoints.push({ + canvasx: 1, // TODO(danvk): real coordinate + canvasy: 1, // TODO(danvk): real coordinate + xval: h.xval, + yval: g.getValue(row, i), + name: labels[i] + }); + } + } else { + // "interpolated" mode. + for (var i = 1; i < g.numColumns(); i++) { + var prevNextRow = hairlines.findPrevNextRows(g, h.xval, i); + prevRow = prevNextRow[0], nextRow = prevNextRow[1]; + + // For x-values outside the domain, interpolate "between" the extreme + // point and itself. + if (prevRow === null) prevRow = nextRow; + if (nextRow === null) nextRow = prevRow; + + // linear interpolation + var prevX = g.getValue(prevRow, 0), + nextX = g.getValue(nextRow, 0), + prevY = g.getValue(prevRow, i), + nextY = g.getValue(nextRow, i), + frac = prevRow == nextRow ? 0 : (h.xval - prevX) / (nextX - prevX), + yval = frac * nextY + (1 - frac) * prevY; + + selPoints.push({ + canvasx: 1, // TODO(danvk): real coordinate + canvasy: 1, // TODO(danvk): real coordinate + xval: h.xval, + yval: yval, + prevRow: prevRow, + nextRow: nextRow, + name: labels[i] + }); + } + } + + if (that.divFiller_) { + that.divFiller_(h.infoDiv, { + closestRow: row, + points: selPoints, + hairline: that.createPublicHairline_(h), + dygraph: g + }); + } else { + var html = Dygraph.Plugins.Legend.generateLegendHTML(g, h.xval, selPoints, 10); + $('.hairline-legend', h.infoDiv).html(html); + } + }); +}; + +// After a resize, the hairline divs can get dettached from the chart. +// This reattaches them. +hairlines.prototype.attachHairlinesToChart_ = function() { + var div = this.dygraph_.graphDiv; + $.each(this.hairlines_, function(idx, h) { + $([h.lineDiv, h.infoDiv]).appendTo(div); + }); +}; + +// Deletes a hairline and removes it from the chart. +hairlines.prototype.removeHairline = function(h) { + var idx = this.hairlines_.indexOf(h); + if (idx >= 0) { + this.hairlines_.splice(idx, 1); + $([h.lineDiv, h.infoDiv]).remove(); + } else { + Dygraph.warn('Tried to remove non-existent hairline.'); + } +}; + +hairlines.prototype.didDrawChart = function(e) { + var g = e.dygraph; + + // Early out in the (common) case of zero hairlines. + if (this.hairlines_.length === 0) return; + + this.updateHairlineDivPositions(); + this.attachHairlinesToChart_(); + this.updateHairlineInfo(); + this.updateHairlineStyles(); +}; + +hairlines.prototype.dataDidUpdate = function(e) { + // When the data in the chart updates, the hairlines should stay in the same + // position on the screen. didDrawChart stores a domX parameter for each + // hairline. We use that to reposition them on data updates. + var g = this.dygraph_; + $.each(this.hairlines_, function(idx, h) { + if (h.hasOwnProperty('domX')) { + h.xval = g.toDataXCoord(h.domX); + } + }); +}; + +hairlines.prototype.click = function(e) { + if (this.addTimer_) { + // Another click is in progress; ignore this one. + return; + } + + var area = e.dygraph.getArea(); + var xval = this.dygraph_.toDataXCoord(e.canvasx); + + var that = this; + this.addTimer_ = setTimeout(function() { + that.addTimer_ = null; + that.hairlines_.push(that.createHairline({xval: xval})); + + that.updateHairlineDivPositions(); + that.updateHairlineInfo(); + that.updateHairlineStyles(); + that.attachHairlinesToChart_(); + + $(that).triggerHandler('hairlineCreated', { + xval: xval + }); + $(that).triggerHandler('hairlinesChanged', {}); + }, CLICK_DELAY_MS); +}; + +hairlines.prototype.dblclick = function(e) { + if (this.addTimer_) { + clearTimeout(this.addTimer_); + this.addTimer_ = null; + } +}; + +hairlines.prototype.destroy = function() { + this.detachLabels(); +}; + + +// Public API + +/** + * This is a restricted view of this.hairlines_ which doesn't expose + * implementation details like the handle divs. + * + * @typedef { + * xval: number, // x-value (i.e. millis or a raw number) + * interpolated: bool, // alternative is to snap to closest + * selected: bool // whether the hairline is selected. + * } PublicHairline + */ + +/** + * @param {!Hairline} h Internal hairline. + * @return {!PublicHairline} Restricted public view of the hairline. + */ +hairlines.prototype.createPublicHairline_ = function(h) { + return { + xval: h.xval, + interpolated: h.interpolated, + selected: h.selected + }; +}; + +/** + * @return {!Array.} The current set of hairlines, ordered + * from back to front. + */ +hairlines.prototype.get = function() { + var result = []; + for (var i = 0; i < this.hairlines_.length; i++) { + var h = this.hairlines_[i]; + result.push(this.createPublicHairline_(h)); + } + return result; +}; + +/** + * Calling this will result in a hairlinesChanged event being triggered, no + * matter whether it consists of additions, deletions, moves or no changes at + * all. + * + * @param {!Array.} hairlines The new set of hairlines, + * ordered from back to front. + */ +hairlines.prototype.set = function(hairlines) { + // Re-use divs from the old hairlines array so far as we can. + // They're already correctly z-ordered. + var anyCreated = false; + for (var i = 0; i < hairlines.length; i++) { + var h = hairlines[i]; + + if (this.hairlines_.length > i) { + this.hairlines_[i].xval = h.xval; + this.hairlines_[i].interpolated = h.interpolated; + this.hairlines_[i].selected = h.selected; + } else { + this.hairlines_.push(this.createHairline({ + xval: h.xval, + interpolated: h.interpolated, + selected: h.selected + })); + anyCreated = true; + } + } + + // If there are any remaining hairlines, destroy them. + while (hairlines.length < this.hairlines_.length) { + this.removeHairline(this.hairlines_[hairlines.length]); + } + + this.updateHairlineDivPositions(); + this.updateHairlineInfo(); + this.updateHairlineStyles(); + if (anyCreated) { + this.attachHairlinesToChart_(); + } + + $(this).triggerHandler('hairlinesChanged', {}); +}; + +return hairlines; + +})(); diff --git a/extras/super-annotations.js b/extras/super-annotations.js new file mode 100644 index 0000000..7697beb --- /dev/null +++ b/extras/super-annotations.js @@ -0,0 +1,474 @@ +/** + * @license + * Copyright 2013 Dan Vanderkam (danvdk@gmail.com) + * MIT-licensed (http://opensource.org/licenses/MIT) + * + * Note: This plugin requires jQuery and jQuery UI Draggable. + * + * See high-level documentation at + * https://docs.google.com/document/d/1OHNE8BNNmMtFlRQ969DACIYIJ9VVJ7w3dSPRJDEeIew/edit# + */ + +/*global Dygraph:false */ + +Dygraph.Plugins.SuperAnnotations = (function() { + +"use strict"; + +/** + * These are just the basic requirements -- annotations can have whatever other + * properties the code that displays them wants them to have. + * + * @typedef { + * xval: number, // x-value (i.e. millis or a raw number) + * series: string, // series name + * yFrac: ?number, // y-positioning. Default is a few px above the point. + * lineDiv: !Element // vertical div connecting point to info div. + * infoDiv: !Element // div containing info about the annotation. + * } Annotation + */ + +var annotations = function(opt_options) { + /* @type {!Array.} */ + this.annotations_ = []; + // Used to detect resizes (which require the divs to be repositioned). + this.lastWidth_ = -1; + this.lastHeight = -1; + this.dygraph_ = null; + + opt_options = opt_options || {}; + this.defaultAnnotationProperties_ = $.extend({ + 'text': 'Description' + }, opt_options['defaultAnnotationProperties']); +}; + +annotations.prototype.toString = function() { + return "SuperAnnotations Plugin"; +}; + +annotations.prototype.activate = function(g) { + this.dygraph_ = g; + this.annotations_ = []; + + return { + didDrawChart: this.didDrawChart, + pointClick: this.pointClick // TODO(danvk): implement in dygraphs + }; +}; + +annotations.prototype.detachLabels = function() { + for (var i = 0; i < this.annotations_.length; i++) { + var a = this.annotations_[i]; + $(a.lineDiv).remove(); + $(a.infoDiv).remove(); + this.annotations_[i] = null; + } + this.annotations_ = []; +}; + +annotations.prototype.annotationWasDragged = function(a, event, ui) { + var g = this.dygraph_; + var area = g.getArea(); + var oldYFrac = a.yFrac; + + var infoDiv = a.infoDiv; + var newYFrac = ((infoDiv.offsetTop + infoDiv.offsetHeight) - area.y) / area.h; + if (newYFrac == oldYFrac) return; + + a.yFrac = newYFrac; + + this.moveAnnotationToTop(a); + this.updateAnnotationDivPositions(); + this.updateAnnotationInfo(); + $(this).triggerHandler('annotationMoved', { + annotation: a, + oldYFrac: oldYFrac, + newYFrac: a.yFrac + }); + $(this).triggerHandler('annotationsChanged', {}); +}; + +annotations.prototype.makeAnnotationEditable = function(a) { + if (a.editable == true) return; + this.moveAnnotationToTop(a); + + // Note: we have to fill out the HTML ourselves because + // updateAnnotationInfo() won't touch editable annotations. + a.editable = true; + var editableTemplateDiv = $('#annotation-editable-template').get(0); + a.infoDiv.innerHTML = this.getTemplateHTML(editableTemplateDiv, a); + $(a.infoDiv).toggleClass('editable', !!a.editable); + $(this).triggerHandler('beganEditAnnotation', a); +}; + +// This creates the hairline object and returns it. +// It does not position it and does not attach it to the chart. +annotations.prototype.createAnnotation = function(a) { + var self = this; + + var color = this.getColorForSeries_(a.series); + + var $lineDiv = $('
').css({ + 'width': '1px', + 'left': '3px', + 'background': 'black', + 'height': '100%', + 'position': 'absolute', + // TODO(danvk): use border-color here for consistency? + 'background-color': color, + 'z-index': 10 + }).addClass('dygraph-annotation-line'); + + var $infoDiv = $('#annotation-template').clone().removeAttr('id').css({ + 'position': 'absolute', + 'border-color': color, + 'z-index': 10 + }) + .show(); + + $.extend(a, { + lineDiv: $lineDiv.get(0), + infoDiv: $infoDiv.get(0) + }); + + var that = this; + + $infoDiv.draggable({ + 'start': function(event, ui) { + $(this).css({'bottom': ''}); + a.isDragging = true; + }, + 'drag': function(event, ui) { + self.annotationWasDragged(a, event, ui); + }, + 'stop': function(event, ui) { + $(this).css({'top': ''}); + a.isDragging = false; + self.updateAnnotationDivPositions(); + }, + 'axis': 'y', + 'containment': 'parent' + }); + + // TODO(danvk): use 'on' instead of delegate/dblclick + $infoDiv.on('click', '.annotation-kill-button', function() { + that.removeAnnotation(a); + $(that).triggerHandler('annotationDeleted', a); + $(that).triggerHandler('annotationsChanged', {}); + }); + + $infoDiv.on('dblclick', function() { + that.makeAnnotationEditable(a); + }); + $infoDiv.on('click', '.annotation-update', function() { + self.extractUpdatedProperties_($infoDiv.get(0), a); + a.editable = false; + self.updateAnnotationInfo(); + $(that).triggerHandler('annotationEdited', a); + $(that).triggerHandler('annotationsChanged', {}); + }); + $infoDiv.on('click', '.annotation-cancel', function() { + a.editable = false; + self.updateAnnotationInfo(); + $(that).triggerHandler('cancelEditAnnotation', a); + }); + + return a; +}; + +// Find the index of a point in a series. +// Returns a 2-element array, [row, col], which can be used with +// dygraph.getValue() to get the value at this point. +// Returns null if there's no match. +annotations.prototype.findPointIndex_ = function(series, xval) { + var col = this.dygraph_.getLabels().indexOf(series); + if (col == -1) return null; + + var lowIdx = 0, highIdx = this.dygraph_.numRows() - 1; + while (lowIdx <= highIdx) { + var idx = Math.floor((lowIdx + highIdx) / 2); + var xAtIdx = this.dygraph_.getValue(idx, 0); + if (xAtIdx == xval) { + return [idx, col]; + } else if (xAtIdx < xval) { + lowIdx = idx + 1; + } else { + highIdx = idx - 1; + } + } + return null; +}; + +annotations.prototype.getColorForSeries_ = function(series) { + var colors = this.dygraph_.getColors(); + var col = this.dygraph_.getLabels().indexOf(series); + if (col == -1) return null; + + return colors[(col - 1) % colors.length]; +}; + +// Moves a hairline's divs to the top of the z-ordering. +annotations.prototype.moveAnnotationToTop = function(a) { + var div = this.dygraph_.graphDiv; + $(a.infoDiv).appendTo(div); + $(a.lineDiv).appendTo(div); + + var idx = this.annotations_.indexOf(a); + this.annotations_.splice(idx, 1); + this.annotations_.push(a); +}; + +// Positions existing hairline divs. +annotations.prototype.updateAnnotationDivPositions = function() { + var layout = this.dygraph_.getArea(); + var chartLeft = layout.x, chartRight = layout.x + layout.w; + var chartTop = layout.y, chartBottom = layout.y + layout.h; + var div = this.dygraph_.graphDiv; + var pos = Dygraph.findPos(div); + var box = [layout.x + pos.x, layout.y + pos.y]; + box.push(box[0] + layout.w); + box.push(box[1] + layout.h); + + var g = this.dygraph_; + + var that = this; + $.each(this.annotations_, function(idx, a) { + var row_col = that.findPointIndex_(a.series, a.xval); + if (row_col == null) { + $([a.lineDiv, a.infoDiv]).hide(); + return; + } else { + // TODO(danvk): only do this if they're invisible? + $([a.lineDiv, a.infoDiv]).show(); + } + var xy = g.toDomCoords(a.xval, g.getValue(row_col[0], row_col[1])); + var x = xy[0], pointY = xy[1]; + + var lineHeight = 6; // TODO(danvk): option? + + var y = pointY; + if (a.yFrac !== undefined) { + y = layout.y + layout.h * a.yFrac; + } else { + y -= lineHeight; + } + + var lineHeight = y < pointY ? (pointY - y) : (y - pointY - a.infoDiv.offsetHeight); + $(a.lineDiv).css({ + 'left': x + 'px', + 'top': Math.min(y, pointY) + 'px', + 'height': lineHeight + 'px' + }); + $(a.infoDiv).css({ + 'left': x + 'px', + }); + if (!a.isDragging) { + // jQuery UI draggable likes to set 'top', whereas superannotations sets + // 'bottom'. Setting both will make the annotation grow and contract as + // the user drags it, which looks bad. + $(a.infoDiv).css({ + 'bottom': (div.offsetHeight - y) + 'px' + }) //.draggable("option", "containment", box); + + var visible = (x >= chartLeft && x <= chartRight) && + (pointY >= chartTop && pointY <= chartBottom); + $([a.infoDiv, a.lineDiv]).toggle(visible); + } + }); +}; + +// Fills out the info div based on current coordinates. +annotations.prototype.updateAnnotationInfo = function() { + var g = this.dygraph_; + + var that = this; + var templateDiv = $('#annotation-template').get(0); + $.each(this.annotations_, function(idx, a) { + // We should never update an editable div -- doing so may kill unsaved + // edits to an annotation. + $(a.infoDiv).toggleClass('editable', !!a.editable); + if (a.editable) return; + a.infoDiv.innerHTML = that.getTemplateHTML(templateDiv, a); + }); +}; + +/** + * @param {!Annotation} a Internal annotation + * @return {!PublicAnnotation} a view of the annotation for the public API. + */ +annotations.prototype.createPublicAnnotation_ = function(a, opt_props) { + var displayAnnotation = $.extend({}, a, opt_props); + delete displayAnnotation['infoDiv']; + delete displayAnnotation['lineDiv']; + delete displayAnnotation['isDragging']; + delete displayAnnotation['editable']; + return displayAnnotation; +}; + +// Fill out a div using the values in the annotation object. +// The div's html is expected to have text of the form "{{key}}" +annotations.prototype.getTemplateHTML = function(div, a) { + var g = this.dygraph_; + var row_col = this.findPointIndex_(a.series, a.xval); + if (row_col == null) return; // perhaps it's no longer a real point? + var row = row_col[0]; + var col = row_col[1]; + + var yOptView = g.optionsViewForAxis_('y1'); // TODO: support secondary, too + var xvf = g.getOptionForAxis('valueFormatter', 'x'); + + var x = xvf(a.xval); + var y = g.getOption('valueFormatter', a.series)( + g.getValue(row, col), yOptView); + + var displayAnnotation = this.createPublicAnnotation_(a, {x:x, y:y}); + var html = div.innerHTML; + for (var k in displayAnnotation) { + var v = displayAnnotation[k]; + if (typeof(v) == 'object') continue; // e.g. infoDiv or lineDiv + html = html.replace(new RegExp('\{\{' + k + '\}\}', 'g'), v); + } + return html; +}; + +// Update the annotation object by looking for elements with a 'dg-ann-field' +// attribute. For example, will have +// its value placed in the 'text' attribute of the annotation. +annotations.prototype.extractUpdatedProperties_ = function(div, a) { + $(div).find('[dg-ann-field]').each(function(idx, el) { + var k = $(el).attr('dg-ann-field'); + var v = $(el).val(); + a[k] = v; + }); +}; + +// After a resize, the hairline divs can get dettached from the chart. +// This reattaches them. +annotations.prototype.attachAnnotationsToChart_ = function() { + var div = this.dygraph_.graphDiv; + $.each(this.annotations_, function(idx, a) { + // Re-attaching an editable div to the DOM can clear its focus. + // This makes typing really difficult! + if (a.editable) return; + + $([a.lineDiv, a.infoDiv]).appendTo(div); + }); +}; + +// Deletes a hairline and removes it from the chart. +annotations.prototype.removeAnnotation = function(a) { + var idx = this.annotations_.indexOf(a); + if (idx >= 0) { + this.annotations_.splice(idx, 1); + $([a.lineDiv, a.infoDiv]).remove(); + } else { + Dygraph.warn('Tried to remove non-existent annotation.'); + } +}; + +annotations.prototype.didDrawChart = function(e) { + var g = e.dygraph; + + // Early out in the (common) case of zero annotations. + if (this.annotations_.length === 0) return; + + this.updateAnnotationDivPositions(); + this.attachAnnotationsToChart_(); + this.updateAnnotationInfo(); +}; + +annotations.prototype.pointClick = function(e) { + // Prevent any other behavior based on this click, e.g. creation of a hairline. + e.preventDefault(); + + var a = $.extend({}, this.defaultAnnotationProperties_, { + series: e.point.name, + xval: e.point.xval + }); + this.annotations_.push(this.createAnnotation(a)); + + this.updateAnnotationDivPositions(); + this.updateAnnotationInfo(); + this.attachAnnotationsToChart_(); + + $(this).triggerHandler('annotationCreated', a); + $(this).triggerHandler('annotationsChanged', {}); + + // Annotations should begin life editable. + this.makeAnnotationEditable(a); +}; + +annotations.prototype.destroy = function() { + this.detachLabels(); +}; + + +// Public API + +/** + * This is a restricted view of this.annotations_ which doesn't expose + * implementation details like the line / info divs. + * + * @typedef { + * xval: number, // x-value (i.e. millis or a raw number) + * series: string, // series name + * } PublicAnnotation + */ + +/** + * @return {!Array.} The current set of annotations, ordered + * from back to front. + */ +annotations.prototype.get = function() { + var result = []; + for (var i = 0; i < this.annotations_.length; i++) { + result.push(this.createPublicAnnotation_(this.annotations_[i])); + } + return result; +}; + +/** + * Calling this will result in an annotationsChanged event being triggered, no + * matter whether it consists of additions, deletions, moves or no changes at + * all. + * + * @param {!Array.} annotations The new set of annotations, + * ordered from back to front. + */ +annotations.prototype.set = function(annotations) { + // Re-use divs from the old annotations array so far as we can. + // They're already correctly z-ordered. + var anyCreated = false; + for (var i = 0; i < annotations.length; i++) { + var a = annotations[i]; + + if (this.annotations_.length > i) { + // Only the divs need to be preserved. + var oldA = this.annotations_[i]; + this.annotations_[i] = $.extend({ + infoDiv: oldA.infoDiv, + lineDiv: oldA.lineDiv + }, a); + } else { + this.annotations_.push(this.createAnnotation(a)); + anyCreated = true; + } + } + + // If there are any remaining annotations, destroy them. + while (annotations.length < this.annotations_.length) { + this.removeAnnotation(this.annotations_[annotations.length]); + } + + this.updateAnnotationDivPositions(); + this.updateAnnotationInfo(); + if (anyCreated) { + this.attachAnnotationsToChart_(); + } + + $(this).triggerHandler('annotationsChanged', {}); +}; + +return annotations; + +})(); diff --git a/tests/hairlines.html b/tests/hairlines.html new file mode 100644 index 0000000..42b7725 --- /dev/null +++ b/tests/hairlines.html @@ -0,0 +1,342 @@ + + + + + Hairlines demo + + + + + + + + + + + + + + + +

Hairlines Demo

+ +

Click the chart to add a hairline. Drag the hairline to move it.

+

Click a point to add an editable annotation. Drag it to move it up/down.

+ + + + + + + +
+
+ +
+ + + + + +
+ Hairline mode: + + + + + +

Learn more about the Hairlines/Super-annotations plugins and their APIs.

+ +
+ + + + + -- 2.7.4