diff --git a/package.json b/package.json index ad6ce22cee48..67999eaa7fab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "bluebird": "~2.0.7", "connect": "~2.19.5", "event-stream": "~3.1.5", - "expect.js": "~0.2.0", + "expect.js": "~0.3.1", "grunt": "~0.4.5", "grunt-contrib-clean": "~0.5.0", "grunt-contrib-compress": "~0.9.1", diff --git a/src/kibana/components/reflow_watcher.js b/src/kibana/components/reflow_watcher.js new file mode 100644 index 000000000000..6b91c1f60ccf --- /dev/null +++ b/src/kibana/components/reflow_watcher.js @@ -0,0 +1,68 @@ +define(function (require) { + return function ReflowWatcherService(Private, $rootScope, $http) { + var angular = require('angular'); + var $ = require('jquery'); + var _ = require('lodash'); + + var EventEmitter = Private(require('factories/events')); + var $body = $(document.body); + var $window = $(window); + + var MOUSE_EVENTS = 'mouseup'; + var WINDOW_EVENTS = 'resize'; + + _(ReflowWatcher).inherits(EventEmitter); + /** + * Watches global activity which might hint at a change in the content, which + * in turn provides a hint to resizers that they should check their size + */ + function ReflowWatcher() { + ReflowWatcher.Super.call(this); + + // bound version of trigger that can be used as a handler + this.trigger = _.bind(this.trigger, this); + this._emitReflow = _.bind(this._emitReflow, this); + + // list of functions to call that will unbind our watchers + this._unwatchers = [ + $rootScope.$watchCollection(function () { + return $http.pendingRequests; + }, this.trigger) + ]; + + $body.on(MOUSE_EVENTS, this.trigger); + $window.on(WINDOW_EVENTS, this.trigger); + } + + /** + * Simply emit reflow, but in a way that can be bound and passed to + * other functions. Using _.bind caused extra arguments to be added, and + * then emitted to other places. No Bueno + * + * @return {void} + */ + ReflowWatcher.prototype._emitReflow = function () { + this.emit('reflow'); + }; + + /** + * Emit the "reflow" event in the next tick of the digest cycle + * @return {void} + */ + ReflowWatcher.prototype.trigger = function () { + $rootScope.$evalAsync(this._emitReflow); + }; + + /** + * Signal to the ReflowWatcher that it should clean up it's listeners + * @return {void} + */ + ReflowWatcher.prototype.destroy = function () { + $body.off(MOUSE_EVENTS, this.trigger); + $window.off(WINDOW_EVENTS, this.trigger); + _.callEach(this._unwatchers); + }; + + return new ReflowWatcher(); + }; +}); \ No newline at end of file diff --git a/src/kibana/components/vislib/lib/resize_checker.js b/src/kibana/components/vislib/lib/resize_checker.js new file mode 100644 index 000000000000..36ae3e25f64a --- /dev/null +++ b/src/kibana/components/vislib/lib/resize_checker.js @@ -0,0 +1,209 @@ +define(function (require) { + return function ResizeCheckerFactory(Private, Notifier) { + var $ = require('jquery'); + var _ = require('lodash'); + + var EventEmitter = Private(require('factories/events')); + var reflowWatcher = Private(require('components/reflow_watcher')); + var sequencer = require('utils/sequencer'); + + var SCHEDULE_LONG = ResizeChecker.SCHEDULE_LONG = sequencer.createEaseOut( + 250, // shortest delay + 10000, // longest delay + 150 // tick count + ); + + var SCHEDULE_SHORT = ResizeChecker.SCHEDULE_SHORT = sequencer.createEaseIn( + 5, // shortest delay + 500, // longest delay + 100 // tick count + ); + + // maximum ms that we can delay emitting 'resize'. This is only used + // to debounce resizes when the size of the element is constantly changing + var MS_MAX_RESIZE_DELAY = ResizeChecker.MS_MAX_RESIZE_DELAY = 500; + + /** + * Checks the size of an element on a regular basis. Provides + * an event that is emited when the element has changed size. + * + * @class ResizeChecker + * @param {HtmlElement} el - the element to track the size of + */ + _(ResizeChecker).inherits(EventEmitter); + function ResizeChecker(el) { + ResizeChecker.Super.call(this); + + this.$el = $(el); + this.notify = new Notifier({ location: 'Vislib ResizeChecker ' + _.uniqueId() }); + + this.saveSize(); + + this.check = _.bind(this.check, this); + this.check(); + + this.onReflow = _.bind(this.onReflow, this); + reflowWatcher.on('reflow', this.onReflow); + } + + ResizeChecker.prototype.onReflow = function () { + this.startSchedule(SCHEDULE_LONG); + }; + + /** + * Read the size of the element + * + * @method read + * @return {object} - an object with keys `w` (width) and `h` (height) + */ + ResizeChecker.prototype.read = function () { + return { + w: this.$el.width(), + h: this.$el.height() + }; + }; + + + /** + * Save the element size, preventing it from being considered as an + * update. + * + * @method save + * @param {object} [size] - optional size to save, otherwise #read() is called + * @return {boolean} - true if their was a change in the new + */ + ResizeChecker.prototype.saveSize = function (size) { + if (!size) size = this.read(); + + if (this._equalsSavedSize(size)) { + return false; + } + + this._savedSize = size; + return true; + }; + + + /** + * Determine if a given size matches the currently saved size. + * + * @private + * @method _equalsSavedSize + * @param {object} a - an object that matches the return value of #read() + * @return {boolean} - true if the passed in value matches the saved size + */ + ResizeChecker.prototype._equalsSavedSize = function (a) { + var b = this._savedSize || {}; + return a.w === b.w && a.h === b.h; + }; + + /** + * Read the time that the dirty state last changed. + * + * @method lastDirtyChange + * @return {timestamp} - the unix timestamp (in ms) of the last update + * to the dirty state + */ + ResizeChecker.prototype.lastDirtyChange = function () { + return this._dirtyChangeStamp; + }; + + /** + * Record the dirty state + * + * @method saveDirty + * @param {boolean} val + * @return {boolean} - true if the dirty state changed by this save + */ + ResizeChecker.prototype.saveDirty = function (val) { + val = !!val; + + if (val === this._isDirty) return false; + + this._isDirty = val; + this._dirtyChangeStamp = Date.now(); + return true; + }; + + /** + * The check routine that executes regularly and will reschedule itself + * to run again in the future. It determines the state of the elements + * size and decides when to emit the "update" event. + * + * @method check + * @return {void} + */ + ResizeChecker.prototype.check = function () { + var newSize = this.read(); + var dirty = this.saveSize(newSize); + var dirtyChanged = this.saveDirty(dirty); + + var doneDirty = !dirty && dirtyChanged; + var muchDirty = dirty && (this.lastDirtyChange() - Date.now() > MS_MAX_RESIZE_DELAY); + if (doneDirty || muchDirty) { + this.emit('resize', newSize); + } + + // if the dirty state is unchanged, continue using the previous schedule + if (!dirtyChanged) { + return this.continueSchedule(); + } + + // when the state changes start a new schedule. Use a schedule that quickly + // slows down if it is unknown wether there are will be additional changes + return this.startSchedule(dirty ? SCHEDULE_SHORT : SCHEDULE_LONG); + }; + + /** + * Start running a new schedule, using one of the SCHEDULE_* constants. + * + * @method startSchedule + * @param {integer[]} schedule - an array of millisecond times that should + * be used to schedule calls to #check(); + * @return {integer} - the id of the next timer + */ + ResizeChecker.prototype.startSchedule = function (schedule) { + this._tick = -1; + this._currentSchedule = schedule; + return this.continueSchedule(); + }; + + /** + * Continue running the current schedule. MUST BE CALLED AFTER #startSchedule() + * + * @method continueSchedule + * @return {integer} - the id of the next timer + */ + ResizeChecker.prototype.continueSchedule = function () { + clearTimeout(this._timerId); + + if (this._tick < this._currentSchedule.length - 1) { + // at the end of the schedule, don't progress any further but repeat the last value + this._tick += 1; + } + + var check = this.check; // already bound + var tick = this._tick; + var notify = this.notify; + var ms = this._currentSchedule[this._tick]; + return (this._timerId = setTimeout(function () { + check(); + }, ms)); + }; + + /** + * Signal that the ResizeChecker should shutdown. + * + * Cleans up it's listeners and timers. + * + * @method destroy + * @return {void} + */ + ResizeChecker.prototype.destroy = function () { + reflowWatcher.off('reflow', this.check); + clearTimeout(this._timerId); + }; + + return ResizeChecker; + }; +}); \ No newline at end of file diff --git a/src/kibana/components/vislib/vis.js b/src/kibana/components/vislib/vis.js index 4d36b6703fe8..e6a492435c0e 100644 --- a/src/kibana/components/vislib/vis.js +++ b/src/kibana/components/vislib/vis.js @@ -4,6 +4,7 @@ define(function (require) { var _ = require('lodash'); var Handler = Private(require('components/vislib/lib/handler')); + var ResizeChecker = Private(require('components/vislib/lib/resize_checker')); var Events = Private(require('factories/events')); var chartTypes = Private(require('components/vislib/vis_types')); @@ -24,6 +25,12 @@ define(function (require) { this.el = $el.get ? $el.get(0) : $el; this.ChartClass = chartTypes[config.type]; this._attr = _.defaults(config || {}, {}); + + // bind the resize function so it can be used as an event handler + this.resize = _.bind(this.resize, this); + + this.resizeChecker = new ResizeChecker(this.el); + this.resizeChecker.on('resize', this.resize); } // Exposed API for rendering charts. @@ -49,24 +56,8 @@ define(function (require) { console.group(error.message); } } - - this.checkSize(); }; - // Check for changes to the chart container height and width. - Vis.prototype.checkSize = _.debounce(function () { - if (arguments.length) { return; } - - // enable auto-resize - var size = $(this.el).find('.chart').width() + ':' + $(this.el).find('.chart').height(); - - if (this.prevSize !== size) { - this.resize(); - } - this.prevSize = size; - setTimeout(this.checkSize(), 250); - }, 250); - // Resize the chart Vis.prototype.resize = function () { if (!this.data) { @@ -78,16 +69,14 @@ define(function (require) { // Destroy the chart Vis.prototype.destroy = function () { - // Turn off checkSize - this.checkSize(false); - // Removing chart and all elements associated with it d3.select(this.el).selectAll('*').remove(); - // Cleaning up event listeners - this.off('click', null); - this.off('hover', null); - this.off('brush', null); + // remove event listeners + this.resizeChecker.off('resize', this.resize); + + // pass destroy call down to owned objects + this.resizeChecker.destroy(); }; // Set attributes on the chart diff --git a/src/kibana/utils/_mixins.js b/src/kibana/utils/_mixins.js index bec42fd00b28..35a5a51b3bb2 100644 --- a/src/kibana/utils/_mixins.js +++ b/src/kibana/utils/_mixins.js @@ -150,6 +150,15 @@ define(function (require) { // always call flush, it might not do anything flush(this, args); }; + }, + chunk: function (arr, count) { + var size = Math.ceil(arr.length / count); + var chunks = new Array(count); + for (var i = 0; i < count; i ++) { + var start = i * size; + chunks[i] = arr.slice(start, start + size); + } + return chunks; } }); diff --git a/src/kibana/utils/sequencer.js b/src/kibana/utils/sequencer.js new file mode 100644 index 000000000000..28189cac3858 --- /dev/null +++ b/src/kibana/utils/sequencer.js @@ -0,0 +1,95 @@ +define(function (require) { + var _ = require('lodash'); + + function create(min, max, length, mod) { + var seq = new Array(length); + + var valueDist = max - min; + + // range of values that the mod creates + var modRange = [mod(0, length), mod(length - 1, length)]; + + // distance between + var modRangeDist = modRange[1] - modRange[0]; + + _.times(length, function (i) { + var modIPercent = (mod(i, length) - modRange[0]) / modRangeDist; + + // percent applied to distance and added to min to + // produce value + seq[i] = min + (valueDist * modIPercent); + }); + + seq.min = min; + seq.max = max; + + return seq; + } + + return { + /** + * Create an exponential sequence of numbers. + * + * Creates a curve resembling: + * + * ; + * / + * / + * .-' + * _.-" + * _.-'" + * _,.-'" + * _,..-'" + * _,..-'"" + * _,..-'"" + * ____,..--'"" + * + * @param {number} min - the min value to produce + * @param {number} max - the max value to produce + * @param {number} length - the number of values to produce + * @return {number[]} - an array containing the sequence + */ + createEaseIn: _.partialRight(create, function (i, length) { + // generates numbers from 1 to +Infinity + return i * Math.pow(i, 1.1111); + }), + + /** + * Create an sequence of numbers using sine. + * + * Create a curve resembling: + * + * ____,..--'"" + * _,..-'"" + * _,..-'"" + * _,..-'" + * _,.-'" + * _.-'" + * _.-" + * .-' + * / + * / + * ; + * + * + * @param {number} min - the min value to produce + * @param {number} max - the max value to produce + * @param {number} length - the number of values to produce + * @return {number[]} - an array containing the sequence + */ + createEaseOut: _.partialRight(create, function (i, length) { + // adapted from output of http://www.timotheegroleau.com/Flash/experiments/easing_function_generator.htm + // generates numbers from 0 to 100 + + var ts = (i /= length) * i; + var tc = ts * i; + return 100 * ( + 0.5 * tc * ts + + -3 * ts * ts + + 6.5 * tc + + -7 * ts + + 4 * i + ); + }) + }; +}); \ No newline at end of file diff --git a/test/unit/index.html b/test/unit/index.html index c1d8724ec15f..53ea56d19fba 100644 --- a/test/unit/index.html +++ b/test/unit/index.html @@ -3,7 +3,7 @@