Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[amp-analytics] More visibilitySpec vars. #3371

Merged
merged 3 commits into from
Jun 2, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/analytics.amp.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"base": "https://example.com/?domain=${canonicalHost}&path=${canonicalPath}&title=${title}&time=${timestamp}&tz=${timezone}&pid=${pageViewId}&_=${random}",
"pageview": "${base}&name=${eventName}&type=${eventId}&screenSize=${screenWidth}x${screenHeight}",
"event": "${base}&name=${eventName}&scrollY=${scrollTop}&scrollX=${scrollLeft}&height=${availableScreenHeight}&width=${availableScreenWidth}&scrollBoundV=${verticalScrollBoundary}&scrollBoundH=${horizontalScrollBoundary}",
"visibility": "https://example.com/visibility?a=${maxContinuousTime}&b=${totalVisibleTime}&c=${firstSeenTime}&d=${lastSeenTime}&e=${fistVisibleTime}&f=${lastVisibleTime}&g=${minVisiblePercentage}&h=${maxVisiblePercentage}"
"visibility": "https://example.com/visibility?a=${maxContinuousTime}&b=${totalVisibleTime}&c=${firstSeenTime}&d=${lastSeenTime}&e=${fistVisibleTime}&f=${lastVisibleTime}&g=${minVisiblePercentage}&h=${maxVisiblePercentage}&i=${elementX}&j=${elementY}&k=${elementWidth}&l=${elementHeight}&m=${totalTime}&n=${loadTimeVisibility}&o=${backgroundedAtStart}&p=${backgrounded}"
},
"vars": {
"title": "Example Request"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import {
isValidPercentage_,
isVisibilitySpecValid,
installVisibilityService,
} from '../../extensions/amp-analytics/0.1/visibility-impl';
import {installResourcesService} from '../../src/service/resources-impl';
import {visibilityFor} from '../../src/visibility';
} from '../visibility-impl';
import {installResourcesService} from '../../../../src/service/resources-impl';
import {layoutRectLtwh, rectIntersection} from '../../../../src/layout-rect';
import {visibilityFor} from '../../../../src/visibility';
import * as sinon from 'sinon';


