Skip to content

Instantly share code, notes, and snippets.

@mattzeunert
Last active December 12, 2018 18:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattzeunert/13939147d9b05f0c82816883a2a21855 to your computer and use it in GitHub Desktop.
Save mattzeunert/13939147d9b05f0c82816883a2a21855 to your computer and use it in GitHub Desktop.
console.time("start")
var FINGER_SIZE_PX = 48;
// var checkOverlapBetweenFingers = false
// var scoreBasedOnOverlapArea = true
/**
* 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
*/
window.getTappableRectsFromClientRects = getTappableRectsFromClientRects
function getTappableRectsFromClientRects(clientRects) {
// for (const cr of clientRects) {
// if (cr.node && cr.node.innerHTML.includes("votes")) {
// debugger
// }
// }
// 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;
}
/**
* 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;
}
// Ratio of the finger area tapping on an unintended element
// to the finger area tapping on the intended element
var MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO = 0.25;
/**
* @param {LH.Artifacts.Rect} cr
*/
function clientRectMeetsMinimumSize(cr) {
return cr.width >= FINGER_SIZE_PX && cr.height >= FINGER_SIZE_PX;
}
/**
* @param {LH.Artifacts.TapTarget} target
*/
function targetIsTooSmall(target) {
for (const cr of target.clientRects) {
if (clientRectMeetsMinimumSize(cr)) {
return false;
}
}
return true;
}
/**
*
* @param {LH.Artifacts.TapTarget[]} targets
*/
function getTooSmallTargets(targets) {
return targets.filter(targetIsTooSmall);
}
/**
*
* @param {LH.Artifacts.TapTarget[]} tooSmallTargets
* @param {LH.Artifacts.TapTarget[]} allTargets
*/
function getOverlapFailures(tooSmallTargets, allTargets) {
/** @type {LH.Audit.TapTargetOverlapDetail[]} */
let failures = [];
tooSmallTargets.forEach(target => {
const overlappingTargets = getTooCloseTargets(
target,
allTargets
);
if (overlappingTargets.length > 0) {
failures = failures.concat(overlappingTargets);
}
});
return failures;
}
/**
* @param {LH.Artifacts.TapTarget} tapTarget
* @param {LH.Artifacts.TapTarget} maybeOverlappingTarget
* @returns {LH.Audit.TapTargetOverlapDetail | null}
*/
function getTargetTooCloseFailure(tapTarget, maybeOverlappingTarget) {
// convert client rects to unique tappable areas from a user's perspective
const tappableRects = getTappableRectsFromClientRects(tapTarget.clientRects);
const isHttpOrHttpsLink = /https?:\/\//.test(tapTarget.href);
if (isHttpOrHttpsLink && tapTarget.href === maybeOverlappingTarget.href) {
// no overlap because same target action
return null;
}
/** @type LH.Audit.TapTargetOverlapDetail | null */
let greatestFailure = null;
for (const targetCR of tappableRects) {
if (allRectsContainedWithinEachOther(tappableRects, maybeOverlappingTarget.clientRects)) {
// If one tap target is fully contained within the other that's
// probably intentional (e.g. an item with a delete button inside)
continue;
}
for (const maybeOverlappingCR of maybeOverlappingTarget.clientRects) {
const failure = getOverlapFailure(targetCR, maybeOverlappingCR);
if (failure) {
targetCR.node && (targetCR.node.style.outline = "red");
// only update our state if this was the biggest failure we've seen for this pair
if (!greatestFailure ||
failure.overlapScoreRatio > greatestFailure.overlapScoreRatio) {
greatestFailure = {
...failure,
tapTarget,
overlappingTarget: maybeOverlappingTarget,
};
}
}
}
}
return greatestFailure;
}
/**
* @param {LH.Artifacts.Rect} targetCR
* @param {LH.Artifacts.Rect} maybeOverlappingCR
*/
function getOverlapFailure(targetCR, maybeOverlappingCR) {
const fingerRect = getRectAtCenter(targetCR, FINGER_SIZE_PX);
// Score indicates how much of the finger area overlaps each target when the user
// taps on the center of targetCR
const tapTargetScore = getRectOverlapArea(fingerRect, targetCR);
const maybeOverlappingScore = getRectOverlapArea(fingerRect, maybeOverlappingCR);
const overlapScoreRatio = maybeOverlappingScore / tapTargetScore;
if (overlapScoreRatio < MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO) {
// low score means it's clear that the user tried to tap on the targetCR,
// rather than the other tap target client rect
return null;
}
return {
overlapScoreRatio,
tapTargetScore,
overlappingTargetScore: maybeOverlappingScore,
};
}
var tapTargets = (function() {
const tapTargetsSelector = "button,a,input,textarea,select,option,[role=button],[role=checkbox],[role=link],[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio],[role=option],[role=scrollbar],[role=slider],[role=spinbutton]";
function getElementsInDocument(selector) {
const realMatchesFn = window.__ElementMatches || window.Element.prototype.matches;
/** @type {Array<Element>} */
const results = [];
/** @param {NodeListOf<Element>} nodes */
const _findAllElements = nodes => {
for (let i = 0, el; el = nodes[i]; ++i) {
if (!selector || realMatchesFn.call(el, selector)) {
results.push(el);
}
// If the element has a shadow root, dig deeper.
if (el.shadowRoot) {
_findAllElements(el.shadowRoot.querySelectorAll('*'));
}
}
};
_findAllElements(document.querySelectorAll('*'));
return results;
};
function filterClientRectsWithinAncestorsVisibleScrollArea(node, clientRects) {
const parent = node.parentElement;
if (!parent) {
return clientRects;
}
if (getComputedStyle(parent).overflowY !== 'visible') {
const parentBCR = parent.getBoundingClientRect();
clientRects = clientRects.filter(cr => rectContains(parentBCR, cr));
}
if (parent.parentElement && parent.parentElement.tagName !== 'BODY') {
return filterClientRectsWithinAncestorsVisibleScrollArea(
parent,
clientRects
);
}
return clientRects;
};
function nodeIsPositionFixedOrAbsolute(node) {
const {position} = getComputedStyle(node);
if (position === 'fixed' || position === 'absolute') {
return true;
}
if (node.parentElement) {
return nodeIsPositionFixedOrAbsolute(node.parentElement);
}
return false;
};
function nodeIsVisible(node) {
const {overflowX, overflowY, display, visibility} = getComputedStyle(node);
if (
display === 'none' ||
(visibility === 'collapse' && ['TR', 'TBODY', 'COL', 'COLGROUP'].includes(node.tagName))
) {
// Element not displayed
return false;
}
// only for block and inline-block, since clientWidth/Height are always 0 for inline elements
if (display === 'block' || display === 'inline-block') {
// if height/width is 0 and no overflow in that direction then
// there's no content that the user can see and tap on
if ((node.clientWidth === 0 && overflowX === 'hidden') ||
(node.clientHeight === 0 && overflowY === 'hidden')) {
return false;
}
}
const parent = node.parentElement;
if (parent && parent.tagName !== 'HTML' && !nodeIsVisible(parent)) {
// if a parent is invisible then the current node is also invisible
return false;
}
return true;
};
function nodeHasParentTapTarget(node) {
if (!node.parentElement) {
return false;
}
if (node.parentElement.matches(tapTargetsSelector)) {
return true;
}
return nodeHasParentTapTarget(node.parentElement);
};
window.getVisibleClientRects= getVisibleClientRects;
function getVisibleClientRects(node) {
if (node.innerHTML.includes("10 votes")) {debugger}
if (!nodeIsVisible(node)) {
return [];
}
const {
overflowX,
overflowY,
} = getComputedStyle(node);
let clientRects = getClientRects(node, true);
if (allClientRectsEmpty(clientRects)) {
if ((overflowX === 'hidden' && overflowY === 'hidden') || node.children.length === 0) {
// own size is 0x0 and there's no visible child content
return [];
}
}
// Treating overflowing content in scroll containers as invisible could mean that
// most of a given page is deemed invisible. But:
// - tap targets audit doesn't consider different containers/layers
// - having most content in an explicit scroll container is rare
// - treating them as hidden only generates false passes, which is better than false failures
clientRects = filterClientRectsWithinAncestorsVisibleScrollArea(node, clientRects);
return clientRects;
};
function truncate(str, maxLength) {
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength - 1) + '…';
};
function getClientRects(node, includeChildren = true) {
/** @type {LH.Artifacts.Rect[]} */
let clientRects = Array.from(
node.getClientRects()
).map(clientRect => {
// Contents of DOMRect get lost when returned from Runtime.evaluate call,
// so we convert them to plain objects.
const {width, height, left, top, right, bottom} = clientRect;
return {width, height, left, top, right, bottom, node};
});
if (includeChildren) {
for (const child of node.children) {
clientRects = clientRects.concat(getClientRects(child));
}
}
return clientRects;
};
function nodeIsInTextBlock(node) {
/**
* @param {Node} node
* @returns {boolean}
*/
function isInline(node) {
if (node.nodeType === Node.TEXT_NODE) {
return true;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return false;
}
const {display} = getComputedStyle(/** @type {Element} */ (node));
return display === 'inline' || display === 'inline-block';
}
/**
* @param {Node} node
*/
function hasTextNodeSiblingsFormingTextBlock(node) {
if (!node.parentElement) {
return false;
}
const parentElement = node.parentElement;
const nodeText = node.textContent || '';
const parentText = parentElement.textContent || '';
if (parentText.length - nodeText.length < 5) {
// Parent text mostly consists of this node, so the parent
// is not a text block container
return false;
}
for (const sibling of node.parentElement.childNodes) {
if (sibling === node) {
continue;
}
const siblingTextContent = (sibling.textContent || '').trim();
if (sibling.nodeType === Node.TEXT_NODE && siblingTextContent.length > 0) {
return true;
}
}
return false;
}
if (!isInline(node)) {
return false;
}
if (hasTextNodeSiblingsFormingTextBlock(node)) {
return true;
} else if (node.parentElement) {
return nodeIsInTextBlock(node.parentElement);
} else {
return false;
}
};
function allClientRectsEmpty(clientRects) {
return clientRects.every(cr => cr.width === 0 && cr.height === 0);
};
function rectContainsPoint(rect, {x, y}) {
return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y;
}
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,
})
);
};
;
function getNodePath(node) {
/** @param {Node} node */
function getNodeIndex(node) {
let index = 0;
let prevNode;
while (prevNode = node.previousSibling) {
node = prevNode;
// skip empty text nodes
if (node.nodeType === Node.TEXT_NODE && node.textContent &&
node.textContent.trim().length === 0) continue;
index++;
}
return index;
}
const path = [];
while (node && node.parentNode) {
const index = getNodeIndex(node);
path.push([index, node.nodeName]);
node = node.parentNode;
}
path.reverse();
return path.join(',');
};
function getNodeSelector(node) {
/**
* @param {Element} node
*/
function getSelectorPart(node) {
let part = node.tagName.toLowerCase();
if (node.id) {
part += '#' + node.id;
} else if (node.classList.length > 0) {
part += '.' + node.classList[0];
}
return part;
}
const parts = [];
while (parts.length < 4) {
parts.unshift(getSelectorPart(node));
if (!node.parentElement) {
break;
}
node = node.parentElement;
if (node.tagName === 'HTML') {
break;
}
}
return parts.join(' > ');
};
function gatherTapTargets() {
/** @type {LH.Artifacts.TapTarget[]} */
const targets = [];
// @ts-ignore - getElementsInDocument put into scope via stringification
Array.from(getElementsInDocument(tapTargetsSelector)).forEach(tapTargetNode => {
if (nodeHasParentTapTarget(tapTargetNode)) {
// This is usually intentional, either the tap targets trigger the same action
// or there's a child with a related action (like a delete button for an item)
return;
}
if (nodeIsInTextBlock(tapTargetNode)) {
// Links inside text blocks cause a lot of failures, and there's also an exception for them
// in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
return;
}
if (nodeIsPositionFixedOrAbsolute(tapTargetNode)) {
// Absolutely positioned elements might not be visible if they have a lower z-index
// than other tap targets, but if we don't ignore them we can get false failures.
//
// TODO: rewrite logic to use elementFromPoint to check if an element is visible
// (this should also mean we'd no longer need to check ancestor visible scroll area)
return;
}
const visibleClientRects = getVisibleClientRects(tapTargetNode);
if (visibleClientRects.length === 0) {
return;
}
targets.push({
clientRects: visibleClientRects,
snippet: truncate(tapTargetNode.outerHTML, 700),
// @ts-ignore - getNodePath put into scope via stringification
path: getNodePath(tapTargetNode),
// @ts-ignore - getNodeSelector put into scope via stringification
selector: getNodeSelector(tapTargetNode),
href: tapTargetNode.href || '',
});
});
return targets;
};
return gatherTapTargets();
})();
console.log({tapTargets})
/**
* @param {LH.Artifacts.ClientRect} cr
*/
function clientRectMeetsMinimumSize(cr) {
return cr.width >= FINGER_SIZE_PX && cr.height >= FINGER_SIZE_PX;
}
/**
* @param {LH.Artifacts.TapTarget} target
*/
function targetIsTooSmall(target) {
for (const cr of target.clientRects) {
if (clientRectMeetsMinimumSize(cr)) {
return false;
}
}
return true;
}
/**
*
* @param {LH.Artifacts.TapTarget[]} targets
*/
function getTooSmallTargets(targets) {
return targets.filter(targetIsTooSmall);
}
function drawFinger(bcr, debugInfo, color = "rgba(0,0,255,0.2)") {
const point = document.createElement('div');
point.bcr = bcr
point.style.position = 'absolute';
point.style.left = bcr.left + 'px';
point.style.top = bcr.top + 'px';
point.style.width = (bcr.right - bcr.left) + "px";
point.style.height = (bcr.bottom - bcr.top) + "px";
point.style.background = color;
point.style.zIndex = "1000000000";
point.setAttribute("finger", "f")
point.setAttribute("debugInfo", JSON.stringify(debugInfo||"").slice(0, 500))
// point.refNode = debugInfo.getNode()
document.body.appendChild(point);
return point
}
/**
*
* @param {LH.Artifacts.TapTarget[]} tooSmallTargets
* @param {LH.Artifacts.TapTarget[]} allTargets
*/
function getOverlapFailures(tooSmallTargets, allTargets) {
/** @type {LH.Audit.TapTargetOverlapDetail[]} */
const failures = [];
tooSmallTargets.forEach(target => {
const overlappingTargets = getTooCloseTargets(
target,
allTargets
);
if (overlappingTargets.length > 0) {
overlappingTargets.forEach(
(targetOverlapDetail) => {
drawFinger(target.clientRects[0], "", "rgba(255,0,0,0.4)")
failures.push(targetOverlapDetail);
}
);
}
});
return failures;
}
/**
* @param {LH.Audit.TapTargetOverlapDetail[]} overlapFailures
*/
function getTableItems(overlapFailures) {
const tableItems = overlapFailures.map(
({
tapTarget,
overlappingTarget,
extraDistanceNeeded,
overlappingTargetScore,
tapTargetScore,
}) => {
const largestCr = getLargestClientRect(tapTarget);
const width = Math.floor(largestCr.width);
const height = Math.floor(largestCr.height);
const size = width + 'x' + height;
return {
tapTarget: targetToTableNode(tapTarget),
overlappingTarget: targetToTableNode(overlappingTarget),
size,
extraDistanceNeeded,
width,
height,
overlappingTargetScore,
tapTargetScore,
};
});
tableItems.sort((a, b) => {
return b.extraDistanceNeeded - a.extraDistanceNeeded;
});
return tableItems;
}
/**
* @param {LH.Artifacts.TapTarget} target
* @returns {LH.Audit.DetailsRendererNodeDetailsJSON}
*/
function targetToTableNode(target) {
return {
type: 'node',
snippet: target.snippet,
path: target.path,
selector: target.selector,
};
}
/**
* @param {LH.Artifacts.Rect[]} rects
* @returns {LH.Artifacts.Rect[]}
*/
function filterOutTinyRects(rects) {
return rects.filter(
rect => rect.width > 1 && rect.height > 1
);
}
(function(){
const artifacts = {TapTargets: tapTargets}
for (const tapTarget of tapTargets) {
const tappableRects = getTappableRectsFromClientRects(tapTarget.clientRects);
for (const targetCR of tappableRects) {
drawFinger(targetCR, "", color = "rgba(0,0,0,0.2)")
const fingerRect = getRectAtCenter(targetCR, FINGER_SIZE_PX);
drawFinger(fingerRect, "", color = "rgba(0,0,255,0.2)")
}
}
const tooSmallTargets = getTooSmallTargets(artifacts.TapTargets);
const overlapFailures = getOverlapFailures(tooSmallTargets, artifacts.TapTargets);
const tableItems = getTableItems(overlapFailures);
const headings = [
{key: 'tapTarget', itemType: 'node', text: 'Tap Target'},
{key: 'size', itemType: 'text', text: 'Size'},
{key: 'overlappingTarget', itemType: 'node', text: 'Overlapping Target'},
];
// const details = Audit.makeTableDetails(headings, tableItems);
const tapTargetCount = artifacts.TapTargets.length;
const failingTapTargetCount = new Set(overlapFailures.map(f => f.tapTarget)).size;
const passingTapTargetCount = tapTargetCount - failingTapTargetCount;
const score = tapTargetCount > 0 ? passingTapTargetCount / tapTargetCount : 1;
const displayValue = Math.round(score * 100) + '% appropriately sized tap targets';
console.log({tableItems, displayValue})
})()
/**
*
* @param {LH.Artifacts.TapTarget} tapTarget
* @param {LH.Artifacts.TapTarget[]} allTapTargets
*/
function getTooCloseTargets(tapTarget, allTapTargets) {
/** @type LH.Audit.TapTargetOverlapDetail[] */
const failures = [];
for (const maybeOverlappingTarget of allTapTargets) {
if (maybeOverlappingTarget === tapTarget) {
// checking the same target with itself, skip
continue;
}
const failure = getTargetTooCloseFailure(tapTarget, maybeOverlappingTarget);
if (failure) {
failures.push(failure);
}
}
return failures;
}
// var artifacts = {TapTargets: tapTargets}
// console.time('tooCloseTargets');
// var ttt = tooCloseTargets(artifacts.TapTargets);
// var tooClose = ttt.failures;
// var failingTapTargetsCount = ttt.failingTapTargetsCount;
// console.timeEnd('tooCloseTargets');
// /** @type {Array<{node: LH.Audit.DetailsRendererNodeDetailsJSON, issue: string}>} */
// var failures = [];
// console.time('tooCloseTargets output');
// tooClose.forEach(({targetA, targetB, overlap}) => {
// failures.push({
// targetA: {type: 'node', snippet: targetA.snippet, __debug: targetA},
// targetB: {type: 'node', snippet: targetB.snippet, __debug: targetB},
// issue:
// `${Math.floor(overlap)}px`,
// });
// });
// var headings = [
// {key: 'targetA', itemType: 'node', text: 'Element 1'},
// {key: 'targetB', itemType: 'node', text: 'Element 2'},
// {key: 'issue', itemType: 'text', text: 'Extra Distance Needed'},
// ];
// console.time('tooCloseTargets output');
// var displayValue;
// if (failures.length) {
// displayValue = failures.length > 1 ?
// `${failures.length} issues found` : '1 issue found';
// }
// var score;
// if (artifacts.TapTargets.length > 0) {
// score = 1 - (failingTapTargetsCount / artifacts.TapTargets.length);
// } else {
// score= 1;
// }
// function drawFinger(bcr, debugInfo) {
// const point = document.createElement('div');
// point.style.position = 'absolute';
// point.style.left = bcr.left + 'px';
// point.style.top = bcr.top + 'px';
// point.style.width = (bcr.right - bcr.left) + "px";
// point.style.height = (bcr.bottom - bcr.top) + "px";
// point.style.background = "rgba(0,0,255,0.2)";
// point.style.zIndex = "1000000000";
// point.setAttribute("finger", "f")
// point.setAttribute("debugInfo", JSON.stringify(debugInfo).slice(0, 500))
// point.refNode = debugInfo.getNode()
// document.body.appendChild(point);
// }
// function drawContainer(bcr, color, debugInfo) {
// debugInfo.getNode().style.outline = "2px solid " + color
// // debugInfo.getNode().setAttribute("debugInfo", JSON.stringify(debugInfo))
// // const point = document.createElement('div');
// // point.style.position = 'absolute';
// // point.style.left = bcr.left + 'px';
// // point.style.top = bcr.top + 'px';
// // point.style.width = (bcr.right - bcr.left) + "px";
// // point.style.height = (bcr.bottom - bcr.top) + "px";
// // point.style.background = color;
// // point.style.zIndex = "1000000000";
// // point.setAttribute("tapTarget", "t")
// // point.setAttribute("debugInfo", JSON.stringify(debugInfo))
// // document.body.appendChild(point);
// }
// var res = {
// score,
// failures,
// tapTargets: artifacts.TapTargets,
// }
// Array.from(document.querySelectorAll("[finger]")).forEach(el => el.remove());
// Array.from(document.querySelectorAll("[tapTarget]")).forEach(el => el.remove());
// res.tapTargets.forEach(tapTarget => {
// simplifyBCRs(tapTarget.bcrs).forEach(bcr => {
// drawFinger(getFingerAtCenter(bcr), tapTarget)
// drawContainer(bcr, "rgba(0,255,0,1)", tapTarget)
// })
// })
// res.failures.forEach((failure) => {
// failure.targetA.__debug.bcrs.forEach(bcr => drawContainer(bcr, "rgba(255,0,0,1)", failure.targetA.__debug));
// failure.targetB.__debug.bcrs.forEach(bcr => drawContainer(bcr, "rgba(255,120,0,1)", failure.targetB.__debug));
// })
// var div = document.createElement("div")
// div.style="position: absolute; top: 200px; width: 110px; right: 0;z-index: 102222000001;padding: 5px;background: red;color:white;"
// div.innerHTML = `Score: ${Math.round(res.score* 100)}%<br> Targets: ${res.tapTargets.length}<br> WithFailures: ${failingTapTargetsCount}<br>Failures: ${res.failures.length}`
// document.body.appendChild(div)
// console.log(tapTargets)
/**
* @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.ClientRect} cr
* @param {{x:number, y:number}} point
*/
/* istanbul ignore next */
function rectContainsPoint(cr, {x, y}) {
return cr.left <= x && cr.right >= x && cr.top <= y && cr.bottom >= y;
}
/**
* @param {LH.Artifacts.ClientRect} cr1
* @param {LH.Artifacts.ClientRect} cr2
*/
/* istanbul ignore next */
function rectContains(cr1, cr2) {
const topLeft = {
x: cr2.left,
y: cr2.top,
};
const topRight = {
x: cr2.right,
y: cr2.top,
};
const bottomLeft = {
x: cr2.left,
y: cr2.bottom,
};
const bottomRight = {
x: cr2.right,
y: cr2.bottom,
};
return (
rectContainsPoint(cr1, topLeft) &&
rectContainsPoint(cr1, topRight) &&
rectContainsPoint(cr1, bottomLeft) &&
rectContainsPoint(cr1, bottomRight)
);
}
/**
* Merge client rects together. This may result in a larger overall size than that of the individual client rects.
* @param {LH.Artifacts.ClientRect[]} clientRects
*/
function simplifyClientRects(clientRects) {
clientRects = filterOutTinyClientRects(clientRects);
clientRects = filterOutClientRectsContainedByOthers(clientRects);
clientRects = mergeTouchingClientRects(clientRects);
return clientRects;
}
/**
* @param {LH.Artifacts.ClientRect[]} clientRects
* @returns {LH.Artifacts.ClientRect[]}
*/
function filterOutTinyClientRects(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.
const nonTinyClientRects = clientRects.filter(
rect => rect.width > 1 && rect.height > 1
);
if (nonTinyClientRects.length > 0) {
return nonTinyClientRects;
}
return clientRects;
}
/**
* @param {LH.Artifacts.ClientRect[]} clientRects
* @returns {LH.Artifacts.ClientRect[]}
*/
function filterOutClientRectsContainedByOthers(clientRects) {
const rectsToKeep = new Set(clientRects);
for (const cr of clientRects) {
for (const possiblyContainingRect of clientRects) {
if (cr === possiblyContainingRect) continue;
if (!rectsToKeep.has(possiblyContainingRect)) continue;
if (rectContains(possiblyContainingRect, cr)) {
rectsToKeep.delete(cr);
break;
}
}
}
return Array.from(rectsToKeep);
}
/**
* @param {number} a
* @param {number} b
*/
function almostEqual(a, b) {
return Math.abs(a - b) <= 10;
}
/**
* @param {LH.Artifacts.ClientRect} rect
*/
function getRectCenterPoint(rect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
/**
* @param {LH.Artifacts.ClientRect} crA
* @param {LH.Artifacts.ClientRect} crB
* @returns {boolean}
*/
function clientRectsTouch(crA, crB) {
// https://stackoverflow.com/questions/2752349/fast-rectangle-to-rectangle-intersection
return (
crA.left <= crB.right &&
crB.left <= crA.right &&
crA.top <= crB.bottom &&
crB.top <= crA.bottom
);
}
/**
* @param {LH.Artifacts.ClientRect[]} clientRects
* @returns {LH.Artifacts.ClientRect[]}
*/
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];
let canMerge = false;
const rectsLineUpHorizontally =
almostEqual(crA.top, crB.top) || almostEqual(crA.bottom, crB.bottom)
const rectsLineUpVertically =
almostEqual(crA.left, crB.left) || almostEqual(crA.right, crB.right);
if (
clientRectsTouch(crA, crB) &&
(rectsLineUpHorizontally || rectsLineUpVertically)
) {
canMerge = true;
}
if (canMerge) {
const left = Math.min(crA.left, crB.left);
const right = Math.max(crA.right, crB.right);
const top = Math.min(crA.top, crB.top);
const bottom = Math.max(crA.bottom, crB.bottom);
const replacementClientRect = addRectWidthAndHeight({
left,
right,
top,
bottom,
});
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 putting a tap target in the finger
// wouldn't hit either actual rect
continue;
}
clientRects.push(replacementClientRect);
clientRects.splice(i, 1);
if (i < j) {
j--; // update index after delete
}
clientRects.splice(j, 1);
return mergeTouchingClientRects(clientRects);
}
}
}
return clientRects;
}
/**
* @param {{left:number, top:number, right:number, bottom: number}} rect
* @return {LH.Artifacts.ClientRect}
*/
function addRectWidthAndHeight({left, top, right, bottom}) {
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
};
}
/**
* @param {LH.Artifacts.ClientRect} rect1
* @param {LH.Artifacts.ClientRect} 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.ClientRect} rect1
* @param {LH.Artifacts.ClientRect} 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.ClientRect} rect1
* @param {LH.Artifacts.ClientRect} rect2
*/
function getRectOverlap(rect1, rect2) {
return getRectXOverlap(rect1, rect2) * getRectYOverlap(rect1, rect2);
}
/**
* @param {LH.Artifacts.ClientRect} clientRect
* @param {number} fingerSize
*/
function getFingerAtCenter(clientRect, fingerSize) {
return addRectWidthAndHeight({
left: clientRect.left + clientRect.width / 2 - fingerSize / 2,
top: clientRect.top + clientRect.height / 2 - fingerSize / 2,
right: clientRect.right - clientRect.width / 2 + fingerSize / 2,
bottom: clientRect.bottom - clientRect.height / 2 + fingerSize / 2,
});
}
/**
* @param {LH.Artifacts.ClientRect} cr
*/
function getClientRectArea(cr) {
return cr.width * cr.height;
}
/**
* @param {LH.Artifacts.TapTarget} target
*/
function getLargestClientRect(target) {
let largestCr = target.clientRects[0];
for (const cr of target.clientRects) {
if (getClientRectArea(cr) > getClientRectArea(largestCr)) {
largestCr = cr;
}
}
return largestCr;
}
/**
*
* @param {LH.Artifacts.ClientRect[]} crListA
* @param {LH.Artifacts.ClientRect[]} crListB
*/
function allClientRectsContainedWithinEachOther(crListA, crListB) {
for (const crA of crListA) {
for (const crB of crListB) {
if (!rectContains(crA, crB) && !rectContains(crB, crA)) {
return false;
}
}
}
return true;
}
console.timeEnd("start")
/**
* @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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment