Added new resizeChecker, and tests

This commit is contained in:
Spencer Alger 2014-09-10 19:41:38 -07:00
parent 9b1dcd8c25
commit 12493cddd1
11 changed files with 778 additions and 27 deletions

View file

@ -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",

View file

@ -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();
};
});

View file

@ -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;
};
});

View file

@ -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

View file

@ -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;
}
});

View file

@ -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
);
})
};
});

View file

@ -3,7 +3,7 @@
<head>
<title>Kibana4 Tests</title>
<link rel="stylesheet" href="/node_modules/mocha/mocha.css" />
<script src="/node_modules/expect.js/expect.js"></script>
<script src="/node_modules/expect.js/index.js"></script>
<script src="/node_modules/mocha/mocha.js"></script>
<script src="/src/bower_components/requirejs/require.js"></script>
<script src="/src/kibana/require.config.js"></script>
@ -79,6 +79,7 @@
'specs/utils/interval',
'specs/utils/versionmath',
'specs/utils/routes/index',
'specs/utils/sequencer',
'specs/courier/search_source/_get_normalized_sort',
'specs/factories/base_object',
'specs/state_management/state',
@ -103,13 +104,15 @@
'specs/vislib/handler',
'specs/vislib/_error_handler',
'specs/vislib/data',
'specs/vislib/resize_checker',
'specs/utils/diff_time_picker_vals',
'specs/factories/events',
'specs/index_patterns/_flatten_search_response',
'specs/utils/registry/index',
'specs/directives/filter_bar',
'specs/components/agg_types/index',
'specs/components/vis/index'
'specs/components/vis/index',
'specs/components/reflow_watcher'
], function (kibana, sinon) {
kibana.load(function () {
var xhr = sinon.useFakeXMLHttpRequest();

View file

@ -0,0 +1,78 @@
define(function (require) {
describe('Reflow watcher', function () {
require('angular');
var $ = require('jquery');
var _ = require('lodash');
var sinon = require('test_utils/auto_release_sinon');
var $body = $(document.body);
var $window = $(window);
var expectStubbedEventAndEl = function (stub, event, $el) {
expect(stub.getCalls().some(function (call) {
var events = call.args[0].split(' ');
return _.contains(events, event) && $el.is(call.thisValue);
})).to.be(true);
};
var EventEmitter;
var reflowWatcher;
var $rootScope;
var $onStub;
beforeEach(module('kibana'));
beforeEach(inject(function (Private, $injector) {
$rootScope = $injector.get('$rootScope');
EventEmitter = Private(require('factories/events'));
// stub jQuery's $.on method while creating the reflowWatcher
$onStub = sinon.stub($.fn, 'on');
reflowWatcher = Private(require('components/reflow_watcher'));
$onStub.restore();
// setup the reflowWatchers $http watcher
$rootScope.$apply();
}));
it('is an event emitter', function () {
expect(reflowWatcher).to.be.an(EventEmitter);
});
describe('listens', function () {
it('to "mouseup" on the body', function () {
expectStubbedEventAndEl($onStub, 'mouseup', $body);
});
it('to "resize" on the window', function () {
expectStubbedEventAndEl($onStub, 'resize', $window);
});
});
describe('un-listens in #destroy()', function () {
var $offStub;
beforeEach(function () {
$offStub = sinon.stub($.fn, 'off');
reflowWatcher.destroy();
$offStub.restore();
});
it('to "mouseup" on the body', function () {
expectStubbedEventAndEl($offStub, 'mouseup', $body);
});
it('to "resize" on the window', function () {
expectStubbedEventAndEl($offStub, 'resize', $window);
});
});
it('triggers the "reflow" event within a new angular tick', function () {
var stub = sinon.stub();
reflowWatcher.on('reflow', stub);
reflowWatcher.trigger();
expect(stub).to.have.property('callCount', 0);
$rootScope.$apply();
expect(stub).to.have.property('callCount', 1);
});
});
});

View file

@ -0,0 +1,101 @@
define(function (require) {
describe('sequencer util', function () {
var _ = require('lodash');
var sequencer = require('utils/sequencer');
var args = [
{ min: 500, max: 7500, length: 1500 },
{ min: 50, max: 500, length: 1000 },
{ min: 5, max: 50, length: 100 }
];
function eachSeqFor(method, fn) {
args.forEach(function (args) {
fn(method(args.min, args.max, args.length), args);
});
}
function getSlopes(seq, count) {
return _.chunk(seq, count).map(function (chunk) {
return (_.last(chunk) - _.first(chunk)) / chunk.length;
});
}
// using expect() here causes massive GC runs because seq can be +1000 elements
function expectedChange(seq, up) {
up = !!up;
if (seq.length < 2) {
throw new Error('unable to reach change without at least two elements');
}
seq.forEach(function (n, i) {
if (i > 0 && (seq[i - 1] < n) !== up) {
throw new Error('expected values to ' + (up ? 'increase': 'decrease'));
}
});
}
function generalTests(seq, args) {
it('obeys the min arg', function () {
expect(Math.min.apply(Math, seq)).to.be(args.min);
});
it('obeys the max arg', function () {
expect(Math.max.apply(Math, seq)).to.be(args.max);
});
it('obeys the length arg', function () {
expect(seq).to.have.length(args.length);
});
it('always creates increasingly larger values', function () {
expectedChange(seq, true);
});
}
describe('#createEaseIn', function () {
eachSeqFor(sequencer.createEaseIn, function (seq, args) {
describe('with args: ' + JSON.stringify(args), function () {
generalTests(seq, args);
it('produces increasing slopes', function () {
expectedChange(getSlopes(seq, 2), true);
expectedChange(getSlopes(seq, 4), true);
expectedChange(getSlopes(seq, 6), true);
});
});
});
});
describe('#createEaseOut', function () {
eachSeqFor(sequencer.createEaseOut, function (seq, args) {
describe('with args: ' + JSON.stringify(args), function () {
generalTests(seq, args);
it('produces decreasing slopes', function () {
expectedChange(getSlopes(seq, 2), false);
expectedChange(getSlopes(seq, 4), false);
expectedChange(getSlopes(seq, 6), false);
});
// Flipped version of previous test to ensure that expectedChange()
// and friends are behaving properly
it('doesn\'t produce increasing slopes', function () {
expect(function () {
expectedChange(getSlopes(seq, 2), true);
}).to.throwError();
expect(function () {
expectedChange(getSlopes(seq, 4), true);
}).to.throwError();
expect(function () {
expectedChange(getSlopes(seq, 6), true);
}).to.throwError();
});
});
});
});
});
});

View file

@ -104,7 +104,7 @@ define(function (require) {
addLegend: true
};
vis = new Vis(el, config);
vis = new Vis(el[0][0], config);
handler = new Handler({
vis: vis,

View file

@ -0,0 +1,199 @@
define(function (require) {
describe('Vislib Resize Checker', function () {
var $ = require('jquery');
var _ = require('lodash');
var Promise = require('bluebird');
var sinon = require('test_utils/auto_release_sinon');
require('test_utils/no_digest_promises').activateForSuite();
var ResizeChecker;
var EventEmitter;
var checker;
var reflowWatcher;
var spyReflowOn;
beforeEach(module('kibana'));
beforeEach(inject(function (Private) {
ResizeChecker = Private(require('components/vislib/lib/resize_checker'));
EventEmitter = Private(require('factories/events'));
reflowWatcher = Private(require('components/reflow_watcher'));
spyReflowOn = sinon.spy(reflowWatcher, 'on');
checker = new ResizeChecker(
$(document.createElement('div'))
.appendTo('body')
.css('visibility', 'hidden')
.get(0)
);
spyReflowOn.restore();
}));
afterEach(function () {
checker.$el.remove();
checker.destroy();
});
it('is an event emitter', function () {
expect(checker).to.be.a(EventEmitter);
});
it('emits a "resize" event when the el is resized', function (done) {
checker.on('resize', function () {
done();
});
checker.$el.text('haz contents');
checker.check();
});
it('listens for the "reflow" event of the reflowWatchers', function () {
expect(spyReflowOn).to.have.property('callCount', 1);
var call = spyReflowOn.getCall(0);
expect(call.args[0]).to.be('reflow');
});
describe('#read', function () {
it('uses jquery to get the width and height of the element', function () {
var stubw = sinon.spy($.fn, 'width');
var stubh = sinon.spy($.fn, 'height');
checker.read();
expect(stubw).to.have.property('callCount', 1);
expect(stubw.getCall(0)).to.have.property('thisValue', checker.$el);
expect(stubh).to.have.property('callCount', 1);
expect(stubh.getCall(0)).to.have.property('thisValue', checker.$el);
});
});
describe('#saveSize', function () {
it('calls #read() when no arg is passed', function () {
var stub = sinon.stub(checker, 'read').returns({});
checker.saveSize();
expect(stub).to.have.property('callCount', 1);
});
it('saves the size of the element', function () {
var football = {};
checker.saveSize(football);
expect(checker).to.have.property('_savedSize', football);
});
it('returns false if the size matches the previous value', function () {
expect(checker.saveSize(checker._savedSize)).to.be(false);
});
it('returns true if the size is different than previous value', function () {
expect(checker.saveSize({})).to.be(true);
});
});
describe('#check()', function () {
var emit;
beforeEach(function () {
emit = sinon.stub(checker, 'emit');
// prevent the checker from auto-checking
checker.destroy();
checker.startSchedule = checker.continueSchedule = _.noop;
});
it('does not emit "resize" immediately after a resize, but waits for changes to stop', function () {
expect(checker).to.have.property('_isDirty', false);
checker.$el.css('height', 100);
checker.check();
expect(checker).to.have.property('_isDirty', true);
expect(emit).to.have.property('callCount', 0);
// no change in el size
checker.check();
expect(checker).to.have.property('_isDirty', false);
expect(emit).to.have.property('callCount', 1);
});
it('emits "resize" based on MS_MAX_RESIZE_DELAY, even if el\'s constantly changing size', function () {
var steps = _.random(5, 10);
this.slow(steps * 10);
// we are going to fake the delay using the fake clock
var msStep = Math.floor(ResizeChecker.MS_MAX_RESIZE_DELAY / (steps - 1));
var clock = sinon.useFakeTimers();
_.times(steps, function step(i) {
checker.$el.css('height', 100 + i);
checker.check();
expect(checker).to.have.property('_isDirty', true);
expect(emit).to.have.property('callCount', i > steps ? 1 : 0);
clock.tick(msStep); // move the clock forward one step
});
});
});
describe('scheduling', function () {
var clock;
var schedule;
beforeEach(function () {
// prevent the checker from running automatically
checker.destroy();
clock = sinon.useFakeTimers();
schedule = [];
_.times(25, function () {
schedule.push(_.random(3, 250));
});
});
it('walks the schedule, using each value as it\'s next timeout', function () {
var timerId = checker.startSchedule(schedule);
// start at 0 even though "start" used the first slot, we will still check it
for (var i = 0; i < schedule.length; i++) {
expect(clock.timeouts[timerId]).to.have.property('callAt', schedule[i]);
timerId = checker.continueSchedule();
}
});
it('repeats the last value in the schedule', function () {
var timerId = checker.startSchedule(schedule);
// start at 1, and go until there is one left
for (var i = 1; i < schedule.length - 1; i++) {
timerId = checker.continueSchedule();
}
var last = _.last(schedule);
_.times(5, function () {
var timer = clock.timeouts[checker.continueSchedule()];
expect(timer).to.have.property('callAt', last);
});
});
});
describe('#destroy()', function () {
it('removes the "reflow" event from the reflowWatcher', function () {
var stub = sinon.stub(reflowWatcher, 'off');
checker.destroy();
expect(stub).to.have.property('callCount', 1);
expect(stub.calledWith('reflow')).to.be.ok();
});
it('clears the timeout', function () {
var spy = sinon.spy(window, 'clearTimeout');
checker.destroy();
expect(spy).to.have.property('callCount', 1);
});
});
});
});