Expand All @@ -34,14 +35,12 @@ describe('Visibility (tag: amp-analytics)', () => {
let getIntersectionStub;
let callbackStub;

const INTERSECTION_0P = {
intersectionRect: {width: 0, height: 0},
boundingClientRect: {height: 100, width: 100},
};
const INTERSECTION_50P = {
intersectionRect: {width: 50, height: 100},
boundingClientRect: {height: 100, width: 100},
};
const INTERSECTION_0P = makeIntersectionEntry([100, 100, 100, 100],
[0, 0, 100, 100]);
const INTERSECTION_1P = makeIntersectionEntry([99, 99, 100, 100],
[0, 0, 100, 100]);
const INTERSECTION_50P = makeIntersectionEntry([50, 0, 100, 100],
[0, 0, 100, 100]);

beforeEach(() => {
sandbox = sinon.sandbox.create();
Expand Down Expand Up @@ -73,18 +72,34 @@ describe('Visibility (tag: amp-analytics)', () => {
sandbox.restore();
});

function makeIntersectionEntry(boundingClientRect, rootBounds) {
boundingClientRect = layoutRectLtwh.apply(window, boundingClientRect);
rootBounds = layoutRectLtwh.apply(window, rootBounds);
return {
intersectionRect: rectIntersection(boundingClientRect, rootBounds),
boundingClientRect,
rootBounds,
};
}

function listen(intersectionChange, config, expectedCalls) {
getIntersectionStub.returns(intersectionChange);
config['selector'] = '#abc';
sandbox.clock.tick(0);
visibility.listenOnce(config, callbackStub);
sandbox.clock.tick(20);
expect(callbackStub.callCount).to.equal(expectedCalls);
}

function verifyChange(intersectionChange, expectedCalls) {
function verifyChange(intersectionChange, expectedCalls, opt_expectedVars) {
getIntersectionStub.returns(intersectionChange);
visibility.scrollListener_();
expect(callbackStub.callCount).to.equal(expectedCalls);
if (opt_expectedVars) {
for (let c = 0; c < opt_expectedVars.length; c++) {
sinon.assert.calledWith(callbackStub.getCall(c), opt_expectedVars[c]);
}
}
}

it('fires for trivial config', () => {
Expand All @@ -93,12 +108,20 @@ describe('Visibility (tag: amp-analytics)', () => {
});

it('fires for non-trivial config', () => {
listen({
intersectionRect: {height: 100, width: 49},
boundingClientRect: {height: 100, width: 100},
}, {visiblePercentageMin: 49, visiblePercentageMax: 80}, 0);

verifyChange(INTERSECTION_50P, 1);
listen(makeIntersectionEntry([51, 0, 100, 100], [0, 0, 100, 100]),
{visiblePercentageMin: 49, visiblePercentageMax: 80}, 0);

verifyChange(INTERSECTION_50P, 1, [sinon.match({
backgrounded: '0',
backgroundedAtStart: '0',
elementX: '50',
elementY: '0',
elementWidth: '100',
elementHeight: '100',
totalTime: sinon.match(value => {
return !isNaN(Number(value));
}),
})]);
});

it('fires only once', () => {
Expand All @@ -118,6 +141,9 @@ describe('Visibility (tag: amp-analytics)', () => {

sandbox.clock.tick(1);
expect(callbackStub.callCount).to.equal(1);
sinon.assert.calledWith(callbackStub.getCall(0), sinon.match({
totalVisibleTime: '1000',
}));
});

it('fires with just continuousTimeMin condition', () => {
Expand All @@ -131,13 +157,24 @@ describe('Visibility (tag: amp-analytics)', () => {
});

it('fires with totalTimeMin=1k and visiblePercentageMin=0', () => {
listen(INTERSECTION_0P, {totalTimeMin: 1000, visiblePercentageMin: 0}, 0);
listen(INTERSECTION_0P, {totalTimeMin: 1000, visiblePercentageMin: 1}, 0);

verifyChange(INTERSECTION_1P, 0);
sandbox.clock.tick(1000);
verifyChange(INTERSECTION_50P, 0);

sandbox.clock.tick(1000);
expect(callbackStub.callCount).to.equal(1);
// There is a 20ms offset in some timedurations because of initial
// timeout in the listenOnce logic.
sinon.assert.calledWith(callbackStub.getCall(0), sinon.match({
maxContinuousTime: '1000',
totalVisibleTime: '1000',
firstSeenTime: '20',
fistVisibleTime: '1020',
lastSeenTime: '2020',
lastVisibleTime: '2020',
}));
});

it('fires for continuousTimeMin=1k and totalTimeMin=2k', () => {
Expand Down Expand Up @@ -166,6 +203,12 @@ describe('Visibility (tag: amp-analytics)', () => {
expect(callbackStub.callCount).to.equal(0);
sandbox.clock.tick(900);
expect(callbackStub.callCount).to.equal(1);
sinon.assert.calledWith(callbackStub.getCall(0), sinon.match({
maxContinuousTime: '1000',
minVisiblePercentage: '50',
maxVisiblePercentage: '50',
totalVisibleTime: '1999',
}));
});

describe('isVisibilitySpecValid', () => {
Expand Down
121 changes: 104 additions & 17 deletions extensions/amp-analytics/0.1/visibility-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,34 @@

import {dev} from '../../../src/log';
import {getService} from '../../../src/service';
import {rectIntersection} from '../../../src/layout-rect';
import {resourcesFor} from '../../../src/resources';
import {timer} from '../../../src/timer';
import {user} from '../../../src/log';
import {viewportFor} from '../../../src/viewport';
import {viewerFor} from '../../../src/viewer';
import {VisibilityState} from '../../../src/visibility-state';

/** @const {number} */
const LISTENER_INITIAL_RUN_DELAY_ = 20;

// Variables that are passed to the callback.
const MAX_CONTINUOUS_TIME = 'maxContinuousTime';
const TOTAL_TIME = 'totalVisibleTime';
const TOTAL_VISIBLE_TIME = 'totalVisibleTime';
const FIRST_SEEN_TIME = 'firstSeenTime';
const LAST_SEEN_TIME = 'lastSeenTime';
const FIRST_VISIBLE_TIME = 'fistVisibleTime';
const LAST_VISIBLE_TIME = 'lastVisibleTime';
const MIN_VISIBLE = 'minVisiblePercentage';
const MAX_VISIBLE = 'maxVisiblePercentage';
const ELEMENT_X = 'elementX';
const ELEMENT_Y = 'elementY';
const ELEMENT_WIDTH = 'elementWidth';
const ELEMENT_HEIGHT = 'elementHeight';
const TOTAL_TIME = 'totalTime';
const LOAD_TIME_VISIBILITY = 'loadTimeVisibility';
const BACKGROUNDED = 'backgrounded';
const BACKGROUNDED_AT_START = 'backgroundedAtStart';

// Variables that are not exposed outside this class.
const CONTINUOUS_TIME = 'cT';
Expand Down Expand Up @@ -169,9 +180,15 @@ export class Visibility {
/** @private @const {function} */
this.boundScrollListener_ = this.scrollListener_.bind(this);

/** @private @const {function} */
this.boundVisibilityListener_ = this.visibilityListener_.bind(this);

/** @private {boolean} */
this.scrollListenerRegistered_ = false;

/** @private {boolean} */
this.visibilityListenerRegistered_ = false;

/** @private {!Resources} */
this.resourcesService_ = resourcesFor(this.win_);

Expand All @@ -183,11 +200,29 @@ export class Visibility {

/** @private {boolean} */
this.scheduledLoadedPromises_ = false;

/** @private {Viewer} */
this.viewer_ = viewerFor(this.win_);

/** @private {boolean} */
this.backgroundedAtStart_ = !this.viewer_.isVisible();

/** @private {boolean} */
this.backgrounded_ = this.backgroundedAtStart_;
}

/** @private */
registerForVisibilityEvents_() {
if (!this.visibilityListenerRegistered_) {
this.viewer_.onVisibilityChanged(this.boundVisibilityListener_);
this.visibilityListenerRegistered_ = true;
this.visibilityListener_();
}
}

/** @private */
registerForViewportEvents_() {
if (!this.scrollListenerRegistered__) {
if (!this.scrollListenerRegistered_) {
const viewport = viewportFor(this.win_);

// Currently unlistens are not being used. In the event that no resources
Expand All @@ -196,7 +231,6 @@ export class Visibility {
viewport.onChanged(this.boundScrollListener_);
this.scrollListenerRegistered_ = true;
}

}

/**
Expand All @@ -210,13 +244,12 @@ export class Visibility {
const resId = res.getId();

this.registerForViewportEvents_();
this.registerForVisibilityEvents_();

this.listeners_[resId] = (this.listeners_[resId] || []);
this.listeners_[resId].push({
config: config,
callback: callback,
state: {[TIME_LOADED]: Date.now()},
});
const state = {};
state[TIME_LOADED] = Date.now();
this.listeners_[resId].push({config, callback, state});
this.resources_.push(res);

if (this.scheduledRunId_ == null) {
Expand All @@ -226,6 +259,15 @@ export class Visibility {
}
}

/** @private */
visibilityListener_() {
const state = this.viewer_.getVisibilityState();
if (state == VisibilityState.HIDDEN || state == VisibilityState.PAUSED ||
state == VisibilityState.INACTIVE) {
this.backgrounded_ = true;
}
}

/** @private */
scrollListener_() {
if (this.scheduledRunId_ != null) {
Expand All @@ -252,10 +294,8 @@ export class Visibility {
for (let c = listeners.length - 1; c >= 0; c--) {
if (this.updateCounters_(visible, listeners[c])) {

// Remove the state that need not be public and call callback.
delete listeners[c]['state'][CONTINUOUS_TIME];
delete listeners[c]['state'][LAST_UPDATE];
delete listeners[c]['state'][IN_VIEWPORT];
this.prepareStateForCallback_(listeners[c]['state'],
change.rootBounds, br, ir);
listeners[c].callback(listeners[c]['state']);
listeners.splice(c, 1);
}
Expand Down Expand Up @@ -316,7 +356,7 @@ export class Visibility {
state[CONTINUOUS_TIME] + timeSinceLastUpdate);

state[LAST_UPDATE] = -1;
state[TOTAL_TIME] += timeSinceLastUpdate;
state[TOTAL_VISIBLE_TIME] += timeSinceLastUpdate;
state[CONTINUOUS_TIME] = 0; // Clear only after max is calculated above.
state[LAST_VISIBLE_TIME] = Date.now() - state[TIME_LOADED];
} else if (state[IN_VIEWPORT] && !wasInViewport) {
Expand All @@ -335,7 +375,7 @@ export class Visibility {
? config[CONTINUOUS_TIME_MIN] - state[CONTINUOUS_TIME]
: Infinity;
const waitForTotalTime = config[TOTAL_TIME_MIN]
? config[TOTAL_TIME_MIN] - state[TOTAL_TIME]
? config[TOTAL_TIME_MIN] - state[TOTAL_VISIBLE_TIME]
: Infinity;

// Wait for minimum of (previous timeToWait, positive values of
Expand All @@ -346,9 +386,9 @@ export class Visibility {
listener['state'] = state;
return state[IN_VIEWPORT] &&
(config[TOTAL_TIME_MIN] === undefined ||
state[TOTAL_TIME] >= config[TOTAL_TIME_MIN]) &&
state[TOTAL_VISIBLE_TIME] >= config[TOTAL_TIME_MIN]) &&
(config[TOTAL_TIME_MAX] === undefined ||
state[TOTAL_TIME] <= config[TOTAL_TIME_MAX]) &&
state[TOTAL_VISIBLE_TIME] <= config[TOTAL_TIME_MAX]) &&
(config[CONTINUOUS_TIME_MIN] === undefined ||
state[CONTINUOUS_TIME] >= config[CONTINUOUS_TIME_MIN]) &&
(config[CONTINUOUS_TIME_MAX] === undefined ||
Expand Down Expand Up @@ -378,7 +418,8 @@ export class Visibility {
/** @private */
setState_(s, visible, sinceLast) {
s[LAST_UPDATE] = Date.now();
s[TOTAL_TIME] = s[TOTAL_TIME] !== undefined ? s[TOTAL_TIME] + sinceLast : 0;
s[TOTAL_VISIBLE_TIME] = s[TOTAL_VISIBLE_TIME] !== undefined
? s[TOTAL_VISIBLE_TIME] + sinceLast : 0;
s[CONTINUOUS_TIME] = s[CONTINUOUS_TIME] !== undefined
? s[CONTINUOUS_TIME] + sinceLast : 0;
s[MAX_CONTINUOUS_TIME] = s[MAX_CONTINUOUS_TIME] !== undefined
Expand All @@ -387,6 +428,52 @@ export class Visibility {
s[MAX_VISIBLE] = s[MAX_VISIBLE] ? Math.max(s[MAX_VISIBLE], visible) : -1;
s[LAST_VISIBLE_TIME] = Date.now() - s[TIME_LOADED];
}

/**
* Sets variable values for callback. Cleans up existing values.
* @param {Object<string, *>} state The state object to populate
* @param {!LayoutRect} rb Bounds of Root object. (the viewport in this case)
* @param {!LayoutRect} br The bounding rectangle for the element
* @param {!LayoutRect} ir The intersection between element and the viewport
* @private
*/
prepareStateForCallback_(state, rb, br, ir) {
const perf = this.win_.performance;
state[ELEMENT_X] = rb.left + br.left;
state[ELEMENT_Y] = rb.top + br.top;
state[ELEMENT_WIDTH] = br.width;
state[ELEMENT_HEIGHT] = br.height;
state[TOTAL_TIME] = perf && perf.timing && perf.timing.domInteractive
? Date.now() - perf.timing.domInteractive
: '';

// Calculate the amount element visible at the time page was loaded. To do
// this, assume that the page is scrolled all the way to top.
const viewportRect = {top: 0, height: rb.height, left: 0, width: rb.width};
const elementRect = {top: ir.top, left: ir.left, width: br.width,
height: br.height};
const intersection = rectIntersection(viewportRect, elementRect);
state[LOAD_TIME_VISIBILITY] = intersection != null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please comment here the meaning of this formula?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added comment.

? Math.round(intersection.width * intersection.height * 10000
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we do anything when br.width or br.height are 0?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 10,000?

Copy link
Contributor Author

@avimehta avimehta Jun 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows for two significant digits after decimal. So 12.344 becomes 12.34 and 12.346 becomes 12.35

Regarding br.width and br.height: I'll need to add checks for that. Currently that would be a problem.

(in practical cases, tracking 0 width/height elements does not make sense.)

/ (br.width * br.height)) / 100
: 0;
state[MIN_VISIBLE] = Math.round(state[MIN_VISIBLE] * 100) / 100;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite understand rounding rules. Could you please clarify?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows for two significant digits after decimal. So 12.344 becomes 12.34 and 12.346 becomes 12.35

state[MAX_VISIBLE] = Math.round(state[MAX_VISIBLE] * 100) / 100;
state[BACKGROUNDED] = this.backgrounded_ ? '1' : '0';
state[BACKGROUNDED_AT_START] = this.backgroundedAtStart_ ? '1' : '0';

// Remove the state that need not be public and call callback.
delete state[CONTINUOUS_TIME];
delete state[LAST_UPDATE];
delete state[IN_VIEWPORT];
delete state[TIME_LOADED];

for (const k in state) {
if (state.hasOwnProperty(k)) {
state[k] = String(state[k]);
}
}
}
}

/**
Expand Down
Loading