X-Git-Url: https://adrianiainlam.tk/git/?a=blobdiff_plain;f=dygraph.js;h=e49c9358f322466a36ad8df48bd949325b6f55a7;hb=c6336f04d04745b75c7fe69dc5f54baddfe7ec9e;hp=4f08596853e6b740fa7fc4ac0237c3fbf6782d56;hpb=0949d3e5cd678e82717f01d6696e7e8abe763be2;p=dygraphs.git diff --git a/dygraph.js b/dygraph.js index 4f08596..e49c935 100644 --- a/dygraph.js +++ b/dygraph.js @@ -73,6 +73,19 @@ DateGraph.DEFAULT_HEIGHT = 320; DateGraph.DEFAULT_STROKE_WIDTH = 1.0; DateGraph.AXIS_LINE_WIDTH = 0.3; +// Default attribute values. +DateGraph.DEFAULT_ATTRS = { + highlightCircleSize: 3, + pixelsPerXLabel: 60, + pixelsPerYLabel: 30, + labelsDivWidth: 250, + labelsDivStyles: { + // TODO(danvk): move defaults from createStatusMessage_ here. + } + + // TODO(danvk): default padding +}; + /** * Initializes the DateGraph. This creates a new DIV and constructs the PlotKit * and interaction <canvas> inside of it. See the constructor for details @@ -84,6 +97,7 @@ DateGraph.AXIS_LINE_WIDTH = 0.3; */ DateGraph.prototype.__init__ = function(div, file, labels, attrs) { // Copy the important bits into the object + // TODO(danvk): most of these should just stay in the attrs_ dictionary. this.maindiv_ = div; this.labels_ = labels; this.file_ = file; @@ -99,7 +113,6 @@ DateGraph.prototype.__init__ = function(div, file, labels, attrs) { this.labelsSeparateLines = attrs.labelsSeparateLines || false; this.labelsDiv_ = attrs.labelsDiv || null; this.labelsKMB_ = attrs.labelsKMB || false; - this.minTickSize_ = attrs.minTickSize || 0; this.xValueParser_ = attrs.xValueParser || DateGraph.prototype.dateParser; this.xValueFormatter_ = attrs.xValueFormatter || DateGraph.prototype.dateString_; @@ -107,7 +120,14 @@ DateGraph.prototype.__init__ = function(div, file, labels, attrs) { this.sigma_ = attrs.sigma || 2.0; this.wilsonInterval_ = attrs.wilsonInterval || true; this.customBars_ = attrs.customBars || false; - this.attrs_ = attrs; + + this.attrs_ = {}; + MochiKit.Base.update(this.attrs_, DateGraph.DEFAULT_ATTRS); + MochiKit.Base.update(this.attrs_, attrs); + + if (typeof this.attrs_.pixelsPerXLabel == 'undefined') { + this.attrs_.pixelsPerXLabel = 60; + } // Make a note of whether labels will be pulled from the CSV file. this.labelsFromCSV_ = (this.labels_ == null); @@ -144,7 +164,8 @@ DateGraph.prototype.__init__ = function(div, file, labels, attrs) { this.createRollInterface_(); this.createDragInterface_(); - connect(window, 'onload', this, function(e) { this.start_(); }); + // connect(window, 'onload', this, function(e) { this.start_(); }); + this.start_(); }; /** @@ -234,7 +255,7 @@ DateGraph.prototype.setColors_ = function(attrs) { */ DateGraph.prototype.createStatusMessage_ = function(){ if (!this.labelsDiv_) { - var divWidth = 250; + var divWidth = this.attrs_.labelsDivWidth; var messagestyle = { "style": { "position": "absolute", "fontSize": "14px", @@ -245,6 +266,7 @@ DateGraph.prototype.createStatusMessage_ = function(){ "background": "white", "textAlign": "left", "overflow": "hidden"}}; + MochiKit.Base.update(messagestyle["style"], this.attrs_.labelsDivStyles); this.labelsDiv_ = MochiKit.DOM.DIV(messagestyle); MochiKit.DOM.appendChildNodes(this.graphDiv, this.labelsDiv_); } @@ -257,13 +279,18 @@ DateGraph.prototype.createStatusMessage_ = function(){ */ DateGraph.prototype.createRollInterface_ = function() { var padding = this.plotter_.options.padding; + if (typeof this.attrs_.showRoller == 'undefined') { + this.attrs_.showRoller = false; + } + var display = this.attrs_.showRoller ? "block" : "none"; var textAttr = { "type": "text", "size": "2", "value": this.rollPeriod_, "style": { "position": "absolute", "zIndex": 10, "top": (this.height_ - 25 - padding.bottom) + "px", - "left": (padding.left+1) + "px" } + "left": (padding.left+1) + "px", + "display": display } }; var roller = MochiKit.DOM.INPUT(textAttr); var pa = this.graphDiv; @@ -290,8 +317,8 @@ DateGraph.prototype.createDragInterface_ = function() { var prevEndX = null; // Utility function to convert page-wide coordinates to canvas coords - var px = PlotKit.Base.findPosX(this.canvas_); - var py = PlotKit.Base.findPosY(this.canvas_); + var px = 0; + var py = 0; var getX = function(e) { return e.mouse().page.x - px }; var getY = function(e) { return e.mouse().page.y - py }; @@ -309,6 +336,8 @@ DateGraph.prototype.createDragInterface_ = function() { // Track the beginning of drag events connect(this.hidden_, 'onmousedown', function(event) { mouseDown = true; + px = PlotKit.Base.findPosX(self.canvas_); + py = PlotKit.Base.findPosY(self.canvas_); dragStartX = getX(event); dragStartY = getY(event); }); @@ -367,7 +396,9 @@ DateGraph.prototype.createDragInterface_ = function() { self.drawGraph_(self.rawData_); var minDate = self.rawData_[0][0]; var maxDate = self.rawData_[self.rawData_.length - 1][0]; - self.zoomCallback_(minDate, maxDate); + if (self.zoomCallback_) { + self.zoomCallback_(minDate, maxDate); + } }); }; @@ -426,7 +457,9 @@ DateGraph.prototype.doZoom_ = function(lowX, highX) { this.dateWindow_ = [minDate, maxDate]; this.drawGraph_(this.rawData_); - this.zoomCallback_(minDate, maxDate); + if (this.zoomCallback_) { + this.zoomCallback_(minDate, maxDate); + } }; /** @@ -467,7 +500,7 @@ DateGraph.prototype.mouseMove_ = function(event) { } // Clear the previously drawn vertical, if there is one - var circleSize = 3; + var circleSize = this.attrs_.highlightCircleSize; var ctx = this.canvas_.getContext("2d"); if (this.previousVerticalX_ >= 0) { var px = this.previousVerticalX_; @@ -520,6 +553,30 @@ DateGraph.prototype.mouseOut_ = function(event) { this.labelsDiv_.innerHTML = ""; }; +DateGraph.zeropad = function(x) { + if (x < 10) return "0" + x; else return "" + x; +} + +/** + * Return a string version of the hours, minutes and seconds portion of a date. + * @param {Number} date The JavaScript date (ms since epoch) + * @return {String} A time of the form "HH:MM:SS" + * @private + */ +DateGraph.prototype.hmsString_ = function(date) { + var zeropad = DateGraph.zeropad; + var d = new Date(date); + if (d.getSeconds()) { + return zeropad(d.getHours()) + ":" + + zeropad(d.getMinutes()) + ":" + + zeropad(d.getSeconds()); + } else if (d.getMinutes()) { + return zeropad(d.getHours()) + ":" + zeropad(d.getMinutes()); + } else { + return zeropad(d.getHours()); + } +} + /** * Convert a JS date (millis since epoch) to YYYY/MM/DD * @param {Number} date The JavaScript date (ms since epoch) @@ -527,18 +584,21 @@ DateGraph.prototype.mouseOut_ = function(event) { * @private */ DateGraph.prototype.dateString_ = function(date) { + var zeropad = DateGraph.zeropad; var d = new Date(date); // Get the year: var year = "" + d.getFullYear(); // Get a 0 padded month string - var month = "" + (d.getMonth() + 1); //months are 0-offset, sigh - if (month.length < 2) month = "0" + month; + var month = zeropad(d.getMonth() + 1); //months are 0-offset, sigh // Get a 0 padded day string - var day = "" + d.getDate(); - if (day.length < 2) day = "0" + day; + var day = zeropad(d.getDate()); + + var ret = ""; + var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); + if (frac) ret = " " + this.hmsString_(date); - return year + "/" + month + "/" + day; + return year + "/" + month + "/" + day + ret; }; /** @@ -584,7 +644,113 @@ DateGraph.prototype.addXTicks_ = function() { var xTicks = this.xTicker_(startDate, endDate); this.layout_.updateOptions({xTicks: xTicks}); -} +}; + +// Time granularity enumeration +DateGraph.SECONDLY = 0; +DateGraph.MINUTELY = 1; +DateGraph.HOURLY = 2; +DateGraph.DAILY = 3; +DateGraph.WEEKLY = 4; +DateGraph.MONTHLY = 5; +DateGraph.QUARTERLY = 6; +DateGraph.BIANNUAL = 7; +DateGraph.ANNUAL = 8; +DateGraph.DECADAL = 9; +DateGraph.NUM_GRANULARITIES = 10; + +DateGraph.SHORT_SPACINGS = []; +DateGraph.SHORT_SPACINGS[DateGraph.SECONDLY] = 1000 * 1; +DateGraph.SHORT_SPACINGS[DateGraph.MINUTELY] = 1000 * 60; +DateGraph.SHORT_SPACINGS[DateGraph.HOURLY] = 1000 * 3600; +DateGraph.SHORT_SPACINGS[DateGraph.DAILY] = 1000 * 86400; +DateGraph.SHORT_SPACINGS[DateGraph.WEEKLY] = 1000 * 604800; + +// NumXTicks() +// +// If we used this time granularity, how many ticks would there be? +// This is only an approximation, but it's generally good enough. +// +DateGraph.prototype.NumXTicks = function(start_time, end_time, granularity) { + if (granularity < DateGraph.MONTHLY) { + // Generate one tick mark for every fixed interval of time. + var spacing = DateGraph.SHORT_SPACINGS[granularity]; + return Math.floor(0.5 + 1.0 * (end_time - start_time) / spacing); + } else { + var year_mod = 1; // e.g. to only print one point every 10 years. + var num_months = 12; + if (granularity == DateGraph.QUARTERLY) num_months = 3; + if (granularity == DateGraph.BIANNUAL) num_months = 2; + if (granularity == DateGraph.ANNUAL) num_months = 1; + if (granularity == DateGraph.DECADAL) { num_months = 1; year_mod = 10; } + + var msInYear = 365.2524 * 24 * 3600 * 1000; + var num_years = 1.0 * (end_time - start_time) / msInYear; + return Math.floor(0.5 + 1.0 * num_years * num_months / year_mod); + } +}; + +// GetXAxis() +// +// Construct an x-axis of nicely-formatted times on meaningful boundaries +// (e.g. 'Jan 09' rather than 'Jan 22, 2009'). +// +// Returns an array containing {v: millis, label: label} dictionaries. +// +DateGraph.prototype.GetXAxis = function(start_time, end_time, granularity) { + var ticks = []; + if (granularity < DateGraph.MONTHLY) { + // Generate one tick mark for every fixed interval of time. + var spacing = DateGraph.SHORT_SPACINGS[granularity]; + var format = '%d%b'; // e.g. "1 Jan" + for (var t = start_time; t <= end_time; t += spacing) { + var d = new Date(t); + var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); + if (frac == 0 || granularity >= DateGraph.DAILY) { + // the extra hour covers DST problems. + ticks.push({ v:t, label: new Date(t + 3600*1000).strftime(format) }); + } else { + ticks.push({ v:t, label: this.hmsString_(t) }); + } + } + } else { + // Display a tick mark on the first of a set of months of each year. + // Years get a tick mark iff y % year_mod == 0. This is useful for + // displaying a tick mark once every 10 years, say, on long time scales. + var months; + var year_mod = 1; // e.g. to only print one point every 10 years. + + // TODO(danvk): use CachingRoundTime where appropriate to get boundaries. + if (granularity == DateGraph.MONTHLY) { + months = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]; + } else if (granularity == DateGraph.QUARTERLY) { + months = [ 0, 3, 6, 9 ]; + } else if (granularity == DateGraph.BIANNUAL) { + months = [ 0, 6 ]; + } else if (granularity == DateGraph.ANNUAL) { + months = [ 0 ]; + } else if (granularity == DateGraph.DECADAL) { + months = [ 0 ]; + year_mod = 10; + } + + var start_year = new Date(start_time).getFullYear(); + var end_year = new Date(end_time).getFullYear(); + var zeropad = DateGraph.zeropad; + for (var i = start_year; i <= end_year; i++) { + if (i % year_mod != 0) continue; + for (var j = 0; j < months.length; j++) { + var date_str = i + "/" + zeropad(1 + months[j]) + "/01"; + var t = Date.parse(date_str); + if (t < start_time || t > end_time) continue; + ticks.push({ v:t, label: new Date(t).strftime('%b %y') }); + } + } + } + + return ticks; +}; + /** * Add ticks to the x-axis based on a date range. @@ -594,59 +760,20 @@ DateGraph.prototype.addXTicks_ = function() { * @public */ DateGraph.prototype.dateTicker = function(startDate, endDate) { - var ONE_DAY = 24*60*60*1000; - startDate = startDate / ONE_DAY; - endDate = endDate / ONE_DAY; - var dateSpan = endDate - startDate; - - var scale = []; - var isMonthly = false; - var yearMod = 1; - if (dateSpan > 30 * 366) { // decadal - isMonthly = true; - scale = ["Jan"]; - yearMod = 10; - } else if (dateSpan > 4*366) { // annual - scale = ["Jan"]; - isMonthly = true; - } else if (dateSpan > 366) { // quarterly - scale = this.quarters; - isMonthly = true; - } else if (dateSpan > 40) { // monthly - scale = this.months; - isMonthly = true; - } else if (dateSpan > 10) { // weekly - for (var week = startDate - 14; week < endDate + 14; week += 7) { - scale.push(week * ONE_DAY); - } - } else { // daily - for (var day = startDate - 14; day < endDate + 14; day += 1) { - scale.push(day * ONE_DAY); + var chosen = -1; + for (var i = 0; i < DateGraph.NUM_GRANULARITIES; i++) { + var num_ticks = this.NumXTicks(startDate, endDate, i); + if (this.width_ / num_ticks >= this.attrs_.pixelsPerXLabel) { + chosen = i; + break; } } - var xTicks = []; - - if (isMonthly) { - var startYear = 1900 + (new Date(startDate* ONE_DAY)).getYear(); - var endYear = 1900 + (new Date(endDate * ONE_DAY)).getYear(); - for (var i = startYear; i <= endYear; i++) { - if (i % yearMod != 0) continue; - for (var j = 0; j < scale.length; j++ ) { - var date = Date.parse(scale[j] + " 1, " + i); - xTicks.push( {label: scale[j] + "'" + ("" + i).substr(2,2), v: date } ); - } - } + if (chosen >= 0) { + return this.GetXAxis(startDate, endDate, chosen); } else { - for (var i = 0; i < scale.length; i++) { - var date = new Date(scale[i]); - var year = date.getFullYear().toString(); - var label = this.months[date.getMonth()] + date.getDate(); - label += "'" + year.substr(year.length - 2, 2); - xTicks.push( {label: label, v: date} ); - } + // TODO(danvk): signal error. } - return xTicks; }; /** @@ -657,26 +784,34 @@ DateGraph.prototype.dateTicker = function(startDate, endDate) { * @public */ DateGraph.prototype.numericTicks = function(minV, maxV) { - var scale; - if (maxV <= 0.0) { - scale = 1.0; - } else { - scale = Math.pow( 10, Math.floor(Math.log(maxV)/Math.log(10.0)) ); - } - - // Add a smallish number of ticks at human-friendly points - var nTicks = (maxV - minV) / scale; - while (2 * nTicks < 20) { - nTicks *= 2; - } - if ((maxV - minV) / nTicks < this.minTickSize_) { - nTicks = this.round_((maxV - minV) / this.minTickSize_, 1); + // Basic idea: + // Try labels every 1, 2, 5, 10, 20, 50, 100, etc. + // Calculate the resulting tick spacing (i.e. this.height_ / nTicks). + // The first spacing greater than this.attrs_.pixelsPerYLabel is what we use. + var mults = [1, 2, 5]; + var scale, low_val, high_val, nTicks; + for (var i = -10; i < 50; i++) { + var base_scale = Math.pow(10, i); + for (var j = 0; j < mults.length; j++) { + scale = base_scale * mults[j]; + console.log("i/j/scale: " + i + "/" + j + "/" + scale); + low_val = Math.floor(minV / scale) * scale; + high_val = Math.ceil(maxV / scale) * scale; + nTicks = (high_val - low_val) / scale; + var spacing = this.height_ / nTicks; + // wish I could break out of both loops at once... + if (spacing > this.attrs_.pixelsPerYLabel) break; + } + if (spacing > this.attrs_.pixelsPerYLabel) break; } + console.log("scale: " + scale); + console.log("low_val: " + low_val); + console.log("high_val: " + high_val); // Construct labels for the ticks var ticks = []; - for (var i = 0; i <= nTicks; i++) { - var tickV = minV + i * (maxV - minV) / nTicks; + for (var i = 0; i < nTicks; i++) { + var tickV = low_val + i * scale; var label = this.round_(tickV, 2); if (this.labelsKMB_) { var k = 1000; @@ -768,10 +903,6 @@ DateGraph.prototype.drawGraph_ = function(data) { // Add some padding and round up to an integer to be human-friendly. maxY *= 1.1; if (maxY <= 0.0) maxY = 1.0; - else { - var scale = Math.pow(10, Math.floor(Math.log(maxY) / Math.log(10.0))); - maxY = scale * Math.ceil(maxY / scale); - } this.addYTicks_(0, maxY); } @@ -841,12 +972,29 @@ DateGraph.prototype.rollingAverage = function(originalData, rollPeriod) { } } } else if (this.customBars_) { - // just ignore the rolling for now. - // TODO(danvk): do something reasonable. + var low = 0; + var mid = 0; + var high = 0; + var count = 0; for (var i = 0; i < originalData.length; i++) { var data = originalData[i][1]; var y = data[1]; rollingData[i] = [originalData[i][0], [y, y - data[0], data[2] - y]]; + + low += data[0]; + mid += y; + high += data[2]; + count += 1; + if (i - rollPeriod >= 0) { + var prev = originalData[i - rollPeriod]; + low -= prev[1][0]; + mid -= prev[1][1]; + high -= prev[1][2]; + count -= 1; + } + rollingData[i] = [originalData[i][0], [ 1.0 * mid / count, + 1.0 * (mid - low) / count, + 1.0 * (high - mid) / count ]]; } } else { // Calculate the rolling average for the first rollPeriod - 1 points where @@ -909,15 +1057,21 @@ DateGraph.prototype.rollingAverage = function(originalData, rollPeriod) { */ DateGraph.prototype.dateParser = function(dateStr) { var dateStrSlashed; - if (dateStr.search("-") != -1) { + if (dateStr.length == 10 && dateStr.search("-") != -1) { // e.g. '2009-07-12' dateStrSlashed = dateStr.replace("-", "/", "g"); - } else if (dateStr.search("/") != -1) { - return Date.parse(dateStr); - } else { + while (dateStrSlashed.search("-") != -1) { + dateStrSlashed = dateStrSlashed.replace("-", "/"); + } + return Date.parse(dateStrSlashed); + } else if (dateStr.length == 8) { // e.g. '20090712' dateStrSlashed = dateStr.substr(0,4) + "/" + dateStr.substr(4,2) + "/" + dateStr.substr(6,2); + return Date.parse(dateStrSlashed); + } else { + // Any format that Date.parse will accept, e.g. "2009/07/12" or + // "2009/07/12 12:34:56" + return Date.parse(dateStr); } - return Date.parse(dateStrSlashed); }; /** @@ -985,6 +1139,56 @@ DateGraph.prototype.parseCSV_ = function(data) { }; /** + * Parses a DataTable object from gviz. + * The data is expected to have a first column that is either a date or a + * number. All subsequent columns must be numbers. If there is a clear mismatch + * between this.xValueParser_ and the type of the first column, it will be + * fixed. Returned value is in the same format as return value of parseCSV_. + * @param {Array.} data See above. + * @private + */ +DateGraph.prototype.parseDataTable_ = function(data) { + var cols = data.getNumberOfColumns(); + var rows = data.getNumberOfRows(); + + // Read column labels + var labels = []; + for (var i = 0; i < cols; i++) { + labels.push(data.getColumnLabel(i)); + } + labels.shift(); // the x-axis parameter is assumed and unnamed. + this.labels_ = labels; + // regenerate automatic colors. + this.setColors_(this.attrs_); + this.renderOptions_.colorScheme = this.colors_; + MochiKit.Base.update(this.plotter_.options, this.renderOptions_); + MochiKit.Base.update(this.layoutOptions_, this.attrs_); + + var indepType = data.getColumnType(0); + if (indepType != 'date' && indepType != 'number') { + // TODO(danvk): standardize error reporting. + alert("only 'date' and 'number' types are supported for column 1" + + "of DataTable input (Got '" + indepType + "')"); + return null; + } + + var ret = []; + for (var i = 0; i < rows; i++) { + var row = []; + if (indepType == 'date') { + row.push(data.getValue(i, 0).getTime()); + } else { + row.push(data.getValue(i, 0)); + } + for (var j = 1; j < cols; j++) { + row.push(data.getValue(i, j)); + } + ret.push(row); + } + return ret; +} + +/** * Get the CSV data. If it's in a function, call that function. If it's in a * file, do an XMLHttpRequest to get it. * @private @@ -993,6 +1197,11 @@ DateGraph.prototype.start_ = function() { if (typeof this.file_ == 'function') { // Stubbed out to allow this to run off a filesystem this.loadedEvent_(this.file_()); + } else if (typeof this.file_ == 'object' && + typeof this.file_.getColumnRange == 'function') { + // must be a DataTable from gviz. + this.rawData_ = this.parseDataTable_(this.file_); + this.drawGraph_(this.rawData_); } else { var req = new XMLHttpRequest(); var caller = this; @@ -1036,9 +1245,7 @@ DateGraph.prototype.updateOptions = function(attrs) { if (attrs.valueRange) { this.valueRange_ = attrs.valueRange; } - if (attrs.minTickSize) { - this.minTickSize_ = attrs.minTickSize; - } + MochiKit.Base.update(this.attrs_, attrs); if (typeof(attrs.labels) != 'undefined') { this.labels_ = attrs.labels; this.labelsFromCSV_ = (attrs.labels == null); @@ -1061,3 +1268,17 @@ DateGraph.prototype.adjustRoll = function(length) { this.rollPeriod_ = length; this.drawGraph_(this.rawData_); }; + + +/** + * A wrapper around DateGraph that implements the gviz API. + * @param {Object} container The DOM object the visualization should live in. + */ +DateGraph.GVizChart = function(container) { + this.container = container; +} + +DateGraph.GVizChart.prototype.draw = function(data, options) { + this.container.innerHTML = ''; + this.date_graph = new DateGraph(this.container, data, null, options || {}); +}