diff --git a/lighthouse-core/lib/rect-helpers.js b/lighthouse-core/lib/rect-helpers.js new file mode 100644 index 000000000000..9deb75bbfaa9 --- /dev/null +++ b/lighthouse-core/lib/rect-helpers.js @@ -0,0 +1,258 @@ +/** + * @license Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +/** + * @param {LH.Artifacts.Rect} rect + * @param {{x:number, y:number}} point + */ +// We sometimes run this as a part of a gatherer script injected into the page, so prevent +// renaming the function for code coverage. +/* istanbul ignore next */ +function rectContainsPoint(rect, {x, y}) { + return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y; +} + +/** + * @param {LH.Artifacts.Rect} rect1 + * @param {LH.Artifacts.Rect} rect2 + */ +// We sometimes run this as a part of a gatherer script injected into the page, so prevent +// renaming the function for code coverage. +/* istanbul ignore next */ +function rectContains(rect1, rect2) { + return ( + // top left corner + rectContainsPoint(rect1, { + x: rect2.left, + y: rect2.top, + }) && + // top right corner + rectContainsPoint(rect1, { + x: rect2.right, + y: rect2.top, + }) && + // bottom left corner + rectContainsPoint(rect1, { + x: rect2.left, + y: rect2.bottom, + }) && + // bottom right corner + rectContainsPoint(rect1, { + x: rect2.right, + y: rect2.bottom, + }) + ); +} + + +const rectContainsString = ` + ${rectContainsPoint.toString()} + ${rectContains.toString()}; +`; + +/** + * @param {LH.Artifacts.Rect[]} rects + * @returns {LH.Artifacts.Rect[]} + */ +function filterOutTinyRects(rects) { + return rects.filter( + rect => rect.width > 1 && rect.height > 1 + ); +} + +/** + * @param {LH.Artifacts.Rect[]} rects + * @returns {LH.Artifacts.Rect[]} + */ +function filterOutRectsContainedByOthers(rects) { + const rectsToKeep = new Set(rects); + + for (const rect of rects) { + for (const possiblyContainingRect of rects) { + if (rect === possiblyContainingRect) continue; + if (!rectsToKeep.has(possiblyContainingRect)) continue; + if (rectContains(possiblyContainingRect, rect)) { + rectsToKeep.delete(rect); + break; + } + } + } + + return Array.from(rectsToKeep); +} + +/** + * @param {LH.Artifacts.Rect} rect + */ +function getRectCenterPoint(rect) { + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; +} + +/** + * @param {LH.Artifacts.Rect} rectA + * @param {LH.Artifacts.Rect} rectB + * @returns {boolean} + */ +function rectsTouchOrOverlap(rectA, rectB) { + // https://stackoverflow.com/questions/2752349/fast-rectangle-to-rectangle-intersection + return ( + rectA.left <= rectB.right && + rectB.left <= rectA.right && + rectA.top <= rectB.bottom && + rectB.top <= rectA.bottom + ); +} + +/** + * @param {LH.Artifacts.Rect} rectA + * @param {LH.Artifacts.Rect} rectB + */ +function getBoundingRect(rectA, rectB) { + const left = Math.min(rectA.left, rectB.left); + const right = Math.max(rectA.right, rectB.right); + const top = Math.min(rectA.top, rectB.top); + const bottom = Math.max(rectA.bottom, rectB.bottom); + return addRectWidthAndHeight({ + left, + right, + top, + bottom, + }); +} + +/** + * @param {{left:number, top:number, right:number, bottom: number}} rect + * @return {LH.Artifacts.Rect} + */ +function addRectWidthAndHeight({left, top, right, bottom}) { + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + }; +} + +/** + * @param {{x:number, y:number, width:number, height: number}} rect + * @return {LH.Artifacts.Rect} + */ +function addRectTopAndBottom({x, y, width, height}) { + return { + left: x, + top: y, + right: x + width, + bottom: y + height, + width, + height, + }; +} + +/** + * @param {LH.Artifacts.Rect} rect1 + * @param {LH.Artifacts.Rect} rect2 + */ +function getRectXOverlap(rect1, rect2) { + // https://stackoverflow.com/a/9325084/1290545 + return Math.max( + 0, + Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left) + ); +} + +/** + * @param {LH.Artifacts.Rect} rect1 + * @param {LH.Artifacts.Rect} rect2 + */ +function getRectYOverlap(rect1, rect2) { + // https://stackoverflow.com/a/9325084/1290545 + return Math.max( + 0, + Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top) + ); +} + +/** + * @param {LH.Artifacts.Rect} rect1 + * @param {LH.Artifacts.Rect} rect2 + */ +function getRectOverlapArea(rect1, rect2) { + return getRectXOverlap(rect1, rect2) * getRectYOverlap(rect1, rect2); +} + +/** + * @param {LH.Artifacts.Rect} rect + * @param {number} centerRectSize + */ +function getRectAtCenter(rect, centerRectSize) { + return addRectWidthAndHeight({ + left: rect.left + rect.width / 2 - centerRectSize / 2, + top: rect.top + rect.height / 2 - centerRectSize / 2, + right: rect.right - rect.width / 2 + centerRectSize / 2, + bottom: rect.bottom - rect.height / 2 + centerRectSize / 2, + }); +} + +/** + * @param {LH.Artifacts.Rect} rect + */ +function getRectArea(rect) { + return rect.width * rect.height; +} + +/** + * @param {LH.Artifacts.Rect[]} rects + */ +function getLargestRect(rects) { + let largestRect = rects[0]; + for (const rect of rects) { + if (getRectArea(rect) > getRectArea(largestRect)) { + largestRect = rect; + } + } + return largestRect; +} + +/** + * + * @param {LH.Artifacts.Rect[]} rectListA + * @param {LH.Artifacts.Rect[]} rectListB + */ +function allRectsContainedWithinEachOther(rectListA, rectListB) { + for (const rectA of rectListA) { + for (const rectB of rectListB) { + if (!rectContains(rectA, rectB) && !rectContains(rectB, rectA)) { + return false; + } + } + } + return true; +} + +module.exports = { + rectContainsPoint, + rectContains, + rectContainsString, + addRectWidthAndHeight, + addRectTopAndBottom, + getRectXOverlap, + getRectYOverlap, + getRectOverlapArea, + getRectAtCenter, + getLargestRect, + getRectCenterPoint, + getBoundingRect, + rectsTouchOrOverlap, + allRectsContainedWithinEachOther, + filterOutRectsContainedByOthers, + filterOutTinyRects, +}; diff --git a/lighthouse-core/lib/tappable-rects.js b/lighthouse-core/lib/tappable-rects.js new file mode 100644 index 000000000000..118d87916d61 --- /dev/null +++ b/lighthouse-core/lib/tappable-rects.js @@ -0,0 +1,104 @@ +/** + * @license Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const { + filterOutRectsContainedByOthers, + filterOutTinyRects, + rectsTouchOrOverlap, + rectContainsPoint, + getBoundingRect, + getRectCenterPoint, +} = require('./rect-helpers'); + +/** + * Merge client rects together and remove small ones. This may result in a larger overall + * size than that of the individual client rects. + * We use this to simulate a finger tap on those targets later on. + * @param {LH.Artifacts.Rect[]} clientRects + */ +function getTappableRectsFromClientRects(clientRects) { + // 1x1px rect shouldn't be reason to treat the rect as something the user should tap on. + // Often they're made invisble in some obscure way anyway, and only exist for e.g. accessibiliity. + clientRects = filterOutTinyRects(clientRects); + clientRects = filterOutRectsContainedByOthers(clientRects); + clientRects = mergeTouchingClientRects(clientRects); + return clientRects; +} + +/** + * @param {number} a + * @param {number} b + */ +function almostEqual(a, b) { + // Sometimes a child will reach out of the parent by + // 1px or 2px, so be somewhat tolerant for merging + return Math.abs(a - b) <= 2; +} + +/** + * Merge touching rects based on what appears as one tappable area to the user. + * @param {LH.Artifacts.Rect[]} clientRects + * @returns {LH.Artifacts.Rect[]} + */ +function mergeTouchingClientRects(clientRects) { + for (let i = 0; i < clientRects.length; i++) { + for (let j = i + 1; j < clientRects.length; j++) { + const crA = clientRects[i]; + const crB = clientRects[j]; + + /** + * We try to determine whether the rects appear as a single tappable + * area to the user, so that they'd tap in the middle of the merged rect. + * Examples of what we want to merge: + * + * AAABBB + * + * AAA + * AAA + * BBBBB + */ + const rectsLineUpHorizontally = + almostEqual(crA.top, crB.top) || almostEqual(crA.bottom, crB.bottom); + const rectsLineUpVertically = + almostEqual(crA.left, crB.left) || almostEqual(crA.right, crB.right); + const canMerge = + rectsTouchOrOverlap(crA, crB) && + (rectsLineUpHorizontally || rectsLineUpVertically); + + if (canMerge) { + const replacementClientRect = getBoundingRect(crA, crB); + const mergedRectCenter = getRectCenterPoint(replacementClientRect); + + if ( + !( + rectContainsPoint(crA, mergedRectCenter) || + rectContainsPoint(crB, mergedRectCenter) + ) + ) { + // Don't merge because the new shape is too different from the + // merged rects, and tapping in the middle wouldn't actually hit + // either rect + continue; + } + + // Replace client rects with merged version + clientRects = clientRects.filter(cr => cr !== crA && cr !== crB); + clientRects.push(replacementClientRect); + + // Start over so we don't have to handle complexity introduced by array mutation. + // Client rect ararys rarely contain more than 5 rects, so starting again doesn't cause perf issues. + return mergeTouchingClientRects(clientRects); + } + } + } + + return clientRects; +} + +module.exports = { + getTappableRectsFromClientRects, +}; diff --git a/lighthouse-core/test/lib/rect-helpers-test.js b/lighthouse-core/test/lib/rect-helpers-test.js new file mode 100644 index 000000000000..22c0a19be04e --- /dev/null +++ b/lighthouse-core/test/lib/rect-helpers-test.js @@ -0,0 +1,87 @@ +/** + * @license Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +/* eslint-env jest */ + +const { + addRectTopAndBottom, + getRectOverlapArea, + getLargestRect, + getRectAtCenter, + allRectsContainedWithinEachOther, +} = require('../../lib/rect-helpers'); + +describe('Rect Helpers', () => { + it('getRectOverlapArea', () => { + const overlapArea = getRectOverlapArea( + addRectTopAndBottom({ + x: 0, + y: 0, + width: 10, + height: 10, + }), + addRectTopAndBottom({ + x: 8, + y: 6, + width: 10, + height: 10, + }) + ); + expect(overlapArea).toBe(8); + }); + + it('getLargestRect', () => { + const rect1 = addRectTopAndBottom({ + x: 0, + y: 0, + width: 5, + height: 5, + }); + const rect2 = addRectTopAndBottom({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + const largestRect = getLargestRect([rect1, rect2]); + expect(largestRect).toBe(rect2); + }); + + it('getRectAtCenter', () => { + const rect = addRectTopAndBottom({ + x: 0, + y: 0, + width: 100, + height: 100, + }); + const largestRect = getRectAtCenter(rect, 10); + expect(largestRect).toEqual( + addRectTopAndBottom({ + x: 45, + y: 45, + width: 10, + height: 10, + }) + ); + }); + + describe('allRectsContainedWithinEachOther', () => { + it('Returns true if the rect lists are both empty', () => { + expect(allRectsContainedWithinEachOther([], [])).toBe(true); + }); + it('Returns true if rects are contained in each other', () => { + const rect1 = addRectTopAndBottom({x: 0, y: 0, width: 100, height: 100}); + const rect2 = addRectTopAndBottom({x: 40, y: 40, width: 20, height: 20}); + expect(allRectsContainedWithinEachOther([rect1], [rect2])).toBe(true); + }); + it('Returns true if rects aren\'t contained in each other', () => { + const rect1 = addRectTopAndBottom({x: 0, y: 0, width: 100, height: 100}); + const rect2 = addRectTopAndBottom({x: 200, y: 200, width: 20, height: 20}); + expect(allRectsContainedWithinEachOther([rect1], [rect2])).toBe(false); + }); + }); +}); diff --git a/lighthouse-core/test/lib/tappable-rects-test.js b/lighthouse-core/test/lib/tappable-rects-test.js new file mode 100644 index 000000000000..030a4e39794d --- /dev/null +++ b/lighthouse-core/test/lib/tappable-rects-test.js @@ -0,0 +1,246 @@ +/** + * @license Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +/* eslint-env jest */ + +const {addRectTopAndBottom} = require('../../lib/rect-helpers'); +const {getTappableRectsFromClientRects} = require('../../lib/tappable-rects'); +const assert = require('assert'); + +describe('getTappableRectsFromClientRects', () => { + it('Merges rects if a smaller rect is inside a larger one', () => { + const containingRect = addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 10, + }); + const containedRect = addRectTopAndBottom({ + x: 10, + y: 10, + width: 50, + height: 10, + }); + + assert.deepEqual(getTappableRectsFromClientRects([ + containingRect, + containedRect, + ]), [containingRect]); + assert.deepEqual(getTappableRectsFromClientRects([ + containedRect, + containingRect, + ]), [containingRect]); + }); + it('Merges two horizontally adjacent client rects', () => { + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 10, + }), + addRectTopAndBottom({ + x: 110, + y: 10, + width: 100, + height: 10, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 200, + height: 10, + }), + ]); + }); + + it('Merges three horizontally adjacent client rects', () => { + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 10, + }), + addRectTopAndBottom({ + x: 110, + y: 10, + width: 100, + height: 10, + }), + addRectTopAndBottom({ + x: 210, + y: 10, + width: 100, + height: 10, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 300, + height: 10, + }), + ]); + }); + + it('Merges client rects correctly if one is duplicated', () => { + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 90, + height: 10, + }), + addRectTopAndBottom({ + x: 10, + y: 10, + width: 90, + height: 10, + }), + addRectTopAndBottom({ + x: 100, + y: 10, + width: 10, + height: 10, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 10, + }), + ]); + }); + + it('Merges two vertically adjacent client rects even if one is wider than the other', () => { + // We do this because to fix issues with children (e.g. images) inside links. + // The link itself might be small, so if we put a finger on it directly then it's + // likely to overlap with something. + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 10, + }), + addRectTopAndBottom({ + x: 10, + y: 15, + width: 200, + height: 15, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 200, + height: 20, + }), + ]); + }); + + it('Does not merge if the center of the merged rect wouldn\'t be in the original rects', () => { + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 10, + height: 100, + }), + addRectTopAndBottom({ + x: 10, + y: 10, + width: 200, + height: 10, + }), + ]); + assert.equal(res.length, 2); + }); + + it('Merges two horizontally adjacent client rects that don\'t line up exactly', () => { + // 2px difference is ok, often there are cases where an image is a 1px or 2px out of the main link client rect + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 10, + }), + addRectTopAndBottom({ + x: 110, + y: 12, + width: 100, + height: 10, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 200, + height: 12, + }), + ]); + }); + + it('Merges two identical client rects into one', () => { + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 10, + height: 10, + }), + addRectTopAndBottom({ + x: 10, + y: 10, + width: 10, + height: 10, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 10, + height: 10, + }), + ]); + }); + + it('Removes tiny 1x1px client rects', () => { + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 100, + }), + addRectTopAndBottom({ + x: 5, + y: 5, + width: 1, + height: 1, + }), + ]); + assert.deepEqual(res, [ + addRectTopAndBottom({ + x: 10, + y: 10, + width: 100, + height: 100, + }), + ]); + }); +}); diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts index aebaa284d310..d37048300f07 100644 --- a/types/artifacts.d.ts +++ b/types/artifacts.d.ts @@ -278,6 +278,15 @@ declare global { }; } + export interface Rect { + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; + } + export interface ViewportDimensions { innerWidth: number; innerHeight: number;