Skip to content

Commit

Permalink
perf(visibility): use rbush to speed up dom mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
brewcoua committed Jun 10, 2024
1 parent f6b250f commit e613f42
Show file tree
Hide file tree
Showing 9 changed files with 252 additions and 226 deletions.
Binary file modified bun.lockb
Binary file not shown.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@
"SoM.js.map"
],
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "^12.0.1",
"@types/rbush": "^3.0.3",
"conventional-changelog-conventionalcommits": "^8.0.0",
"prettier": "^3.2.5",
"rollup": "^4.18.0",
Expand All @@ -49,5 +52,8 @@
"scripts": {
"build": "rollup -c",
"build:watch": "rollup -c -w"
},
"dependencies": {
"rbush": "^3.0.1"
}
}
6 changes: 6 additions & 0 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

import terser from '@rollup/plugin-terser';
import { string } from 'rollup-plugin-string';

Expand All @@ -20,7 +23,10 @@ export default {
plugins: [
typescript({
outputToFilesystem: true,
noEmitOnError: true,
}),
resolve(),
commonjs(),
string({
include: '**/*.css',
exclude: ['node_modules/**'],
Expand Down
87 changes: 45 additions & 42 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,58 @@
// Selectors for preselecting elements to be checked
export const SELECTORS = [
"a:not(:has(img))",
"a img",
"button",
'input:not([type="hidden"])',
"select",
"textarea",
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
".btn",
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="input"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="switch"]',
'[role="tab"]',
'[role="treeitem"]',
'[role="gridcell"]',
'[role="search"]',
'[role="combobox"]',
'[role="listbox"]',
'[role="slider"]',
'[role="spinbutton"]',
'a:not(:has(img))',
'a img',
'button',
'input:not([type="hidden"])',
'select',
'textarea',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
'.btn',
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="input"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="switch"]',
'[role="tab"]',
'[role="treeitem"]',
'[role="gridcell"]',
'[role="search"]',
'[role="combobox"]',
'[role="listbox"]',
'[role="slider"]',
'[role="spinbutton"]',
];

export const EDITABLE_SELECTORS = [
'input[type="text"]',
'input[type="password"]',
'input[type="email"]',
'input[type="tel"]',
'input[type="number"]',
'input[type="search"]',
'input[type="url"]',
'input[type="date"]',
'input[type="time"]',
'input[type="datetime-local"]',
'input[type="month"]',
'input[type="week"]',
'input[type="color"]',
"textarea",
'[contenteditable="true"]',
'input[type="text"]',
'input[type="password"]',
'input[type="email"]',
'input[type="tel"]',
'input[type="number"]',
'input[type="search"]',
'input[type="url"]',
'input[type="date"]',
'input[type="time"]',
'input[type="datetime-local"]',
'input[type="month"]',
'input[type="week"]',
'input[type="color"]',
'textarea',
'[contenteditable="true"]',
];

// Required visibility ratio for an element to be considered visible
export const VISIBILITY_RATIO = 0.6;

// A difference in size and position of less than DISJOINT_THRESHOLD means the elements are joined
export const DISJOINT_THRESHOLD = 0.1;

// Maximum ratio of the screen that an element can cover to be considered visible (to avoid huge ads)
export const MAX_COVER_RATIO = 0.8;

Expand Down
16 changes: 11 additions & 5 deletions src/filters/visibility/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import QuadTree, { Rectangle } from './quad';
import QuadTree, { Rectangle } from './tree';
import { isAbove, isVisible } from './utils';

export default class VisibilityCanvas {
Expand Down Expand Up @@ -48,7 +48,6 @@ export default class VisibilityCanvas {
if (totalPixels === 0) return 0;

const elements = this.getIntersectingElements(qt);

for (const el of elements) {
this.drawElement(el, 'black');
}
Expand All @@ -63,14 +62,21 @@ export default class VisibilityCanvas {
this.rect.right,
this.rect.width,
this.rect.height,
this.element
[this.element]
);
const candidates = qt.query(range);

// Now, for the sake of avoiding completely hidden elements, we do one elementsOnPoint check
const elementsFromPoint = document.elementsFromPoint(
this.rect.left + this.rect.width / 2,
this.rect.top + this.rect.height / 2
) as HTMLElement[];

return candidates
.map((candidate) => candidate.element!)
.concat(elementsFromPoint)
.filter(
(el) => el != this.element && isAbove(this.element, el) && isVisible(el)
(el, i, arr) =>
arr.indexOf(el) === i && isVisible(el) && isAbove(this.element, el)
);
}

Expand Down
43 changes: 21 additions & 22 deletions src/filters/visibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import {
import Filter from '@/domain/Filter';
import InteractiveElements from '@/domain/InteractiveElements';

import QuadTree, { Rectangle } from './quad';
import DOMTree, { Rectangle } from './tree';
import { isVisible } from './utils';
import VisibilityCanvas from './canvas';

class VisibilityFilter extends Filter {
private qt!: QuadTree;
private dt!: DOMTree;

async apply(elements: InteractiveElements): Promise<InteractiveElements> {
this.qt = this.mapQuadTree();
this.dt = this.buildDOMTree();

const results = await Promise.all([
this.applyScoped(elements.fixed),
Expand Down Expand Up @@ -52,36 +52,35 @@ class VisibilityFilter extends Filter {
return results.flat();
}

mapQuadTree(): QuadTree {
buildDOMTree(): DOMTree {
const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight);
const qt = new QuadTree(boundary, 4);
const dt = new DOMTree(boundary);

// use a tree walker and also filter out invisible elements
// Use a tree walker to traverse the DOM tree
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node) => {
const element = node as HTMLElement;
if (isVisible(element)) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
},
}
NodeFilter.SHOW_ELEMENT
);

const buf: Rectangle[] = [];

let currentNode: Node | null = walker.currentNode;
while (currentNode) {
const element = currentNode as HTMLElement;
const rect = element.getBoundingClientRect();
qt.insert(
new Rectangle(rect.left, rect.top, rect.width, rect.height, element)
);

if (isVisible(element)) {
const rect = element.getBoundingClientRect();
buf.push(
new Rectangle(rect.left, rect.top, rect.width, rect.height, [element])
);
}
currentNode = walker.nextNode();
}

return qt;
// Finally, insert all the rectangles into the tree
dt.insertAll(buf);

return dt;
}

async isDeepVisible(element: HTMLElement) {
Expand All @@ -108,7 +107,7 @@ class VisibilityFilter extends Filter {
// IntersectionObserver only checks intersection with the viewport, not with other elements
// Thus, we need to calculate the visible area ratio relative to the intersecting elements
const canvas = new VisibilityCanvas(element);
const visibleAreaRatio = await canvas.eval(this.qt);
const visibleAreaRatio = await canvas.eval(this.dt);

resolve(visibleAreaRatio >= VISIBILITY_RATIO);
});
Expand Down
132 changes: 0 additions & 132 deletions src/filters/visibility/quad.ts

This file was deleted.

Loading

0 comments on commit e613f42

Please sign in to comment.