From d3fdd893ccd361e09ccd5f3f3b443d6b4aa4ee8d Mon Sep 17 00:00:00 2001 From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com> Date: Thu, 6 Jun 2024 10:18:53 +0200 Subject: [PATCH 1/5] docs(readme): add script explanation --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index dfbe9b0..e23ef37 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,26 @@ You can then use the `SoM` object in the `window` object to interact with the sc window.SoM.display().then(() => console.log("Set-of-Marks displayed")); ``` +### How it works + +This is a step-by-step guide on how the script works: + +#### 1. Elements loading + +The script will first query all elements on the page (and inside shadow roots) that fit specific selectors (e.g. `a`, `button`, `input`, etc., see [src/constants.ts](src/constants.ts)). After that, it will go through all elements on the page and find the elements that display a pointer cursor. These elements will be stored in a list of elements that can be clicked, but are less likely to be right than the previously queried elements. + +#### 2. Elements filtering + +The script will then first proceed to filter out, in both lists, the elements that are not visible enough (see [src/constants.ts](src/constants.ts) for the threshold values, e.g. `0.7`). To do that, we first use an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to check if the element is visible enough in the viewport, and if it is, we check the actual pixel-by-pixel visibility ratio of the element by first drawing it on a canvas, then drawing the overlapping elements on the same canvas, and finally counting the number of pixels that were not overlapped by other elements. + +After that, we take the elements in the second list (the ones that display a pointer cursor) and apply a nesting filter. This filter will remove all elements that are either inside a prioritized element (e.g. a button) or that have too many clickable children. Additionally, we consider elements disjoint if their size is different enough (see [src/constants.ts](src/constants.ts) for the threshold value, e.g. `0.7`). +When applying this filter, we also consider the first list for reference, while not removing any element from that first list afterwards. + +#### 3. Elements rendering + +Finally, we proceed to render the boxes over the elements that passed the filters. We first render all the boxes, for which we calculate a contrasted color based on the element's background color and all surrounding boxes' colors (we also apply a min/max luminance and minimum saturation). After that, we render labels for the boxes, while calculating the label position that would overlap the least with other labels and boxes, while ignoring any box that fully overlaps that label's box (since some buttons may be completely inside cards, for example). If an element is editable, the box will have a stripped pattern, along with a border to make it more visible. +All boxes ignore pointer events, so the user can still interact with the page. + ## License This project is licensed under either of the following, at your option: From 32660dbda7394a0e8719f8a7ad10daaaadd7e710 Mon Sep 17 00:00:00 2001 From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com> Date: Sat, 8 Jun 2024 09:45:33 +0200 Subject: [PATCH 2/5] perf: use quadtree to optimize element mapping --- build.ts | 13 +- dist/SoM.js | 466 ++++++++++++++++++++---------- dist/SoM.min.js | 2 +- src/constants.ts | 7 + src/domain/Filter.ts | 5 + src/domain/InteractiveElements.ts | 6 + src/domain/filter.ts | 3 - src/filters/nesting.ts | 21 +- src/filters/visibility.ts | 385 ------------------------ src/filters/visibility/canvas.ts | 220 ++++++++++++++ src/filters/visibility/index.ts | 119 ++++++++ src/filters/visibility/quad.ts | 127 ++++++++ src/filters/visibility/utils.ts | 71 +++++ src/loader.ts | 59 ++-- src/ui.ts | 1 - tsconfig.json | 31 ++ 16 files changed, 957 insertions(+), 579 deletions(-) create mode 100644 src/domain/Filter.ts create mode 100644 src/domain/InteractiveElements.ts delete mode 100644 src/domain/filter.ts delete mode 100644 src/filters/visibility.ts create mode 100644 src/filters/visibility/canvas.ts create mode 100644 src/filters/visibility/index.ts create mode 100644 src/filters/visibility/quad.ts create mode 100644 src/filters/visibility/utils.ts create mode 100644 tsconfig.json diff --git a/build.ts b/build.ts index 926ddc1..d4d601b 100644 --- a/build.ts +++ b/build.ts @@ -23,19 +23,28 @@ async function main() { rmSync("dist", { recursive: true, force: true }); - await Bun.build({ + const result1 = await Bun.build({ entrypoints: ["./src/main.ts"], outdir: "./dist", plugins: [InlineStylePlugin], }); + if (!result1.success) { + result1.logs.forEach(console.log); + process.exit(1); + } renameSync("./dist/main.js", "./dist/SoM.js"); - await Bun.build({ + const result2 = await Bun.build({ entrypoints: ["./src/main.ts"], outdir: "./dist", plugins: [InlineStylePlugin], minify: true, }); + if (!result2.success) { + result2.logs.forEach(console.log); + process.exit(1); + } + renameSync("./dist/main.js", "./dist/SoM.min.js"); } diff --git a/dist/SoM.js b/dist/SoM.js index 91483e2..ae2ea08 100644 --- a/dist/SoM.js +++ b/dist/SoM.js @@ -46,184 +46,237 @@ var EDITABLE_SELECTORS = [ '[contenteditable="true"]' ]; var VISIBILITY_RATIO = 0.6; -var ELEMENT_SAMPLING_RATE = 0.2; +var MAX_COVER_RATIO = 0.8; +var ELEMENT_BATCH_SIZE = 10; var SURROUNDING_RADIUS = 200; var MAX_LUMINANCE = 0.7; var MIN_LUMINANCE = 0.25; var MIN_SATURATION = 0.3; -// src/domain/filter.ts +// src/domain/Filter.ts class Filter { } -// src/filters/visibility.ts -class VisibilityFilter extends Filter { - constructor() { - super(...arguments); +// src/filters/visibility/quad.ts +class Rectangle { + x; + y; + width; + height; + element; + constructor(x, y, width, height, element = null) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.element = element; } - async apply(elements) { - const visibleElements = await Promise.all(elements.map(async (element) => { - if (element.offsetWidth === 0 && element.offsetHeight === 0) { - return null; - } - const style = window.getComputedStyle(element); - if (style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none") { - return null; - } - let parent = element.parentElement; - let passed = true; - while (parent !== null) { - const parentStyle = window.getComputedStyle(parent); - if (parentStyle.display === "none" || parentStyle.visibility === "hidden" || parentStyle.pointerEvents === "none") { - passed = false; - break; - } - parent = parent.parentElement; + contains(rect) { + return rect.x >= this.x && rect.x + rect.width <= this.x + this.width && rect.y >= this.y && rect.y + rect.height <= this.y + this.height; + } + intersects(rect) { + return !(rect.x > this.x + this.width || rect.x + rect.width < this.x || rect.y > this.y + this.height || rect.y + rect.height < this.y); + } +} + +class QuadTree { + boundary; + capacity; + elements; + divided; + northeast; + northwest; + southeast; + southwest; + constructor(boundary, capacity) { + this.boundary = boundary; + this.capacity = capacity; + this.elements = []; + this.divided = false; + this.northeast = null; + this.northwest = null; + this.southeast = null; + this.southwest = null; + } + subdivide() { + const x = this.boundary.x; + const y = this.boundary.y; + const w = this.boundary.width / 2; + const h = this.boundary.height / 2; + const ne = new Rectangle(x + w, y, w, h); + const nw = new Rectangle(x, y, w, h); + const se = new Rectangle(x + w, y + h, w, h); + const sw = new Rectangle(x, y + h, w, h); + this.northeast = new QuadTree(ne, this.capacity); + this.northwest = new QuadTree(nw, this.capacity); + this.southeast = new QuadTree(se, this.capacity); + this.southwest = new QuadTree(sw, this.capacity); + this.divided = true; + } + insert(element) { + if (!this.boundary.intersects(element)) { + return false; + } + if (this.elements.length < this.capacity) { + this.elements.push(element); + return true; + } else { + if (!this.divided) { + this.subdivide(); } - if (!passed) { - return null; + if (this.northeast.insert(element)) { + return true; + } else if (this.northwest.insert(element)) { + return true; + } else if (this.southeast.insert(element)) { + return true; + } else if (this.southwest.insert(element)) { + return true; } - const isVisible = await this.isElementVisible(element); - if (!isVisible) { - return null; + return false; + } + } + query(range, found = []) { + if (!this.boundary.intersects(range)) { + return found; + } + for (let element of this.elements) { + if (range.intersects(element)) { + found.push(element); } - return element; - })); - return visibleElements.filter((element) => element !== null); + } + if (this.divided) { + this.northwest.query(range, found); + this.northeast.query(range, found); + this.southwest.query(range, found); + this.southeast.query(range, found); + } + return found; } - async isElementVisible(element) { - return new Promise((resolve) => { - const observer = new IntersectionObserver(async (entries) => { - const entry = entries[0]; - observer.disconnect(); - if (entry.intersectionRatio < VISIBILITY_RATIO) { - resolve(false); - return; - } - const rect = element.getBoundingClientRect(); - if (rect.width <= 1 || rect.height <= 1) { - resolve(false); - return; - } - if (rect.width >= window.innerWidth * 0.8 || rect.height >= window.innerHeight * 0.8) { - resolve(false); - return; - } - const visibleAreaRatio = await this.getVisibilityRatio(element, rect); - resolve(visibleAreaRatio >= VISIBILITY_RATIO); - }); - observer.observe(element); - }); +} + +// src/filters/visibility/utils.ts +function isAbove(element, referenceElement) { + const elementZIndex = window.getComputedStyle(element).zIndex; + const referenceElementZIndex = window.getComputedStyle(referenceElement).zIndex; + const elementPosition = element.compareDocumentPosition(referenceElement); + if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) { + return false; + } + if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return true; + } + if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") { + return parseInt(elementZIndex) > parseInt(referenceElementZIndex); } - async getVisibilityRatio(element, rect) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d", { + if (elementZIndex === "auto" || referenceElementZIndex === "auto") { + return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); + } + return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); +} +function isVisible(element) { + if (element.offsetWidth === 0 && element.offsetHeight === 0) { + return false; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none") { + return false; + } + let parent = element.parentElement; + while (parent !== null) { + const parentStyle = window.getComputedStyle(parent); + if (parentStyle.display === "none" || parentStyle.visibility === "hidden" || parentStyle.pointerEvents === "none") { + return false; + } + parent = parent.parentElement; + } + return true; +} + +// src/filters/visibility/canvas.ts +class VisibilityCanvas { + element; + canvas; + ctx; + rect; + visibleRect; + constructor(element) { + this.element = element; + this.element = element; + this.rect = this.element.getBoundingClientRect(); + this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height); + this.ctx = this.canvas.getContext("2d", { willReadFrequently: true }); - if (!ctx) { - throw new Error("Could not get 2D context"); - } - const elementZIndex = parseInt(window.getComputedStyle(element).zIndex || "0", 10); - canvas.width = rect.width; - canvas.height = rect.height; - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - const visibleRect = { - top: Math.max(0, rect.top), - left: Math.max(0, rect.left), - bottom: Math.min(window.innerHeight, rect.bottom), - right: Math.min(window.innerWidth, rect.right), - width: rect.width, - height: rect.height + this.visibleRect = { + top: Math.max(0, this.rect.top), + left: Math.max(0, this.rect.left), + bottom: Math.min(window.innerHeight, this.rect.bottom), + right: Math.min(window.innerWidth, this.rect.right), + width: this.rect.width, + height: this.rect.height }; - visibleRect.width = visibleRect.right - visibleRect.left; - visibleRect.height = visibleRect.bottom - visibleRect.top; - this.drawElement(element, ctx, rect, rect, "white"); - const totalPixels = this.countVisiblePixels(ctx, { - top: visibleRect.top - rect.top, - left: visibleRect.left - rect.left, - width: canvas.width, - height: canvas.height - }); - const elements = await this.getIntersectingElements(element); - await Promise.all(elements.map(async (el) => { - const elRect = el.getBoundingClientRect(); - this.drawElement(el, ctx, elRect, rect, "black"); - })); - const visiblePixels = this.countVisiblePixels(ctx, { - top: visibleRect.top - rect.top, - left: visibleRect.left - rect.left, - width: visibleRect.width, - height: visibleRect.height - }); - canvas.remove(); - if (totalPixels === 0) { + this.visibleRect.width = this.visibleRect.right - this.visibleRect.left; + this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top; + } + async eval(qt) { + this.ctx.fillStyle = "black"; + this.ctx.fillRect(0, 0, this.rect.width, this.rect.height); + this.drawElement(this.element, "white"); + const canvasVisRect = { + top: this.visibleRect.top - this.rect.top, + bottom: this.visibleRect.bottom - this.rect.top, + left: this.visibleRect.left - this.rect.left, + right: this.visibleRect.right - this.rect.left, + width: this.canvas.width, + height: this.canvas.height + }; + const totalPixels = await this.countVisiblePixels(canvasVisRect); + if (totalPixels === 0) return 0; + const elements = this.getIntersectingElements(qt); + for (const el of elements) { + this.drawElement(el, "black"); } + const visiblePixels = await this.countVisiblePixels(canvasVisRect); return visiblePixels / totalPixels; } - async getIntersectingElements(element) { - const elementZIndex = parseInt(window.getComputedStyle(element).zIndex || "0", 10); - const rect = element.getBoundingClientRect(); - const foundElements = await Promise.all(Array.from({ length: Math.ceil(1 / ELEMENT_SAMPLING_RATE) }).map(async (_, i) => { - return Array.from({ - length: Math.ceil(1 / ELEMENT_SAMPLING_RATE) - }).map((_2, j) => { - const elements2 = document.elementsFromPoint(rect.left + rect.width * ELEMENT_SAMPLING_RATE * i, rect.top + rect.height * ELEMENT_SAMPLING_RATE * j); - if (!elements2.includes(element)) { - return []; - } - const currentIndex = elements2.indexOf(element); - return elements2.slice(0, currentIndex); - }); - })); - const uniqueElements = Array.from(new Set(foundElements.flat(2).filter((el) => el !== element))); - let elements = []; - for (const el of uniqueElements) { - const elZIndex = parseInt(window.getComputedStyle(el).zIndex || "0", 10); - if (elZIndex < elementZIndex) { - continue; - } - if (el.contains(element) || element.contains(el)) { - continue; - } - elements.push(el); - } - elements = elements.filter((el) => { - for (const other of elements) { - if (el !== other && other.contains(el)) { - return false; - } - } - return true; - }); - return elements; + getIntersectingElements(qt) { + const range = new Rectangle(this.rect.left, this.rect.right, this.rect.width, this.rect.height, this.element); + const candidates = qt.query(range); + return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(el, this.element) && isVisible(el)); } - countVisiblePixels(ctx, rect) { - const data = ctx.getImageData(rect.left, rect.top, rect.width, rect.height).data; + async countVisiblePixels(visibleRect) { + const imageData = this.ctx.getImageData(visibleRect.left, visibleRect.top, visibleRect.width, visibleRect.height); let visiblePixels = 0; - for (let i = 0;i < data.length; i += 4) { - if (data[i] > 0) { + for (let i = 0;i < imageData.data.length; i += 4) { + const isWhite = imageData.data[i] === 255; + if (isWhite) { visiblePixels++; } } return visiblePixels; } - drawElement(element, ctx, rect, baseRect, color = "black") { + drawElement(element, color = "black") { + const rect = element.getBoundingClientRect(); const styles = window.getComputedStyle(element); const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r)); const clipPath = styles.clipPath; const offsetRect = { - top: rect.top - baseRect.top, - bottom: rect.bottom - baseRect.top, - left: rect.left - baseRect.left, - right: rect.right - baseRect.left, + top: rect.top - this.rect.top, + bottom: rect.bottom - this.rect.top, + left: rect.left - this.rect.left, + right: rect.right - this.rect.left, width: rect.width, height: rect.height }; offsetRect.width = offsetRect.right - offsetRect.left; offsetRect.height = offsetRect.bottom - offsetRect.top; - ctx.fillStyle = color; + this.ctx.fillStyle = color; if (clipPath && clipPath !== "none") { const clips = clipPath.split(/,| /); clips.forEach((clip) => { @@ -234,7 +287,7 @@ class VisibilityFilter extends Filter { switch (kind[0]) { case "polygon": const path = this.pathFromPolygon(clip, rect); - ctx.fill(path); + this.ctx.fill(path); break; default: console.log("Unknown clip path kind: " + kind); @@ -254,9 +307,9 @@ class VisibilityFilter extends Filter { path.arcTo(offsetRect.left, offsetRect.bottom, offsetRect.left, offsetRect.top, radius[3]); path.arcTo(offsetRect.left, offsetRect.top, offsetRect.right, offsetRect.top, radius[0]); path.closePath(); - ctx.fill(path); + this.ctx.fill(path); } else { - ctx.fillRect(offsetRect.left, offsetRect.top, offsetRect.width, offsetRect.height); + this.ctx.fillRect(offsetRect.left, offsetRect.top, offsetRect.width, offsetRect.height); } } pathFromPolygon(polygon, rect) { @@ -279,6 +332,83 @@ class VisibilityFilter extends Filter { return path; } } + +// src/filters/visibility/index.ts +class VisibilityFilter extends Filter { + constructor() { + super(...arguments); + } + qt; + async apply(elements) { + this.qt = this.mapQuadTree(); + const results = await Promise.all([ + this.applyScoped(elements.fixed), + this.applyScoped(elements.unknown) + ]); + return { + fixed: results[0], + unknown: results[1] + }; + } + async applyScoped(elements) { + const results = await Promise.all(Array.from({ + length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE) + }).map(async (_, i) => { + const batch = elements.slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE).filter((el) => isVisible(el)); + const visibleElements = []; + for (const element of batch) { + const isVisible2 = await this.isDeepVisible(element); + if (isVisible2) { + visibleElements.push(element); + } + } + return visibleElements; + })); + return results.flat(); + } + mapQuadTree() { + const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight); + const qt = new QuadTree(boundary, 4); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => { + const element = node; + if (isVisible(element)) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_REJECT; + } + }); + let currentNode = walker.currentNode; + while (currentNode) { + const element = currentNode; + const rect = element.getBoundingClientRect(); + qt.insert(new Rectangle(rect.left, rect.top, rect.width, rect.height, element)); + currentNode = walker.nextNode(); + } + return qt; + } + async isDeepVisible(element) { + return new Promise((resolve) => { + const observer = new IntersectionObserver(async (entries) => { + const entry = entries[0]; + observer.disconnect(); + if (entry.intersectionRatio < VISIBILITY_RATIO) { + resolve(false); + return; + } + const rect = element.getBoundingClientRect(); + if (rect.width >= window.innerWidth * MAX_COVER_RATIO || rect.height >= window.innerHeight * MAX_COVER_RATIO) { + resolve(false); + return; + } + const canvas2 = new VisibilityCanvas(element); + const visibleAreaRatio = await canvas2.eval(this.qt); + resolve(visibleAreaRatio >= VISIBILITY_RATIO); + }); + observer.observe(element); + }); + } +} var visibility_default = VisibilityFilter; // src/filters/nesting.ts var SIZE_THRESHOLD = 0.9; @@ -290,9 +420,13 @@ class NestingFilter extends Filter { super(...arguments); } async apply(elements) { - const { top, others } = this.getTopLevelElements(elements); + const fullElements = elements.fixed.concat(elements.unknown); + const { top, others } = this.getTopLevelElements(fullElements); const results = await Promise.all(top.map(async (topElement) => this.compareTopWithChildren(topElement, others))); - return results.flat(); + return { + fixed: elements.fixed, + unknown: results.flat().filter((el) => elements.fixed.indexOf(el) === -1) + }; } async compareTopWithChildren(top, children) { if (PRIORITY_SELECTOR.some((selector) => top.matches(selector))) { @@ -311,7 +445,7 @@ class NestingFilter extends Filter { if (branch.children.length === 0) { return [branch.top]; } - return await this.compareTopWithChildren(branch.top, branch.children); + return this.compareTopWithChildren(branch.top, branch.children); })); const total = results.flat(); if (total.length > QUANTITY_THRESHOLD) { @@ -355,23 +489,38 @@ class Loader { }; async loadElements() { const selector = SELECTORS.join(","); - let preselectedElements = Array.from(document.querySelectorAll(selector)); + let fixedElements = Array.from(document.querySelectorAll(selector)); const shadowRoots = this.shadowRoots(); for (let i = 0;i < shadowRoots.length; i++) { - preselectedElements = preselectedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector))); + fixedElements = fixedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector))); } const allElements = document.querySelectorAll("*"); - let clickableElements = []; - for (let i = 0;i < allElements.length; i++) { - if (!allElements[i].matches(selector) && window.getComputedStyle(allElements[i]).cursor === "pointer") { - clickableElements.push(allElements[i]); + let unknownElements = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { + acceptNode() { + return NodeFilter.FILTER_ACCEPT; + } + }); + let node; + while (node = walker.nextNode()) { + const el = node; + if (!el.matches(selector) && window.getComputedStyle(el).cursor === "pointer") { + unknownElements.push(el); } } - clickableElements = Array.from(clickableElements).filter((element, index, self) => self.indexOf(element) === index).filter((element) => !element.closest("svg") && !preselectedElements.some((el) => el.contains(element))); - const visiblePreselected = await this.filters.visibility.apply(preselectedElements); - const visibleClickable = await this.filters.visibility.apply(clickableElements); - const nestedAll = await this.filters.nesting.apply(visibleClickable.concat(visiblePreselected)); - return visiblePreselected.concat(nestedAll).filter((element, index, self) => self.indexOf(element) === index); + unknownElements = Array.from(unknownElements).filter((element, index, self) => self.indexOf(element) === index).filter((element) => !element.closest("svg") && !fixedElements.some((el) => el.contains(element))); + let interactive = { + fixed: fixedElements, + unknown: unknownElements + }; + console.groupCollapsed("Elements"); + console.log("Before filters", interactive); + interactive = await this.filters.visibility.apply(interactive); + console.log("After visibility filter", interactive); + interactive = await this.filters.nesting.apply(interactive); + console.log("After nesting filter", interactive); + console.groupEnd(); + return interactive.fixed.concat(interactive.unknown); } shadowRoots() { const shadowRoots = []; @@ -764,7 +913,6 @@ class UI { ]; return distances.some((distance) => distance < SURROUNDING_RADIUS); }).map((box) => box.color); - console.groupCollapsed(`Element: ${element.tagName} (${i})`); const color = this.colors.contrastColor(element, surroundingColors); div.style.setProperty("--SoM-color", `${color.r}, ${color.g}, ${color.b}`); document.body.appendChild(div); diff --git a/dist/SoM.min.js b/dist/SoM.min.js index 57b5a2c..97edd62 100644 --- a/dist/SoM.min.js +++ b/dist/SoM.min.js @@ -1 +1 @@ -var A=["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"]'],y=['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"]'],P=0.6,M=0.2,u=200,S=0.7,_=0.25,L=0.3;class q{}class I extends q{constructor(){super(...arguments)}async apply(K){return(await Promise.all(K.map(async($)=>{if($.offsetWidth===0&&$.offsetHeight===0)return null;const Q=window.getComputedStyle($);if(Q.display==="none"||Q.visibility==="hidden"||Q.pointerEvents==="none")return null;let W=$.parentElement,X=!0;while(W!==null){const Z=window.getComputedStyle(W);if(Z.display==="none"||Z.visibility==="hidden"||Z.pointerEvents==="none"){X=!1;break}W=W.parentElement}if(!X)return null;if(!await this.isElementVisible($))return null;return $}))).filter(($)=>$!==null)}async isElementVisible(K){return new Promise((J)=>{const $=new IntersectionObserver(async(Q)=>{const W=Q[0];if($.disconnect(),W.intersectionRatio=window.innerWidth*0.8||X.height>=window.innerHeight*0.8){J(!1);return}const j=await this.getVisibilityRatio(K,X);J(j>=P)});$.observe(K)})}async getVisibilityRatio(K,J){const $=document.createElement("canvas"),Q=$.getContext("2d",{willReadFrequently:!0});if(!Q)throw new Error("Could not get 2D context");const W=parseInt(window.getComputedStyle(K).zIndex||"0",10);$.width=J.width,$.height=J.height,Q.fillStyle="black",Q.fillRect(0,0,$.width,$.height);const X={top:Math.max(0,J.top),left:Math.max(0,J.left),bottom:Math.min(window.innerHeight,J.bottom),right:Math.min(window.innerWidth,J.right),width:J.width,height:J.height};X.width=X.right-X.left,X.height=X.bottom-X.top,this.drawElement(K,Q,J,J,"white");const j=this.countVisiblePixels(Q,{top:X.top-J.top,left:X.left-J.left,width:$.width,height:$.height}),Z=await this.getIntersectingElements(K);await Promise.all(Z.map(async(Y)=>{const V=Y.getBoundingClientRect();this.drawElement(Y,Q,V,J,"black")}));const z=this.countVisiblePixels(Q,{top:X.top-J.top,left:X.left-J.left,width:X.width,height:X.height});if($.remove(),j===0)return 0;return z/j}async getIntersectingElements(K){const J=parseInt(window.getComputedStyle(K).zIndex||"0",10),$=K.getBoundingClientRect(),Q=await Promise.all(Array.from({length:Math.ceil(1/M)}).map(async(j,Z)=>{return Array.from({length:Math.ceil(1/M)}).map((z,Y)=>{const V=document.elementsFromPoint($.left+$.width*M*Z,$.top+$.height*M*Y);if(!V.includes(K))return[];const D=V.indexOf(K);return V.slice(0,D)})})),W=Array.from(new Set(Q.flat(2).filter((j)=>j!==K)));let X=[];for(let j of W){if(parseInt(window.getComputedStyle(j).zIndex||"0",10){for(let Z of X)if(j!==Z&&Z.contains(j))return!1;return!0}),X}countVisiblePixels(K,J){const $=K.getImageData(J.left,J.top,J.width,J.height).data;let Q=0;for(let W=0;W<$.length;W+=4)if($[W]>0)Q++;return Q}drawElement(K,J,$,Q,W="black"){const X=window.getComputedStyle(K),j=X.borderRadius?.split(" ").map((Y)=>parseFloat(Y)),Z=X.clipPath,z={top:$.top-Q.top,bottom:$.bottom-Q.top,left:$.left-Q.left,right:$.right-Q.left,width:$.width,height:$.height};if(z.width=z.right-z.left,z.height=z.bottom-z.top,J.fillStyle=W,Z&&Z!=="none")Z.split(/,| /).forEach((V)=>{const D=V.trim().match(/^([a-z]+)\((.*)\)$/);if(!D)return;switch(D[0]){case"polygon":const w=this.pathFromPolygon(V,$);J.fill(w);break;default:console.log("Unknown clip path kind: "+D)}});else if(j){const Y=new Path2D;if(j.length===1)j[1]=j[0];if(j.length===2)j[2]=j[0];if(j.length===3)j[3]=j[1];Y.moveTo(z.left+j[0],z.top),Y.arcTo(z.right,z.top,z.right,z.bottom,j[1]),Y.arcTo(z.right,z.bottom,z.left,z.bottom,j[2]),Y.arcTo(z.left,z.bottom,z.left,z.top,j[3]),Y.arcTo(z.left,z.top,z.right,z.top,j[0]),Y.closePath(),J.fill(Y)}else J.fillRect(z.left,z.top,z.width,z.height)}pathFromPolygon(K,J){if(!K||!K.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+K);const $=new Path2D,Q=K.match(/\d+(\.\d+)?%/g);if(Q&&Q.length>=2){const W=parseFloat(Q[0]),X=parseFloat(Q[1]);$.moveTo(W*J.width/100,X*J.height/100);for(let j=2;jthis.compareTopWithChildren(W,$)))).flat()}async compareTopWithChildren(K,J){if(d.some((j)=>K.matches(j)))return[K];const $=this.getBranches(K,J),Q=K.getBoundingClientRect();if($.length<=1)return[K];const X=(await Promise.all($.map(async(j)=>{const Z=j.top.getBoundingClientRect();if(Z.width/Q.widthp)return X;return[K,...X]}getBranches(K,J){const $=this.getFirstHitChildren(K,J);return $.map((Q)=>{const W=J.filter((X)=>!$.includes(X)&&Q.contains(X));return{top:Q,children:W}})}getFirstHitChildren(K,J){const $=K.querySelectorAll(":scope > *"),Q=Array.from($).filter((W)=>J.includes(W));if(Q.length>0)return Q;return Array.from($).flatMap((W)=>this.getFirstHitChildren(W,J))}getTopLevelElements(K){const J=[],$=[];for(let Q of K)if(!K.some((W)=>W!==Q&&W.contains(Q)))J.push(Q);else $.push(Q);return{top:J,others:$}}}var T=v;class k{filters={visibility:new N,nesting:new T};async loadElements(){const K=A.join(",");let J=Array.from(document.querySelectorAll(K));const $=this.shadowRoots();for(let z=0;z<$.length;z++)J=J.concat(Array.from($[z].querySelectorAll(K)));const Q=document.querySelectorAll("*");let W=[];for(let z=0;zV.indexOf(z)===Y).filter((z)=>!z.closest("svg")&&!J.some((Y)=>Y.contains(z)));const X=await this.filters.visibility.apply(J),j=await this.filters.visibility.apply(W),Z=await this.filters.nesting.apply(j.concat(X));return X.concat(Z).filter((z,Y,V)=>V.indexOf(z)===Y)}shadowRoots(){const K=[],J=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(Q){return NodeFilter.FILTER_ACCEPT}});let $;while($=J.nextNode())if($&&$.shadowRoot)K.push($.shadowRoot);return K}}class O{contrastColor(K,J){const $=window.getComputedStyle(K),Q=G.fromCSS($.backgroundColor);return this.getBestContrastColor([Q,...J])}getBestContrastColor(K){const J=K.filter((Q)=>Q.a>0).map((Q)=>Q.complimentary());let $;if(J.length===0)$=new G(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else $=this.getAverageColor(J);if($.luminance()>S)$=$.withLuminance(S);else if($.luminance()<_)$=$.withLuminance(_);if($.saturation()W+X.r,0)/K.length,$=K.reduce((W,X)=>W+X.g,0)/K.length,Q=K.reduce((W,X)=>W+X.b,0)/K.length;return new G(J,$,Q)}}class G{K;J;$;Q;constructor(K,J,$,Q=255){this.r=K;this.g=J;this.b=$;this.a=Q;if(K<0||K>255)throw new Error(`Invalid red value: ${K}`);if(J<0||J>255)throw new Error(`Invalid green value: ${J}`);if($<0||$>255)throw new Error(`Invalid blue value: ${$}`);if(Q<0||Q>255)throw new Error(`Invalid alpha value: ${Q}`);this.r=Math.round(K),this.g=Math.round(J),this.b=Math.round($),this.a=Math.round(Q)}static fromCSS(K){if(K.startsWith("#"))return G.fromHex(K);if(K.startsWith("rgb")){const $=K.replace(/rgba?\(/,"").replace(")","").split(",").map((Q)=>parseInt(Q.trim()));return new G(...$)}if(K.startsWith("hsl")){const $=K.replace(/hsla?\(/,"").replace(")","").split(",").map((Q)=>parseFloat(Q.trim()));return G.fromHSL({h:$[0],s:$[1],l:$[2]})}const J=x[K.toLowerCase()];if(J)return G.fromHex(J);throw new Error(`Unknown color format: ${K}`)}static fromHex(K){if(K=K.replace("#",""),K.length===3)K=K.split("").map((W)=>W+W).join("");const J=parseInt(K.substring(0,2),16),$=parseInt(K.substring(2,4),16),Q=parseInt(K.substring(4,6),16);if(K.length===8){const W=parseInt(K.substring(6,8),16);return new G(J,$,Q,W)}return new G(J,$,Q)}static fromHSL(K){const{h:J,s:$,l:Q}=K;let W,X,j;if($===0)W=X=j=Q;else{const Z=(V,D,w)=>{if(w<0)w+=1;if(w>1)w-=1;if(w<0.16666666666666666)return V+(D-V)*6*w;if(w<0.5)return D;if(w<0.6666666666666666)return V+(D-V)*(0.6666666666666666-w)*6;return V},z=Q<0.5?Q*(1+$):Q+$-Q*$,Y=2*Q-z;W=Z(Y,z,J+0.3333333333333333),X=Z(Y,z,J),j=Z(Y,z,J-0.3333333333333333)}return new G(W*255,X*255,j*255)}luminance(){const K=this.r/255,J=this.g/255,$=this.b/255,Q=[K,J,$].map((W)=>{if(W<=0.03928)return W/12.92;return Math.pow((W+0.055)/1.055,2.4)});return 0.2126*Q[0]+0.7152*Q[1]+0.0722*Q[2]}withLuminance(K){const J=this.luminance(),$=K/J,Q=Math.min(255,this.r*$),W=Math.min(255,this.g*$),X=Math.min(255,this.b*$);return new G(Q,W,X,this.a)}saturation(){return this.toHsl().s}withSaturation(K){const J=this.toHsl();return J.s=K,G.fromHSL(J)}contrast(K){const J=this.luminance(),$=K.luminance();return(Math.max(J,$)+0.05)/(Math.min(J,$)+0.05)}complimentary(){const K=this.toHsl();return K.h=(K.h+0.5)%1,G.fromHSL(K)}toHex(){const K=this.r.toString(16).padStart(2,"0"),J=this.g.toString(16).padStart(2,"0"),$=this.b.toString(16).padStart(2,"0");if(this.a<255){const Q=this.a.toString(16).padStart(2,"0");return`#${K}${J}${$}${Q}`}return`#${K}${J}${$}`}toHsl(){const K=this.r/255,J=this.g/255,$=this.b/255,Q=Math.max(K,J,$),W=Math.min(K,J,$);let X=(Q+W)/2,j=(Q+W)/2,Z=(Q+W)/2;if(Q===W)X=j=0;else{const z=Q-W;switch(j=Z>0.5?z/(2-Q-W):z/(Q+W),Q){case K:X=(J-$)/z+(J<$?6:0);break;case J:X=($-K)/z+2;break;case $:X=(K-J)/z+4;break}X/=6}return{h:X,s:j,l:Z,a:this.a/255}}toString(){return this.toHex()}}var x={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",honeydew:"#f0fff0",hotpink:"#ff69b4","indianred ":"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgrey:"#d3d3d3",lightgreen:"#90ee90",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370d8",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#d87093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};class H{colors=new O;display(K){const J=[],$=[],Q=[];for(let W=0;WX.matches(V)))Z.classList.add("editable");const z=$.filter((V)=>{return[Math.sqrt(Math.pow(j.left-V.left,2)+Math.pow(j.top-V.top,2)),Math.sqrt(Math.pow(j.right-V.right,2)+Math.pow(j.top-V.top,2)),Math.sqrt(Math.pow(j.left-V.left,2)+Math.pow(j.bottom-V.bottom,2)),Math.sqrt(Math.pow(j.right-V.right,2)+Math.pow(j.bottom-V.bottom,2))].some((w)=>wV.color);console.groupCollapsed(`Element: ${X.tagName} (${W})`);const Y=this.colors.contrastColor(X,z);Z.style.setProperty("--SoM-color",`${Y.r}, ${Y.g}, ${Y.b}`),document.body.appendChild(Z),$.push({top:j.top,bottom:j.bottom,left:j.left,right:j.right,width:j.width,height:j.height,color:Y}),Q.push(Z)}for(let W=0;W{let U=0;if(F.top<0||F.top+z.height>window.innerHeight||F.left<0||F.left+z.width>window.innerWidth)U+=Infinity;else J.concat($).forEach((B)=>{if(B.top<=j.top&&B.bottom>=j.bottom&&B.left<=j.left&&B.right>=j.right)return;const f=Math.max(0,Math.min(F.left+z.width,B.left+B.width)-Math.max(F.left,B.left)),R=Math.max(0,Math.min(F.top+z.height,B.top+B.height)-Math.max(F.top,B.top));U+=f*R});return U}),w=V[D.indexOf(Math.min(...D))];Z.style.top=`${w.top-j.top}px`,Z.style.left=`${w.left-j.left}px`,J.push({top:w.top,left:w.left,right:w.left+z.width,bottom:w.top+z.height,width:z.width,height:z.height}),X.setAttribute("data-SoM",`${W}`)}}getColorByLuminance(K){return K.luminance()>0.5?"black":"white"}}var E=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class g{loader=new k;ui=new H;async display(){this.log("Displaying...");const K=performance.now(),J=await this.loader.loadElements();this.clear(),this.ui.display(J),this.log("Done!",`Took ${performance.now()-K}ms to display ${J.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((K)=>{K.remove()}),document.querySelectorAll("[data-som]").forEach((K)=>{K.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((K)=>K.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((K)=>K.style.display="block")}resolve(K){return document.querySelector(`[data-som="${K}"]`)}log(...K){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...K)}}if(!document.getElementById("SoM-styles")){const K=document.createElement("style");K.id="SoM-styles",K.innerHTML=E;const J=setInterval(()=>{if(document.head)clearInterval(J),document.head.appendChild(K)},100)}window.SoM=new g;window.SoM.log("Ready!"); +var U=["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"]'],H=['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"]'],Y=0.6,d=0.8,G=10;var O=200,q=0.7,s=0.25,B=0.3;class W{}class F{x;y;width;height;element;constructor(h,u,t,i,w=null){this.x=h,this.y=u,this.width=t,this.height=i,this.element=w}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightparseInt(i);if(t==="auto"||i==="auto")return!!(w&Node.DOCUMENT_POSITION_PRECEDING);return!!(w&Node.DOCUMENT_POSITION_PRECEDING)}function j(h){if(h.offsetWidth===0&&h.offsetHeight===0)return!1;const u=h.getBoundingClientRect();if(u.width<=0||u.height<=0)return!1;const t=window.getComputedStyle(h);if(t.display==="none"||t.visibility==="hidden"||t.pointerEvents==="none")return!1;let i=h.parentElement;while(i!==null){const w=window.getComputedStyle(i);if(w.display==="none"||w.visibility==="hidden"||w.pointerEvents==="none")return!1;i=i.parentElement}return!0}class z{h;canvas;ctx;rect;visibleRect;constructor(h){this.element=h;this.element=h,this.rect=this.element.getBoundingClientRect(),this.canvas=new OffscreenCanvas(this.rect.width,this.rect.height),this.ctx=this.canvas.getContext("2d",{willReadFrequently:!0}),this.visibleRect={top:Math.max(0,this.rect.top),left:Math.max(0,this.rect.left),bottom:Math.min(window.innerHeight,this.rect.bottom),right:Math.min(window.innerWidth,this.rect.right),width:this.rect.width,height:this.rect.height},this.visibleRect.width=this.visibleRect.right-this.visibleRect.left,this.visibleRect.height=this.visibleRect.bottom-this.visibleRect.top}async eval(h){this.ctx.fillStyle="black",this.ctx.fillRect(0,0,this.rect.width,this.rect.height),this.drawElement(this.element,"white");const u={top:this.visibleRect.top-this.rect.top,bottom:this.visibleRect.bottom-this.rect.top,left:this.visibleRect.left-this.rect.left,right:this.visibleRect.right-this.rect.left,width:this.canvas.width,height:this.canvas.height},t=await this.countVisiblePixels(u);if(t===0)return 0;const i=this.getIntersectingElements(h);for(let a of i)this.drawElement(a,"black");return await this.countVisiblePixels(u)/t}getIntersectingElements(h){const u=new F(this.rect.left,this.rect.right,this.rect.width,this.rect.height,this.element);return h.query(u).map((i)=>i.element).filter((i)=>i!=this.element&&T(i,this.element)&&j(i))}async countVisiblePixels(h){const u=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let i=0;iparseFloat(y)),a=i.clipPath,f={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(f.width=f.right-f.left,f.height=f.bottom-f.top,this.ctx.fillStyle=u,a&&a!=="none")a.split(/,| /).forEach((p)=>{const g=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!g)return;switch(g[0]){case"polygon":const v=this.pathFromPolygon(p,t);this.ctx.fill(v);break;default:console.log("Unknown clip path kind: "+g)}});else if(w){const y=new Path2D;if(w.length===1)w[1]=w[0];if(w.length===2)w[2]=w[0];if(w.length===3)w[3]=w[1];y.moveTo(f.left+w[0],f.top),y.arcTo(f.right,f.top,f.right,f.bottom,w[1]),y.arcTo(f.right,f.bottom,f.left,f.bottom,w[2]),y.arcTo(f.left,f.bottom,f.left,f.top,w[3]),y.arcTo(f.left,f.top,f.right,f.top,w[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(f.left,f.top,f.width,f.height)}pathFromPolygon(h,u){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,i=h.match(/\d+(\.\d+)?%/g);if(i&&i.length>=2){const w=parseFloat(i[0]),a=parseFloat(i[1]);t.moveTo(w*u.width/100,a*u.height/100);for(let f=2;f{const w=h.slice(i*G,(i+1)*G).filter((f)=>j(f)),a=[];for(let f of w)if(await this.isDeepVisible(f))a.push(f);return a}))).flat()}mapQuadTree(){const h=new F(0,0,window.innerWidth,window.innerHeight),u=new J(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(w)=>{if(j(w))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let i=t.currentNode;while(i){const w=i,a=w.getBoundingClientRect();u.insert(new F(a.left,a.top,a.width,a.height,w)),i=t.nextNode()}return u}async isDeepVisible(h){return new Promise((u)=>{const t=new IntersectionObserver(async(i)=>{const w=i[0];if(t.disconnect(),w.intersectionRatio=window.innerWidth*d||a.height>=window.innerHeight*d){u(!1);return}const y=await new z(h).eval(this.qt);u(y>=Y)});t.observe(h)})}}var R=S;var L=0.9,b=3,E=["a","button","input","select","textarea"];class A extends W{constructor(){super(...arguments)}async apply(h){const u=h.fixed.concat(h.unknown),{top:t,others:i}=this.getTopLevelElements(u),w=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,i)));return{fixed:h.fixed,unknown:w.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,u){if(E.some((f)=>h.matches(f)))return[h];const t=this.getBranches(h,u),i=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(f)=>{const y=f.top.getBoundingClientRect();if(y.width/i.widthb)return a;return[h,...a]}getBranches(h,u){const t=this.getFirstHitChildren(h,u);return t.map((i)=>{const w=u.filter((a)=>!t.includes(a)&&i.contains(a));return{top:i,children:w}})}getFirstHitChildren(h,u){const t=h.querySelectorAll(":scope > *"),i=Array.from(t).filter((w)=>u.includes(w));if(i.length>0)return i;return Array.from(t).flatMap((w)=>this.getFirstHitChildren(w,u))}getTopLevelElements(h){const u=[],t=[];for(let i of h)if(!h.some((w)=>w!==i&&w.contains(i)))u.push(i);else t.push(i);return{top:u,others:t}}}var Z=A;class k{filters={visibility:new R,nesting:new Z};async loadElements(){const h=U.join(",");let u=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let p=0;pv.indexOf(p)===g).filter((p)=>!p.closest("svg")&&!u.some((g)=>g.contains(p)));let y={fixed:u,unknown:w};return console.groupCollapsed("Elements"),console.log("Before filters",y),y=await this.filters.visibility.apply(y),console.log("After visibility filter",y),y=await this.filters.nesting.apply(y),console.log("After nesting filter",y),console.groupEnd(),y.fixed.concat(y.unknown)}shadowRoots(){const h=[],u=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(i){return NodeFilter.FILTER_ACCEPT}});let t;while(t=u.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class D{contrastColor(h,u){const t=window.getComputedStyle(h),i=$.fromCSS(t.backgroundColor);return this.getBestContrastColor([i,...u])}getBestContrastColor(h){const u=h.filter((i)=>i.a>0).map((i)=>i.complimentary());let t;if(u.length===0)t=new $(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(u);if(t.luminance()>q)t=t.withLuminance(q);else if(t.luminance()w+a.r,0)/h.length,t=h.reduce((w,a)=>w+a.g,0)/h.length,i=h.reduce((w,a)=>w+a.b,0)/h.length;return new $(u,t,i)}}class ${h;u;t;i;constructor(h,u,t,i=255){this.r=h;this.g=u;this.b=t;this.a=i;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(u<0||u>255)throw new Error(`Invalid green value: ${u}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(i<0||i>255)throw new Error(`Invalid alpha value: ${i}`);this.r=Math.round(h),this.g=Math.round(u),this.b=Math.round(t),this.a=Math.round(i)}static fromCSS(h){if(h.startsWith("#"))return $.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((i)=>parseInt(i.trim()));return new $(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((i)=>parseFloat(i.trim()));return $.fromHSL({h:t[0],s:t[1],l:t[2]})}const u=c[h.toLowerCase()];if(u)return $.fromHex(u);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((w)=>w+w).join("");const u=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),i=parseInt(h.substring(4,6),16);if(h.length===8){const w=parseInt(h.substring(6,8),16);return new $(u,t,i,w)}return new $(u,t,i)}static fromHSL(h){const{h:u,s:t,l:i}=h;let w,a,f;if(t===0)w=a=f=i;else{const y=(v,K,P)=>{if(P<0)P+=1;if(P>1)P-=1;if(P<0.16666666666666666)return v+(K-v)*6*P;if(P<0.5)return K;if(P<0.6666666666666666)return v+(K-v)*(0.6666666666666666-P)*6;return v},p=i<0.5?i*(1+t):i+t-i*t,g=2*i-p;w=y(g,p,u+0.3333333333333333),a=y(g,p,u),f=y(g,p,u-0.3333333333333333)}return new $(w*255,a*255,f*255)}luminance(){const h=this.r/255,u=this.g/255,t=this.b/255,i=[h,u,t].map((w)=>{if(w<=0.03928)return w/12.92;return Math.pow((w+0.055)/1.055,2.4)});return 0.2126*i[0]+0.7152*i[1]+0.0722*i[2]}withLuminance(h){const u=this.luminance(),t=h/u,i=Math.min(255,this.r*t),w=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new $(i,w,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const u=this.toHsl();return u.s=h,$.fromHSL(u)}contrast(h){const u=this.luminance(),t=h.luminance();return(Math.max(u,t)+0.05)/(Math.min(u,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,$.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),u=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const i=this.a.toString(16).padStart(2,"0");return`#${h}${u}${t}${i}`}return`#${h}${u}${t}`}toHsl(){const h=this.r/255,u=this.g/255,t=this.b/255,i=Math.max(h,u,t),w=Math.min(h,u,t);let a=(i+w)/2,f=(i+w)/2,y=(i+w)/2;if(i===w)a=f=0;else{const p=i-w;switch(f=y>0.5?p/(2-i-w):p/(i+w),i){case h:a=(u-t)/p+(ua.matches(v)))y.classList.add("editable");const p=t.filter((v)=>{return[Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.bottom-v.bottom,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.bottom-v.bottom,2))].some((P)=>Pv.color),g=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${g.r}, ${g.g}, ${g.b}`),document.body.appendChild(y),t.push({top:f.top,bottom:f.bottom,left:f.left,right:f.right,width:f.width,height:f.height,color:g}),i.push(y)}for(let w=0;w{let X=0;if(Q.top<0||Q.top+p.height>window.innerHeight||Q.left<0||Q.left+p.width>window.innerWidth)X+=Infinity;else u.concat(t).forEach((M)=>{if(M.top<=f.top&&M.bottom>=f.bottom&&M.left<=f.left&&M.right>=f.right)return;const I=Math.max(0,Math.min(Q.left+p.width,M.left+M.width)-Math.max(Q.left,M.left)),C=Math.max(0,Math.min(Q.top+p.height,M.top+M.height)-Math.max(Q.top,M.top));X+=I*C});return X}),P=v[K.indexOf(Math.min(...K))];y.style.top=`${P.top-f.top}px`,y.style.left=`${P.left-f.left}px`,u.push({top:P.top,left:P.left,right:P.left+p.width,bottom:P.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${w}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new k;ui=new V;async display(){this.log("Displaying...");const h=performance.now(),u=await this.loader.loadElements();this.clear(),this.ui.display(u),this.log("Done!",`Took ${performance.now()-h}ms to display ${u.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const u=setInterval(()=>{if(document.head)clearInterval(u),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!"); diff --git a/src/constants.ts b/src/constants.ts index 7a04eb6..00c8f1d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -50,6 +50,13 @@ export const EDITABLE_SELECTORS = [ // Required visibility ratio for an element to be considered visible export const VISIBILITY_RATIO = 0.6; +// 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; + +// Size of batch for each promise when processing element visibility +// Lower batch values may increase performance but in some cases, it can block the main thread +export const ELEMENT_BATCH_SIZE = 10; + // Rate at which elements are sampled for elements on point // e.g. 0.1 => Make a grid and check on every point every 10% of the size of the element // This is used to make sure that every element that intersects with the element is checked diff --git a/src/domain/Filter.ts b/src/domain/Filter.ts new file mode 100644 index 0000000..70708ff --- /dev/null +++ b/src/domain/Filter.ts @@ -0,0 +1,5 @@ +import InteractiveElements from "./InteractiveElements"; + +export default abstract class Filter { + abstract apply(elements: InteractiveElements): Promise; +} diff --git a/src/domain/InteractiveElements.ts b/src/domain/InteractiveElements.ts new file mode 100644 index 0000000..3c8b305 --- /dev/null +++ b/src/domain/InteractiveElements.ts @@ -0,0 +1,6 @@ +type InteractiveElements = { + fixed: HTMLElement[]; + unknown: HTMLElement[]; +}; + +export default InteractiveElements; diff --git a/src/domain/filter.ts b/src/domain/filter.ts deleted file mode 100644 index 1491d3b..0000000 --- a/src/domain/filter.ts +++ /dev/null @@ -1,3 +0,0 @@ -export abstract class Filter { - abstract apply(elements: HTMLElement[]): Promise; -} diff --git a/src/filters/nesting.ts b/src/filters/nesting.ts index c6a15a3..89c8864 100644 --- a/src/filters/nesting.ts +++ b/src/filters/nesting.ts @@ -1,4 +1,5 @@ -import { Filter } from "../domain/filter"; +import Filter from "@/domain/Filter"; +import InteractiveElements from "@/domain/InteractiveElements"; // Threshold to be considered disjoint from the top-level element const SIZE_THRESHOLD = 0.9; @@ -8,11 +9,13 @@ const QUANTITY_THRESHOLD = 3; const PRIORITY_SELECTOR = ["a", "button", "input", "select", "textarea"]; class NestingFilter extends Filter { - async apply(elements: HTMLElement[]): Promise { + async apply(elements: InteractiveElements): Promise { // Basically, what we want to do it is compare the size of the top-level elements with the size of their children. // For that, we make branches and compare with the first children of each of these branches. // If there are other children beyond that, we'll recursively call this function on them. - const { top, others } = this.getTopLevelElements(elements); + + const fullElements = elements.fixed.concat(elements.unknown); + const { top, others } = this.getTopLevelElements(fullElements); const results = await Promise.all( top.map(async (topElement) => @@ -20,10 +23,16 @@ class NestingFilter extends Filter { ) ); - return results.flat(); + return { + fixed: elements.fixed, + unknown: results.flat().filter((el) => elements.fixed.indexOf(el) === -1), + }; } - async compareTopWithChildren(top: HTMLElement, children: HTMLElement[]) { + async compareTopWithChildren( + top: HTMLElement, + children: HTMLElement[] + ): Promise { if (PRIORITY_SELECTOR.some((selector) => top.matches(selector))) { return [top]; } @@ -53,7 +62,7 @@ class NestingFilter extends Filter { return [branch.top]; } - return await this.compareTopWithChildren(branch.top, branch.children); + return this.compareTopWithChildren(branch.top, branch.children); }) ); diff --git a/src/filters/visibility.ts b/src/filters/visibility.ts deleted file mode 100644 index 41a1f25..0000000 --- a/src/filters/visibility.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { VISIBILITY_RATIO, ELEMENT_SAMPLING_RATE } from "../constants"; -import { Filter } from "../domain/filter"; - -class VisibilityFilter extends Filter { - async apply(elements: HTMLElement[]): Promise { - const visibleElements = await Promise.all( - elements.map(async (element) => { - if (element.offsetWidth === 0 && element.offsetHeight === 0) { - return null; - } - - const style = window.getComputedStyle(element); - if ( - style.display === "none" || - style.visibility === "hidden" || - style.pointerEvents === "none" - ) { - return null; - } - - // Check if any of the element's parents are hidden - let parent = element.parentElement; - let passed = true; - while (parent !== null) { - const parentStyle = window.getComputedStyle(parent); - if ( - parentStyle.display === "none" || - parentStyle.visibility === "hidden" || - parentStyle.pointerEvents === "none" - ) { - passed = false; - break; - } - parent = parent.parentElement; - } - if (!passed) { - return null; - } - - // This checks if the element is in the viewport AND that is it visible more than VISIBILITY_RATIO (0.7) - const isVisible = await this.isElementVisible(element); - if (!isVisible) { - return null; - } - - return element; - }) - ); - - return visibleElements.filter( - (element) => element !== null - ) as HTMLElement[]; - } - - async isElementVisible(element: HTMLElement) { - return new Promise((resolve) => { - const observer = new IntersectionObserver(async (entries) => { - const entry = entries[0]; - observer.disconnect(); - - if (entry.intersectionRatio < VISIBILITY_RATIO) { - resolve(false); - return; - } - - const rect = element.getBoundingClientRect(); - - // If rect is way too small, ignore it - if (rect.width <= 1 || rect.height <= 1) { - resolve(false); - return; - } - - // If rect is covering more than 80% of the screen ignore it (we do not want to consider full screen ads) - if ( - rect.width >= window.innerWidth * 0.8 || - rect.height >= window.innerHeight * 0.8 - ) { - resolve(false); - return; - } - - // 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 visibleAreaRatio = await this.getVisibilityRatio(element, rect); - resolve(visibleAreaRatio >= VISIBILITY_RATIO); - }); - observer.observe(element); - }); - } - - async getVisibilityRatio(element: HTMLElement, rect: DOMRect) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d", { - willReadFrequently: true, - }); - - if (!ctx) { - throw new Error("Could not get 2D context"); - } - - const elementZIndex = parseInt( - window.getComputedStyle(element).zIndex || "0", - 10 - ); - - // Ensure the canvas size matches the element's size - canvas.width = rect.width; - canvas.height = rect.height; - - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // The whole canvas may not be visibile => we need to check the visible area for the viewport - const visibleRect = { - top: Math.max(0, rect.top), - left: Math.max(0, rect.left), - bottom: Math.min(window.innerHeight, rect.bottom), - right: Math.min(window.innerWidth, rect.right), - width: rect.width, - height: rect.height, - }; - visibleRect.width = visibleRect.right - visibleRect.left; - visibleRect.height = visibleRect.bottom - visibleRect.top; - - // Draw the element on the canvas - this.drawElement(element, ctx, rect, rect, "white"); - - // Count pixels that are visible after drawing the element - const totalPixels = this.countVisiblePixels(ctx, { - top: visibleRect.top - rect.top, - left: visibleRect.left - rect.left, - width: canvas.width, - height: canvas.height, - }); - - const elements = await this.getIntersectingElements(element); - - // Then, we draw all the intersecting elements on our canvas - await Promise.all( - elements.map(async (el) => { - const elRect = el.getBoundingClientRect(); - - // Try drawing it on the canvas - this.drawElement(el, ctx, elRect, rect, "black"); - }) - ); - - // Finally, calculate the visible pixels after drawing all the elements - const visiblePixels = this.countVisiblePixels(ctx, { - top: visibleRect.top - rect.top, - left: visibleRect.left - rect.left, - width: visibleRect.width, - height: visibleRect.height, - }); - - canvas.remove(); - - // Prevent NaN - if (totalPixels === 0) { - return 0; - } - - return visiblePixels / totalPixels; - } - - async getIntersectingElements(element: HTMLElement) { - const elementZIndex = parseInt( - window.getComputedStyle(element).zIndex || "0", - 10 - ); - - const rect = element.getBoundingClientRect(); - - // Find all elements that can possibly intersect with the element - // For this, we make a simple grid of points following ELEMENT_SAMPLING_RATE and check at each of those points - // Hence, we end up with (1 / ELEMENT_SAMPLING_RATE) ^ 2 points (or less if the element is too small) - const foundElements = await Promise.all( - Array.from({ length: Math.ceil(1 / ELEMENT_SAMPLING_RATE) }).map( - async (_, i) => { - return Array.from({ - length: Math.ceil(1 / ELEMENT_SAMPLING_RATE), - }).map((_, j) => { - const elements = document.elementsFromPoint( - rect.left + rect.width * ELEMENT_SAMPLING_RATE * i, - rect.top + rect.height * ELEMENT_SAMPLING_RATE * j - ); - - // Make sure the current element is included (point may miss if the element is not rectangular) - if (!elements.includes(element)) { - return []; - } - - const currentIndex = elements.indexOf(element); - - return elements.slice(0, currentIndex); - }); - } - ) - ); - - // We remove duplicates and flatten the array - const uniqueElements = Array.from( - new Set(foundElements.flat(2).filter((el) => el !== element)) - ); - - // We also remove all elements that have a lower zIndex, or are parents (that have lower/no zIndex) - let elements: Element[] = []; - for (const el of uniqueElements) { - const elZIndex = parseInt(window.getComputedStyle(el).zIndex || "0", 10); - if (elZIndex < elementZIndex) { - continue; - } - - if (el.contains(element) || element.contains(el)) { - continue; - } - - elements.push(el); - } - - // After that, remove anything that is a children of any other non-filtered element - elements = elements.filter((el) => { - for (const other of elements) { - if (el !== other && other.contains(el)) { - return false; - } - } - return true; - }); - - return elements; - } - - countVisiblePixels( - ctx: CanvasRenderingContext2D, - rect: { - top: number; - left: number; - width: number; - height: number; - } - ) { - const data = ctx.getImageData( - rect.left, - rect.top, - rect.width, - rect.height - ).data; - - let visiblePixels = 0; - for (let i = 0; i < data.length; i += 4) { - if (data[i] > 0) { - // Just check R channel - visiblePixels++; - } - } - return visiblePixels; - } - - drawElement( - element: Element, - ctx: CanvasRenderingContext2D, - rect: DOMRect, - baseRect: DOMRect, - color = "black" - ) { - const styles = window.getComputedStyle(element); - - const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r)); - const clipPath = styles.clipPath; - - const offsetRect = { - top: rect.top - baseRect.top, - bottom: rect.bottom - baseRect.top, - left: rect.left - baseRect.left, - right: rect.right - baseRect.left, - width: rect.width, - height: rect.height, - }; - offsetRect.width = offsetRect.right - offsetRect.left; - offsetRect.height = offsetRect.bottom - offsetRect.top; - - ctx.fillStyle = color; - - if (clipPath && clipPath !== "none") { - const clips = clipPath.split(/,| /); - - clips.forEach((clip) => { - const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/); - if (!kind) { - return; - } - - switch (kind[0]) { - case "polygon": - const path = this.pathFromPolygon(clip, rect); - ctx.fill(path); - break; - default: - console.log("Unknown clip path kind: " + kind); - } - }); - } else if (radius) { - const path = new Path2D(); - - if (radius.length === 1) radius[1] = radius[0]; - if (radius.length === 2) radius[2] = radius[0]; - if (radius.length === 3) radius[3] = radius[1]; - - // Go to the top left corner - path.moveTo(offsetRect.left + radius[0], offsetRect.top); - - path.arcTo( - // Arc to the top right corner - offsetRect.right, - offsetRect.top, - offsetRect.right, - offsetRect.bottom, - radius[1] - ); - - path.arcTo( - offsetRect.right, - offsetRect.bottom, - offsetRect.left, - offsetRect.bottom, - radius[2] - ); - - path.arcTo( - offsetRect.left, - offsetRect.bottom, - offsetRect.left, - offsetRect.top, - radius[3] - ); - - path.arcTo( - offsetRect.left, - offsetRect.top, - offsetRect.right, - offsetRect.top, - radius[0] - ); - path.closePath(); - - ctx.fill(path); - } else { - ctx.fillRect( - offsetRect.left, - offsetRect.top, - offsetRect.width, - offsetRect.height - ); - } - } - - pathFromPolygon(polygon: string, rect: DOMRect) { - if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) { - throw new Error("Invalid polygon format: " + polygon); - } - - const path = new Path2D(); - const points = polygon.match(/\d+(\.\d+)?%/g); - - if (points && points.length >= 2) { - const startX = parseFloat(points[0]); - const startY = parseFloat(points[1]); - path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100); - - for (let i = 2; i < points.length; i += 2) { - const x = parseFloat(points[i]); - const y = parseFloat(points[i + 1]); - path.lineTo((x * rect.width) / 100, (y * rect.height) / 100); - } - - path.closePath(); - } - - return path; - } -} - -export default VisibilityFilter; diff --git a/src/filters/visibility/canvas.ts b/src/filters/visibility/canvas.ts new file mode 100644 index 0000000..b062ace --- /dev/null +++ b/src/filters/visibility/canvas.ts @@ -0,0 +1,220 @@ +import QuadTree, { Rectangle } from "./quad"; +import { isAbove, isVisible } from "./utils"; + +export default class VisibilityCanvas { + private readonly canvas: OffscreenCanvas; + private readonly ctx: OffscreenCanvasRenderingContext2D; + + private readonly rect: AbstractRect; + private readonly visibleRect: AbstractRect; + + constructor(private readonly element: HTMLElement) { + this.element = element; + this.rect = this.element.getBoundingClientRect(); + this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height); + this.ctx = this.canvas.getContext("2d", { + willReadFrequently: true, + })!; + + this.visibleRect = { + top: Math.max(0, this.rect.top), + left: Math.max(0, this.rect.left), + bottom: Math.min(window.innerHeight, this.rect.bottom), + right: Math.min(window.innerWidth, this.rect.right), + width: this.rect.width, + height: this.rect.height, + }; + this.visibleRect.width = this.visibleRect.right - this.visibleRect.left; + this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top; + } + + async eval(qt: QuadTree): Promise { + this.ctx.fillStyle = "black"; + this.ctx.fillRect(0, 0, this.rect.width, this.rect.height); + + this.drawElement(this.element, "white"); + + const canvasVisRect: AbstractRect = { + top: this.visibleRect.top - this.rect.top, + bottom: this.visibleRect.bottom - this.rect.top, + left: this.visibleRect.left - this.rect.left, + right: this.visibleRect.right - this.rect.left, + width: this.canvas.width, + height: this.canvas.height, + }; + + const totalPixels = await this.countVisiblePixels(canvasVisRect); + if (totalPixels === 0) return 0; + + const elements = this.getIntersectingElements(qt); + for (const el of elements) { + this.drawElement(el, "black"); + } + + const visiblePixels = await this.countVisiblePixels(canvasVisRect); + return visiblePixels / totalPixels; + } + + private getIntersectingElements(qt: QuadTree): HTMLElement[] { + const range = new Rectangle( + this.rect.left, + this.rect.right, + this.rect.width, + this.rect.height, + this.element + ); + const candidates = qt.query(range); + + return candidates + .map((candidate) => candidate.element!) + .filter( + (el) => el != this.element && isAbove(el, this.element) && isVisible(el) + ); + } + + private async countVisiblePixels(visibleRect: AbstractRect): Promise { + const imageData = this.ctx.getImageData( + visibleRect.left, + visibleRect.top, + visibleRect.width, + visibleRect.height + ); + + let visiblePixels = 0; + for (let i = 0; i < imageData.data.length; i += 4) { + const isWhite = imageData.data[i] === 255; + if (isWhite) { + visiblePixels++; + } + } + + return visiblePixels; + } + + private drawElement(element: Element, color = "black") { + const rect = element.getBoundingClientRect(); + const styles = window.getComputedStyle(element); + + const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r)); + const clipPath = styles.clipPath; + + const offsetRect = { + top: rect.top - this.rect.top, + bottom: rect.bottom - this.rect.top, + left: rect.left - this.rect.left, + right: rect.right - this.rect.left, + width: rect.width, + height: rect.height, + }; + offsetRect.width = offsetRect.right - offsetRect.left; + offsetRect.height = offsetRect.bottom - offsetRect.top; + + this.ctx.fillStyle = color; + + if (clipPath && clipPath !== "none") { + const clips = clipPath.split(/,| /); + + clips.forEach((clip) => { + const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/); + if (!kind) { + return; + } + + switch (kind[0]) { + case "polygon": + const path = this.pathFromPolygon(clip, rect); + this.ctx.fill(path); + break; + default: + console.log("Unknown clip path kind: " + kind); + } + }); + } else if (radius) { + const path = new Path2D(); + + if (radius.length === 1) radius[1] = radius[0]; + if (radius.length === 2) radius[2] = radius[0]; + if (radius.length === 3) radius[3] = radius[1]; + + // Go to the top left corner + path.moveTo(offsetRect.left + radius[0], offsetRect.top); + + path.arcTo( + // Arc to the top right corner + offsetRect.right, + offsetRect.top, + offsetRect.right, + offsetRect.bottom, + radius[1] + ); + + path.arcTo( + offsetRect.right, + offsetRect.bottom, + offsetRect.left, + offsetRect.bottom, + radius[2] + ); + + path.arcTo( + offsetRect.left, + offsetRect.bottom, + offsetRect.left, + offsetRect.top, + radius[3] + ); + + path.arcTo( + offsetRect.left, + offsetRect.top, + offsetRect.right, + offsetRect.top, + radius[0] + ); + path.closePath(); + + this.ctx.fill(path); + } else { + this.ctx.fillRect( + offsetRect.left, + offsetRect.top, + offsetRect.width, + offsetRect.height + ); + } + } + + private pathFromPolygon(polygon: string, rect: AbstractRect) { + if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) { + throw new Error("Invalid polygon format: " + polygon); + } + + const path = new Path2D(); + const points = polygon.match(/\d+(\.\d+)?%/g); + + if (points && points.length >= 2) { + const startX = parseFloat(points[0]); + const startY = parseFloat(points[1]); + path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100); + + for (let i = 2; i < points.length; i += 2) { + const x = parseFloat(points[i]); + const y = parseFloat(points[i + 1]); + path.lineTo((x * rect.width) / 100, (y * rect.height) / 100); + } + + path.closePath(); + } + + return path; + } +} + +type AbstractRect = { + top: number; + left: number; + bottom: number; + right: number; + width: number; + height: number; +}; diff --git a/src/filters/visibility/index.ts b/src/filters/visibility/index.ts new file mode 100644 index 0000000..19c93c9 --- /dev/null +++ b/src/filters/visibility/index.ts @@ -0,0 +1,119 @@ +import { + VISIBILITY_RATIO, + ELEMENT_BATCH_SIZE, + MAX_COVER_RATIO, +} from "@/constants"; +import Filter from "@/domain/Filter"; +import InteractiveElements from "@/domain/InteractiveElements"; + +import QuadTree, { Rectangle } from "./quad"; +import { isVisible } from "./utils"; +import VisibilityCanvas from "./canvas"; + +class VisibilityFilter extends Filter { + private qt!: QuadTree; + + async apply(elements: InteractiveElements): Promise { + this.qt = this.mapQuadTree(); + + const results = await Promise.all([ + this.applyScoped(elements.fixed), + this.applyScoped(elements.unknown), + ]); + + return { + fixed: results[0], + unknown: results[1], + }; + } + + async applyScoped(elements: HTMLElement[]): Promise { + const results = await Promise.all( + Array.from({ + length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE), + }).map(async (_, i) => { + const batch = elements + .slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE) + .filter((el) => isVisible(el)); + + // Now, let's process the batch + const visibleElements: HTMLElement[] = []; + for (const element of batch) { + const isVisible = await this.isDeepVisible(element); + if (isVisible) { + visibleElements.push(element); + } + } + + return visibleElements; + }) + ); + + return results.flat(); + } + + mapQuadTree(): QuadTree { + const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight); + const qt = new QuadTree(boundary, 4); + + // use a tree walker and also filter out invisible elements + 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; + }, + } + ); + + 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) + ); + currentNode = walker.nextNode(); + } + + return qt; + } + + async isDeepVisible(element: HTMLElement) { + return new Promise((resolve) => { + const observer = new IntersectionObserver(async (entries) => { + const entry = entries[0]; + observer.disconnect(); + + if (entry.intersectionRatio < VISIBILITY_RATIO) { + resolve(false); + return; + } + + const rect = element.getBoundingClientRect(); + // If rect is covering more than size * MAX_COVER_RATIO of the screen ignore it (we do not want to consider full screen ads) + if ( + rect.width >= window.innerWidth * MAX_COVER_RATIO || + rect.height >= window.innerHeight * MAX_COVER_RATIO + ) { + resolve(false); + return; + } + + // 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); + resolve(visibleAreaRatio >= VISIBILITY_RATIO); + }); + observer.observe(element); + }); + } +} + +export default VisibilityFilter; diff --git a/src/filters/visibility/quad.ts b/src/filters/visibility/quad.ts new file mode 100644 index 0000000..ee3956a --- /dev/null +++ b/src/filters/visibility/quad.ts @@ -0,0 +1,127 @@ +export class Rectangle { + x: number; + y: number; + width: number; + height: number; + element: HTMLElement | null; + + constructor( + x: number, + y: number, + width: number, + height: number, + element: HTMLElement | null = null + ) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.element = element; + } + + contains(rect: Rectangle): boolean { + return ( + rect.x >= this.x && + rect.x + rect.width <= this.x + this.width && + rect.y >= this.y && + rect.y + rect.height <= this.y + this.height + ); + } + + intersects(rect: Rectangle): boolean { + return !( + rect.x > this.x + this.width || + rect.x + rect.width < this.x || + rect.y > this.y + this.height || + rect.y + rect.height < this.y + ); + } +} + +export default class QuadTree { + boundary: Rectangle; + capacity: number; + elements: Rectangle[]; + divided: boolean; + northeast: QuadTree | null; + northwest: QuadTree | null; + southeast: QuadTree | null; + southwest: QuadTree | null; + + constructor(boundary: Rectangle, capacity: number) { + this.boundary = boundary; + this.capacity = capacity; + this.elements = []; + this.divided = false; + this.northeast = null; + this.northwest = null; + this.southeast = null; + this.southwest = null; + } + + subdivide(): void { + const x = this.boundary.x; + const y = this.boundary.y; + const w = this.boundary.width / 2; + const h = this.boundary.height / 2; + + const ne = new Rectangle(x + w, y, w, h); + const nw = new Rectangle(x, y, w, h); + const se = new Rectangle(x + w, y + h, w, h); + const sw = new Rectangle(x, y + h, w, h); + + this.northeast = new QuadTree(ne, this.capacity); + this.northwest = new QuadTree(nw, this.capacity); + this.southeast = new QuadTree(se, this.capacity); + this.southwest = new QuadTree(sw, this.capacity); + + this.divided = true; + } + + insert(element: Rectangle): boolean { + if (!this.boundary.intersects(element)) { + return false; + } + + if (this.elements.length < this.capacity) { + this.elements.push(element); + return true; + } else { + if (!this.divided) { + this.subdivide(); + } + + if (this.northeast!.insert(element)) { + return true; + } else if (this.northwest!.insert(element)) { + return true; + } else if (this.southeast!.insert(element)) { + return true; + } else if (this.southwest!.insert(element)) { + return true; + } + return false; + } + } + + query(range: Rectangle, found: Rectangle[] = []): Rectangle[] { + if (!this.boundary.intersects(range)) { + return found; + } + + for (let element of this.elements) { + if (range.intersects(element)) { + found.push(element); + } + } + + if (this.divided) { + this.northwest!.query(range, found); + this.northeast!.query(range, found); + this.southwest!.query(range, found); + this.southeast!.query(range, found); + } + + return found; + } +} diff --git a/src/filters/visibility/utils.ts b/src/filters/visibility/utils.ts new file mode 100644 index 0000000..3525639 --- /dev/null +++ b/src/filters/visibility/utils.ts @@ -0,0 +1,71 @@ +/* + * Utility + */ +export function isAbove( + element: HTMLElement, + referenceElement: HTMLElement +): boolean { + const elementZIndex = window.getComputedStyle(element).zIndex; + const referenceElementZIndex = + window.getComputedStyle(referenceElement).zIndex; + + const elementPosition = element.compareDocumentPosition(referenceElement); + + // Check if element is a child of referenceElement + if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) { + return false; + } + + // Check if referenceElement is a child of element + if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return true; + } + + // Compare z-index if both are not 'auto' + if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") { + return parseInt(elementZIndex) > parseInt(referenceElementZIndex); + } + + // If one of them has z-index 'auto', we need to compare their DOM position + if (elementZIndex === "auto" || referenceElementZIndex === "auto") { + return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); + } + + // As a fallback, compare document order + return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); +} + +export function isVisible(element: HTMLElement): boolean { + if (element.offsetWidth === 0 && element.offsetHeight === 0) { + return false; + } + + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + + const style = window.getComputedStyle(element); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.pointerEvents === "none" + ) { + return false; + } + + let parent = element.parentElement; + while (parent !== null) { + const parentStyle = window.getComputedStyle(parent); + if ( + parentStyle.display === "none" || + parentStyle.visibility === "hidden" || + parentStyle.pointerEvents === "none" + ) { + return false; + } + parent = parent.parentElement; + } + + return true; +} diff --git a/src/loader.ts b/src/loader.ts index 8ca455a..38b4b1c 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,4 +1,5 @@ import { SELECTORS } from "./constants"; +import InteractiveElements from "./domain/InteractiveElements"; import { VisibilityFilter, NestingFilter } from "./filters"; export default class Loader { @@ -10,50 +11,64 @@ export default class Loader { async loadElements() { const selector = SELECTORS.join(","); - let preselectedElements = Array.from(document.querySelectorAll(selector)); + let fixedElements = Array.from( + document.querySelectorAll(selector) + ) as HTMLElement[]; // Let's also do a querySelectorAll inside all the shadow roots (for custom elements, e.g. reddit) const shadowRoots = this.shadowRoots(); for (let i = 0; i < shadowRoots.length; i++) { - preselectedElements = preselectedElements.concat( + fixedElements = fixedElements.concat( Array.from(shadowRoots[i].querySelectorAll(selector)) ); } - const allElements = document.querySelectorAll("*"); + let unknownElements: HTMLElement[] = []; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT, + { + acceptNode() { + return NodeFilter.FILTER_ACCEPT; + }, + } + ); - let clickableElements: HTMLElement[] = []; - for (let i = 0; i < allElements.length; i++) { + let node: Node | null; + while ((node = walker.nextNode())) { + const el = node as HTMLElement; if ( - // Make sure it does not match the selector too to avoid duplicates - !allElements[i].matches(selector) && - window.getComputedStyle(allElements[i]).cursor === "pointer" + !el.matches(selector) && + window.getComputedStyle(el).cursor === "pointer" ) { - clickableElements.push(allElements[i] as HTMLElement); + unknownElements.push(el); } } - clickableElements = Array.from(clickableElements) + unknownElements = Array.from(unknownElements) .filter((element, index, self) => self.indexOf(element) === index) .filter( (element) => !element.closest("svg") && - !preselectedElements.some((el) => el.contains(element)) + !fixedElements.some((el) => el.contains(element)) ); - const visiblePreselected = await this.filters.visibility.apply( - preselectedElements as HTMLElement[] - ); + let interactive: InteractiveElements = { + fixed: fixedElements, + unknown: unknownElements, + }; - const visibleClickable = - await this.filters.visibility.apply(clickableElements); - const nestedAll = await this.filters.nesting.apply( - visibleClickable.concat(visiblePreselected) - ); + console.groupCollapsed("Elements"); + console.log("Before filters", interactive); + + interactive = await this.filters.visibility.apply(interactive); + console.log("After visibility filter", interactive); + + interactive = await this.filters.nesting.apply(interactive); + console.log("After nesting filter", interactive); + console.groupEnd(); - return visiblePreselected - .concat(nestedAll) - .filter((element, index, self) => self.indexOf(element) === index); + return interactive.fixed.concat(interactive.unknown); } shadowRoots() { diff --git a/src/ui.ts b/src/ui.ts index 0e3f1a3..781cd0e 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -69,7 +69,6 @@ export default class UI { }) .map((box) => box.color); - console.groupCollapsed(`Element: ${element.tagName} (${i})`); const color = this.colors.contrastColor(element, surroundingColors); // Set color as variable to be used in CSS diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..355aa34 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + + "composite": true, + "strict": true, + "useDefineForClassFields": true, + "allowJs": false, + "skipLibCheck": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + "lib": ["DOM", "ESNext"], + + "baseUrl": ".", + "outDir": "dist", + "rootDir": "src", + + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "types/**/*.d.ts"], + "exclude": ["node_modules", "dist"] +} From 428a3423b605dd7b0004eb9013dc965fd9abac58 Mon Sep 17 00:00:00 2001 From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com> Date: Sat, 8 Jun 2024 11:11:14 +0200 Subject: [PATCH 3/5] fix(visibility): missing candidates from QT query & parent zIndex relationship for isAbove --- bun.lockb | Bin 2699 -> 2337 bytes dist/SoM.js | 63 +++-- dist/SoM.min.js | 2 +- package.json | 21 +- src/filters/visibility/canvas.ts | 431 ++++++++++++++++--------------- src/filters/visibility/index.ts | 225 ++++++++-------- src/filters/visibility/quad.ts | 249 +++++++++--------- src/filters/visibility/utils.ts | 118 +++++---- src/style.css | 44 ++-- 9 files changed, 590 insertions(+), 563 deletions(-) diff --git a/bun.lockb b/bun.lockb index 560b4d23acb810091fa78c41e0e3d8e846e555a7..52e2bdb64288959ff6a62315a57b81b9778925f4 100755 GIT binary patch delta 389 zcmeAcT_`j`PjjKAv9RI2-i1%sB`x{5F0%@F`PYH}lM&g2T#qg*?n?mxpixq{6`9%K*0zyA;b zVuKv;1FD9DZSpxbBQ6UzkOTvR2Z*aC1yaET#2{yboLB*se!w=_k6mkW4Z9cPg2@lr lCAqZNfwDlL#XgyZ!+|RT%I)Ns?8l)rIgdkO^8=36i~zPZQ=I?+ delta 617 zcmZ1|)GazePm|x^d+*FeCye)gRd|_mS^4nf^(^9tXYg4niOXFLE%D|0&B*`;Y!k!f zz4F)~90rE+)Fj>F{9Fb(pa35OLqkDPYDr0EYLO_A57g1{2}pARX&}V_6l-AO*!%ly zq7Gl-cdvu|8K>eVZpaYO0m``mR@6h>L*7sl{mycx%y|vrl{@zEi=#YTCJ~6 zV`Kece&gQeDZ3{0Oj;(`KflJRqxQK%S!Q0i-P_5!&JP2n4n9j*_{3Ay-nzl0nR7BP zv&7`HOx}#llO>tMC!b*Eom|W8&B!|WBy;k_2O>aku}n5#;ov9$Iza&F<;fdaawe}~ zIXbz4$&hOUG*pkUO|E9Ok^1)^0zj?>`RW5ynuUGxHC7`&kcSM|L6X4G0|_a~fmAUA zF%uAj{034|0afygeR2?+*5nE{KRZ}l!Qz04G0srWK+lkY0Tx?N-t3Y(pz!x16Jwo$ zo|&Ej&;|~mSwP^zG5Hmn16Ki*`XoEdZ7yS9%?JR%_m|!P diff --git a/dist/SoM.js b/dist/SoM.js index ae2ea08..bb90555 100644 --- a/dist/SoM.js +++ b/dist/SoM.js @@ -117,23 +117,27 @@ class QuadTree { if (!this.boundary.intersects(element)) { return false; } - if (this.elements.length < this.capacity) { + if (this.elements.length < this.capacity && !this.divided) { this.elements.push(element); return true; } else { if (!this.divided) { this.subdivide(); } - if (this.northeast.insert(element)) { - return true; - } else if (this.northwest.insert(element)) { - return true; - } else if (this.southeast.insert(element)) { - return true; - } else if (this.southwest.insert(element)) { - return true; + let inserted = false; + if (this.northeast.boundary.intersects(element)) { + inserted = this.northeast.insert(element) || inserted; } - return false; + if (this.northwest.boundary.intersects(element)) { + inserted = this.northwest.insert(element) || inserted; + } + if (this.southeast.boundary.intersects(element)) { + inserted = this.southeast.insert(element) || inserted; + } + if (this.southwest.boundary.intersects(element)) { + inserted = this.southwest.insert(element) || inserted; + } + return inserted; } } query(range, found = []) { @@ -151,26 +155,31 @@ class QuadTree { this.southwest.query(range, found); this.southeast.query(range, found); } - return found; + return found.filter((el, i, arr) => arr.indexOf(el) === i); } } // src/filters/visibility/utils.ts function isAbove(element, referenceElement) { - const elementZIndex = window.getComputedStyle(element).zIndex; - const referenceElementZIndex = window.getComputedStyle(referenceElement).zIndex; + function getEffectiveZIndex(element2) { + while (element2) { + const zIndex = window.getComputedStyle(element2).zIndex; + if (zIndex !== "auto") { + const zIndexValue = parseInt(zIndex, 10); + return isNaN(zIndexValue) ? 0 : zIndexValue; + } + element2 = element2.parentElement; + } + return 0; + } + const elementZIndex = getEffectiveZIndex(element); + const referenceElementZIndex = getEffectiveZIndex(referenceElement); const elementPosition = element.compareDocumentPosition(referenceElement); - if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) { + if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS || elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { return false; } - if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return true; - } - if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") { - return parseInt(elementZIndex) > parseInt(referenceElementZIndex); - } - if (elementZIndex === "auto" || referenceElementZIndex === "auto") { - return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); + if (elementZIndex !== referenceElementZIndex) { + return elementZIndex < referenceElementZIndex; } return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); } @@ -212,6 +221,7 @@ class VisibilityCanvas { this.ctx = this.canvas.getContext("2d", { willReadFrequently: true }); + this.ctx.imageSmoothingEnabled = false; this.visibleRect = { top: Math.max(0, this.rect.top), left: Math.max(0, this.rect.left), @@ -225,7 +235,7 @@ class VisibilityCanvas { } async eval(qt) { this.ctx.fillStyle = "black"; - this.ctx.fillRect(0, 0, this.rect.width, this.rect.height); + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.drawElement(this.element, "white"); const canvasVisRect = { top: this.visibleRect.top - this.rect.top, @@ -248,13 +258,13 @@ class VisibilityCanvas { getIntersectingElements(qt) { const range = new Rectangle(this.rect.left, this.rect.right, this.rect.width, this.rect.height, this.element); const candidates = qt.query(range); - return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(el, this.element) && isVisible(el)); + return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(this.element, el) && isVisible(el)); } async countVisiblePixels(visibleRect) { const imageData = this.ctx.getImageData(visibleRect.left, visibleRect.top, visibleRect.width, visibleRect.height); let visiblePixels = 0; for (let i = 0;i < imageData.data.length; i += 4) { - const isWhite = imageData.data[i] === 255; + const isWhite = imageData.data[i + 1] === 255; if (isWhite) { visiblePixels++; } @@ -494,7 +504,6 @@ class Loader { for (let i = 0;i < shadowRoots.length; i++) { fixedElements = fixedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector))); } - const allElements = document.querySelectorAll("*"); let unknownElements = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { acceptNode() { @@ -991,7 +1000,7 @@ class UI { } // src/style.css -var style_default = ".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}"; +var style_default = ".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.35)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.35) 10px,rgba(var(--SoM-color),.35) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:'Courier New',Courier,monospace;background-color:rgba(var(--SoM-color),.7)}"; // src/main.ts class SoM { diff --git a/dist/SoM.min.js b/dist/SoM.min.js index 97edd62..28bd9f9 100644 --- a/dist/SoM.min.js +++ b/dist/SoM.min.js @@ -1 +1 @@ -var U=["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"]'],H=['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"]'],Y=0.6,d=0.8,G=10;var O=200,q=0.7,s=0.25,B=0.3;class W{}class F{x;y;width;height;element;constructor(h,u,t,i,w=null){this.x=h,this.y=u,this.width=t,this.height=i,this.element=w}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightparseInt(i);if(t==="auto"||i==="auto")return!!(w&Node.DOCUMENT_POSITION_PRECEDING);return!!(w&Node.DOCUMENT_POSITION_PRECEDING)}function j(h){if(h.offsetWidth===0&&h.offsetHeight===0)return!1;const u=h.getBoundingClientRect();if(u.width<=0||u.height<=0)return!1;const t=window.getComputedStyle(h);if(t.display==="none"||t.visibility==="hidden"||t.pointerEvents==="none")return!1;let i=h.parentElement;while(i!==null){const w=window.getComputedStyle(i);if(w.display==="none"||w.visibility==="hidden"||w.pointerEvents==="none")return!1;i=i.parentElement}return!0}class z{h;canvas;ctx;rect;visibleRect;constructor(h){this.element=h;this.element=h,this.rect=this.element.getBoundingClientRect(),this.canvas=new OffscreenCanvas(this.rect.width,this.rect.height),this.ctx=this.canvas.getContext("2d",{willReadFrequently:!0}),this.visibleRect={top:Math.max(0,this.rect.top),left:Math.max(0,this.rect.left),bottom:Math.min(window.innerHeight,this.rect.bottom),right:Math.min(window.innerWidth,this.rect.right),width:this.rect.width,height:this.rect.height},this.visibleRect.width=this.visibleRect.right-this.visibleRect.left,this.visibleRect.height=this.visibleRect.bottom-this.visibleRect.top}async eval(h){this.ctx.fillStyle="black",this.ctx.fillRect(0,0,this.rect.width,this.rect.height),this.drawElement(this.element,"white");const u={top:this.visibleRect.top-this.rect.top,bottom:this.visibleRect.bottom-this.rect.top,left:this.visibleRect.left-this.rect.left,right:this.visibleRect.right-this.rect.left,width:this.canvas.width,height:this.canvas.height},t=await this.countVisiblePixels(u);if(t===0)return 0;const i=this.getIntersectingElements(h);for(let a of i)this.drawElement(a,"black");return await this.countVisiblePixels(u)/t}getIntersectingElements(h){const u=new F(this.rect.left,this.rect.right,this.rect.width,this.rect.height,this.element);return h.query(u).map((i)=>i.element).filter((i)=>i!=this.element&&T(i,this.element)&&j(i))}async countVisiblePixels(h){const u=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let i=0;iparseFloat(y)),a=i.clipPath,f={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(f.width=f.right-f.left,f.height=f.bottom-f.top,this.ctx.fillStyle=u,a&&a!=="none")a.split(/,| /).forEach((p)=>{const g=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!g)return;switch(g[0]){case"polygon":const v=this.pathFromPolygon(p,t);this.ctx.fill(v);break;default:console.log("Unknown clip path kind: "+g)}});else if(w){const y=new Path2D;if(w.length===1)w[1]=w[0];if(w.length===2)w[2]=w[0];if(w.length===3)w[3]=w[1];y.moveTo(f.left+w[0],f.top),y.arcTo(f.right,f.top,f.right,f.bottom,w[1]),y.arcTo(f.right,f.bottom,f.left,f.bottom,w[2]),y.arcTo(f.left,f.bottom,f.left,f.top,w[3]),y.arcTo(f.left,f.top,f.right,f.top,w[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(f.left,f.top,f.width,f.height)}pathFromPolygon(h,u){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,i=h.match(/\d+(\.\d+)?%/g);if(i&&i.length>=2){const w=parseFloat(i[0]),a=parseFloat(i[1]);t.moveTo(w*u.width/100,a*u.height/100);for(let f=2;f{const w=h.slice(i*G,(i+1)*G).filter((f)=>j(f)),a=[];for(let f of w)if(await this.isDeepVisible(f))a.push(f);return a}))).flat()}mapQuadTree(){const h=new F(0,0,window.innerWidth,window.innerHeight),u=new J(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(w)=>{if(j(w))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let i=t.currentNode;while(i){const w=i,a=w.getBoundingClientRect();u.insert(new F(a.left,a.top,a.width,a.height,w)),i=t.nextNode()}return u}async isDeepVisible(h){return new Promise((u)=>{const t=new IntersectionObserver(async(i)=>{const w=i[0];if(t.disconnect(),w.intersectionRatio=window.innerWidth*d||a.height>=window.innerHeight*d){u(!1);return}const y=await new z(h).eval(this.qt);u(y>=Y)});t.observe(h)})}}var R=S;var L=0.9,b=3,E=["a","button","input","select","textarea"];class A extends W{constructor(){super(...arguments)}async apply(h){const u=h.fixed.concat(h.unknown),{top:t,others:i}=this.getTopLevelElements(u),w=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,i)));return{fixed:h.fixed,unknown:w.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,u){if(E.some((f)=>h.matches(f)))return[h];const t=this.getBranches(h,u),i=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(f)=>{const y=f.top.getBoundingClientRect();if(y.width/i.widthb)return a;return[h,...a]}getBranches(h,u){const t=this.getFirstHitChildren(h,u);return t.map((i)=>{const w=u.filter((a)=>!t.includes(a)&&i.contains(a));return{top:i,children:w}})}getFirstHitChildren(h,u){const t=h.querySelectorAll(":scope > *"),i=Array.from(t).filter((w)=>u.includes(w));if(i.length>0)return i;return Array.from(t).flatMap((w)=>this.getFirstHitChildren(w,u))}getTopLevelElements(h){const u=[],t=[];for(let i of h)if(!h.some((w)=>w!==i&&w.contains(i)))u.push(i);else t.push(i);return{top:u,others:t}}}var Z=A;class k{filters={visibility:new R,nesting:new Z};async loadElements(){const h=U.join(",");let u=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let p=0;pv.indexOf(p)===g).filter((p)=>!p.closest("svg")&&!u.some((g)=>g.contains(p)));let y={fixed:u,unknown:w};return console.groupCollapsed("Elements"),console.log("Before filters",y),y=await this.filters.visibility.apply(y),console.log("After visibility filter",y),y=await this.filters.nesting.apply(y),console.log("After nesting filter",y),console.groupEnd(),y.fixed.concat(y.unknown)}shadowRoots(){const h=[],u=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(i){return NodeFilter.FILTER_ACCEPT}});let t;while(t=u.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class D{contrastColor(h,u){const t=window.getComputedStyle(h),i=$.fromCSS(t.backgroundColor);return this.getBestContrastColor([i,...u])}getBestContrastColor(h){const u=h.filter((i)=>i.a>0).map((i)=>i.complimentary());let t;if(u.length===0)t=new $(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(u);if(t.luminance()>q)t=t.withLuminance(q);else if(t.luminance()w+a.r,0)/h.length,t=h.reduce((w,a)=>w+a.g,0)/h.length,i=h.reduce((w,a)=>w+a.b,0)/h.length;return new $(u,t,i)}}class ${h;u;t;i;constructor(h,u,t,i=255){this.r=h;this.g=u;this.b=t;this.a=i;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(u<0||u>255)throw new Error(`Invalid green value: ${u}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(i<0||i>255)throw new Error(`Invalid alpha value: ${i}`);this.r=Math.round(h),this.g=Math.round(u),this.b=Math.round(t),this.a=Math.round(i)}static fromCSS(h){if(h.startsWith("#"))return $.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((i)=>parseInt(i.trim()));return new $(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((i)=>parseFloat(i.trim()));return $.fromHSL({h:t[0],s:t[1],l:t[2]})}const u=c[h.toLowerCase()];if(u)return $.fromHex(u);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((w)=>w+w).join("");const u=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),i=parseInt(h.substring(4,6),16);if(h.length===8){const w=parseInt(h.substring(6,8),16);return new $(u,t,i,w)}return new $(u,t,i)}static fromHSL(h){const{h:u,s:t,l:i}=h;let w,a,f;if(t===0)w=a=f=i;else{const y=(v,K,P)=>{if(P<0)P+=1;if(P>1)P-=1;if(P<0.16666666666666666)return v+(K-v)*6*P;if(P<0.5)return K;if(P<0.6666666666666666)return v+(K-v)*(0.6666666666666666-P)*6;return v},p=i<0.5?i*(1+t):i+t-i*t,g=2*i-p;w=y(g,p,u+0.3333333333333333),a=y(g,p,u),f=y(g,p,u-0.3333333333333333)}return new $(w*255,a*255,f*255)}luminance(){const h=this.r/255,u=this.g/255,t=this.b/255,i=[h,u,t].map((w)=>{if(w<=0.03928)return w/12.92;return Math.pow((w+0.055)/1.055,2.4)});return 0.2126*i[0]+0.7152*i[1]+0.0722*i[2]}withLuminance(h){const u=this.luminance(),t=h/u,i=Math.min(255,this.r*t),w=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new $(i,w,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const u=this.toHsl();return u.s=h,$.fromHSL(u)}contrast(h){const u=this.luminance(),t=h.luminance();return(Math.max(u,t)+0.05)/(Math.min(u,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,$.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),u=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const i=this.a.toString(16).padStart(2,"0");return`#${h}${u}${t}${i}`}return`#${h}${u}${t}`}toHsl(){const h=this.r/255,u=this.g/255,t=this.b/255,i=Math.max(h,u,t),w=Math.min(h,u,t);let a=(i+w)/2,f=(i+w)/2,y=(i+w)/2;if(i===w)a=f=0;else{const p=i-w;switch(f=y>0.5?p/(2-i-w):p/(i+w),i){case h:a=(u-t)/p+(ua.matches(v)))y.classList.add("editable");const p=t.filter((v)=>{return[Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.top-v.top,2)),Math.sqrt(Math.pow(f.left-v.left,2)+Math.pow(f.bottom-v.bottom,2)),Math.sqrt(Math.pow(f.right-v.right,2)+Math.pow(f.bottom-v.bottom,2))].some((P)=>Pv.color),g=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${g.r}, ${g.g}, ${g.b}`),document.body.appendChild(y),t.push({top:f.top,bottom:f.bottom,left:f.left,right:f.right,width:f.width,height:f.height,color:g}),i.push(y)}for(let w=0;w{let X=0;if(Q.top<0||Q.top+p.height>window.innerHeight||Q.left<0||Q.left+p.width>window.innerWidth)X+=Infinity;else u.concat(t).forEach((M)=>{if(M.top<=f.top&&M.bottom>=f.bottom&&M.left<=f.left&&M.right>=f.right)return;const I=Math.max(0,Math.min(Q.left+p.width,M.left+M.width)-Math.max(Q.left,M.left)),C=Math.max(0,Math.min(Q.top+p.height,M.top+M.height)-Math.max(Q.top,M.top));X+=I*C});return X}),P=v[K.indexOf(Math.min(...K))];y.style.top=`${P.top-f.top}px`,y.style.left=`${P.left-f.left}px`,u.push({top:P.top,left:P.left,right:P.left+p.width,bottom:P.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${w}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.45)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.45) 10px,rgba(var(--SoM-color),.45) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:\"Courier New\",Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new k;ui=new V;async display(){this.log("Displaying...");const h=performance.now(),u=await this.loader.loadElements();this.clear(),this.ui.display(u),this.log("Done!",`Took ${performance.now()-h}ms to display ${u.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const u=setInterval(()=>{if(document.head)clearInterval(u),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!"); +var O=["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"]'],U=['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"]'],Y=0.6,V=0.8,W=10;var H=200,z=0.7,q=0.25,B=0.3;class J{}class ${x;y;width;height;element;constructor(h,i,t,u,f=null){this.x=h,this.y=i,this.width=t,this.height=u,this.element=f}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightf.indexOf(t)===u)}}function S(h,i){function t(w){while(w){const y=window.getComputedStyle(w).zIndex;if(y!=="auto"){const p=parseInt(y,10);return isNaN(p)?0:p}w=w.parentElement}return 0}const u=t(h),f=t(i),a=h.compareDocumentPosition(i);if(a&Node.DOCUMENT_POSITION_CONTAINS||a&Node.DOCUMENT_POSITION_CONTAINED_BY)return!1;if(u!==f)return uu.element).filter((u)=>u!=this.element&&S(this.element,u)&&K(u))}async countVisiblePixels(h){const i=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let u=0;uparseFloat(y)),a=u.clipPath,w={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(w.width=w.right-w.left,w.height=w.bottom-w.top,this.ctx.fillStyle=i,a&&a!=="none")a.split(/,| /).forEach((p)=>{const v=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!v)return;switch(v[0]){case"polygon":const s=this.pathFromPolygon(p,t);this.ctx.fill(s);break;default:console.log("Unknown clip path kind: "+v)}});else if(f){const y=new Path2D;if(f.length===1)f[1]=f[0];if(f.length===2)f[2]=f[0];if(f.length===3)f[3]=f[1];y.moveTo(w.left+f[0],w.top),y.arcTo(w.right,w.top,w.right,w.bottom,f[1]),y.arcTo(w.right,w.bottom,w.left,w.bottom,f[2]),y.arcTo(w.left,w.bottom,w.left,w.top,f[3]),y.arcTo(w.left,w.top,w.right,w.top,f[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(w.left,w.top,w.width,w.height)}pathFromPolygon(h,i){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,u=h.match(/\d+(\.\d+)?%/g);if(u&&u.length>=2){const f=parseFloat(u[0]),a=parseFloat(u[1]);t.moveTo(f*i.width/100,a*i.height/100);for(let w=2;w{const f=h.slice(u*W,(u+1)*W).filter((w)=>K(w)),a=[];for(let w of f)if(await this.isDeepVisible(w))a.push(w);return a}))).flat()}mapQuadTree(){const h=new $(0,0,window.innerWidth,window.innerHeight),i=new M(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(f)=>{if(K(f))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let u=t.currentNode;while(u){const f=u,a=f.getBoundingClientRect();i.insert(new $(a.left,a.top,a.width,a.height,f)),u=t.nextNode()}return i}async isDeepVisible(h){return new Promise((i)=>{const t=new IntersectionObserver(async(u)=>{const f=u[0];if(t.disconnect(),f.intersectionRatio=window.innerWidth*V||a.height>=window.innerHeight*V){i(!1);return}const y=await new j(h).eval(this.qt);i(y>=Y)});t.observe(h)})}}var R=T;var L=0.9,I=3,c=["a","button","input","select","textarea"];class A extends J{constructor(){super(...arguments)}async apply(h){const i=h.fixed.concat(h.unknown),{top:t,others:u}=this.getTopLevelElements(i),f=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,u)));return{fixed:h.fixed,unknown:f.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,i){if(c.some((w)=>h.matches(w)))return[h];const t=this.getBranches(h,i),u=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(w)=>{const y=w.top.getBoundingClientRect();if(y.width/u.widthI)return a;return[h,...a]}getBranches(h,i){const t=this.getFirstHitChildren(h,i);return t.map((u)=>{const f=i.filter((a)=>!t.includes(a)&&u.contains(a));return{top:u,children:f}})}getFirstHitChildren(h,i){const t=h.querySelectorAll(":scope > *"),u=Array.from(t).filter((f)=>i.includes(f));if(u.length>0)return u;return Array.from(t).flatMap((f)=>this.getFirstHitChildren(f,i))}getTopLevelElements(h){const i=[],t=[];for(let u of h)if(!h.some((f)=>f!==u&&f.contains(u)))i.push(u);else t.push(u);return{top:i,others:t}}}var Z=A;class G{filters={visibility:new R,nesting:new Z};async loadElements(){const h=O.join(",");let i=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let y=0;yv.indexOf(y)===p).filter((y)=>!y.closest("svg")&&!i.some((p)=>p.contains(y)));let w={fixed:i,unknown:u};return console.groupCollapsed("Elements"),console.log("Before filters",w),w=await this.filters.visibility.apply(w),console.log("After visibility filter",w),w=await this.filters.nesting.apply(w),console.log("After nesting filter",w),console.groupEnd(),w.fixed.concat(w.unknown)}shadowRoots(){const h=[],i=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(u){return NodeFilter.FILTER_ACCEPT}});let t;while(t=i.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class k{contrastColor(h,i){const t=window.getComputedStyle(h),u=d.fromCSS(t.backgroundColor);return this.getBestContrastColor([u,...i])}getBestContrastColor(h){const i=h.filter((u)=>u.a>0).map((u)=>u.complimentary());let t;if(i.length===0)t=new d(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(i);if(t.luminance()>z)t=t.withLuminance(z);else if(t.luminance()f+a.r,0)/h.length,t=h.reduce((f,a)=>f+a.g,0)/h.length,u=h.reduce((f,a)=>f+a.b,0)/h.length;return new d(i,t,u)}}class d{h;i;t;u;constructor(h,i,t,u=255){this.r=h;this.g=i;this.b=t;this.a=u;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(i<0||i>255)throw new Error(`Invalid green value: ${i}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(u<0||u>255)throw new Error(`Invalid alpha value: ${u}`);this.r=Math.round(h),this.g=Math.round(i),this.b=Math.round(t),this.a=Math.round(u)}static fromCSS(h){if(h.startsWith("#"))return d.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((u)=>parseInt(u.trim()));return new d(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((u)=>parseFloat(u.trim()));return d.fromHSL({h:t[0],s:t[1],l:t[2]})}const i=E[h.toLowerCase()];if(i)return d.fromHex(i);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((f)=>f+f).join("");const i=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),u=parseInt(h.substring(4,6),16);if(h.length===8){const f=parseInt(h.substring(6,8),16);return new d(i,t,u,f)}return new d(i,t,u)}static fromHSL(h){const{h:i,s:t,l:u}=h;let f,a,w;if(t===0)f=a=w=u;else{const y=(s,F,g)=>{if(g<0)g+=1;if(g>1)g-=1;if(g<0.16666666666666666)return s+(F-s)*6*g;if(g<0.5)return F;if(g<0.6666666666666666)return s+(F-s)*(0.6666666666666666-g)*6;return s},p=u<0.5?u*(1+t):u+t-u*t,v=2*u-p;f=y(v,p,i+0.3333333333333333),a=y(v,p,i),w=y(v,p,i-0.3333333333333333)}return new d(f*255,a*255,w*255)}luminance(){const h=this.r/255,i=this.g/255,t=this.b/255,u=[h,i,t].map((f)=>{if(f<=0.03928)return f/12.92;return Math.pow((f+0.055)/1.055,2.4)});return 0.2126*u[0]+0.7152*u[1]+0.0722*u[2]}withLuminance(h){const i=this.luminance(),t=h/i,u=Math.min(255,this.r*t),f=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new d(u,f,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const i=this.toHsl();return i.s=h,d.fromHSL(i)}contrast(h){const i=this.luminance(),t=h.luminance();return(Math.max(i,t)+0.05)/(Math.min(i,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,d.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),i=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const u=this.a.toString(16).padStart(2,"0");return`#${h}${i}${t}${u}`}return`#${h}${i}${t}`}toHsl(){const h=this.r/255,i=this.g/255,t=this.b/255,u=Math.max(h,i,t),f=Math.min(h,i,t);let a=(u+f)/2,w=(u+f)/2,y=(u+f)/2;if(u===f)a=w=0;else{const p=u-f;switch(w=y>0.5?p/(2-u-f):p/(u+f),u){case h:a=(i-t)/p+(ia.matches(s)))y.classList.add("editable");const p=t.filter((s)=>{return[Math.sqrt(Math.pow(w.left-s.left,2)+Math.pow(w.top-s.top,2)),Math.sqrt(Math.pow(w.right-s.right,2)+Math.pow(w.top-s.top,2)),Math.sqrt(Math.pow(w.left-s.left,2)+Math.pow(w.bottom-s.bottom,2)),Math.sqrt(Math.pow(w.right-s.right,2)+Math.pow(w.bottom-s.bottom,2))].some((g)=>gs.color),v=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${v.r}, ${v.g}, ${v.b}`),document.body.appendChild(y),t.push({top:w.top,bottom:w.bottom,left:w.left,right:w.right,width:w.width,height:w.height,color:v}),u.push(y)}for(let f=0;f{let X=0;if(P.top<0||P.top+p.height>window.innerHeight||P.left<0||P.left+p.width>window.innerWidth)X+=Infinity;else i.concat(t).forEach((Q)=>{if(Q.top<=w.top&&Q.bottom>=w.bottom&&Q.left<=w.left&&Q.right>=w.right)return;const b=Math.max(0,Math.min(P.left+p.width,Q.left+Q.width)-Math.max(P.left,Q.left)),C=Math.max(0,Math.min(P.top+p.height,Q.top+Q.height)-Math.max(P.top,Q.top));X+=b*C});return X}),g=s[F.indexOf(Math.min(...F))];y.style.top=`${g.top-w.top}px`,y.style.left=`${g.left-w.left}px`,i.push({top:g.top,left:g.left,right:g.left+p.width,bottom:g.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${f}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.35)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.35) 10px,rgba(var(--SoM-color),.35) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:'Courier New',Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new G;ui=new D;async display(){this.log("Displaying...");const h=performance.now(),i=await this.loader.loadElements();this.clear(),this.ui.display(i),this.log("Done!",`Took ${performance.now()-h}ms to display ${i.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const i=setInterval(()=>{if(document.head)clearInterval(i),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!"); diff --git a/package.json b/package.json index d523832..6967f77 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,6 @@ { "name": "web-som", "version": "1.0.0", - "description": "Set-of-Marks script for web grounding, used for web agent automation", - "main": "dist/SoM.js", - "license": "(MIT OR Apache-2.0)", "author": { "name": "Brewen Couaran", "email": "contact@brewen.dev", @@ -13,20 +10,20 @@ "type": "git", "url": "git@github.com:brewcoua/web-som.git" }, - "homepage": "https://github.com/brewcoua/web-som#readme", + "main": "dist/SoM.js", + "devDependencies": { + "@types/contrast-color": "^1.0.3", + "clean-css": "^5.3.3", + "prettier": "^3.2.5" + }, "bugs": { "url": "https://github.com/brewcoua/web-som/issues", "email": "contact@brewen.dev" }, + "description": "Set-of-Marks script for web grounding, used for web agent automation", + "homepage": "https://github.com/brewcoua/web-som#readme", + "license": "(MIT OR Apache-2.0)", "scripts": { "build": "bun ./build.ts" - }, - "devDependencies": { - "@types/contrast-color": "^1.0.3", - "clean-css": "^5.3.3", - "prettier": "^3.2.5" - }, - "dependencies": { - "contrast-color": "^1.0.1" } } diff --git a/src/filters/visibility/canvas.ts b/src/filters/visibility/canvas.ts index b062ace..1edb78e 100644 --- a/src/filters/visibility/canvas.ts +++ b/src/filters/visibility/canvas.ts @@ -1,220 +1,223 @@ -import QuadTree, { Rectangle } from "./quad"; -import { isAbove, isVisible } from "./utils"; +import QuadTree, { Rectangle } from './quad'; +import { isAbove, isVisible } from './utils'; export default class VisibilityCanvas { - private readonly canvas: OffscreenCanvas; - private readonly ctx: OffscreenCanvasRenderingContext2D; - - private readonly rect: AbstractRect; - private readonly visibleRect: AbstractRect; - - constructor(private readonly element: HTMLElement) { - this.element = element; - this.rect = this.element.getBoundingClientRect(); - this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height); - this.ctx = this.canvas.getContext("2d", { - willReadFrequently: true, - })!; - - this.visibleRect = { - top: Math.max(0, this.rect.top), - left: Math.max(0, this.rect.left), - bottom: Math.min(window.innerHeight, this.rect.bottom), - right: Math.min(window.innerWidth, this.rect.right), - width: this.rect.width, - height: this.rect.height, - }; - this.visibleRect.width = this.visibleRect.right - this.visibleRect.left; - this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top; - } - - async eval(qt: QuadTree): Promise { - this.ctx.fillStyle = "black"; - this.ctx.fillRect(0, 0, this.rect.width, this.rect.height); - - this.drawElement(this.element, "white"); - - const canvasVisRect: AbstractRect = { - top: this.visibleRect.top - this.rect.top, - bottom: this.visibleRect.bottom - this.rect.top, - left: this.visibleRect.left - this.rect.left, - right: this.visibleRect.right - this.rect.left, - width: this.canvas.width, - height: this.canvas.height, - }; - - const totalPixels = await this.countVisiblePixels(canvasVisRect); - if (totalPixels === 0) return 0; - - const elements = this.getIntersectingElements(qt); - for (const el of elements) { - this.drawElement(el, "black"); - } - - const visiblePixels = await this.countVisiblePixels(canvasVisRect); - return visiblePixels / totalPixels; - } - - private getIntersectingElements(qt: QuadTree): HTMLElement[] { - const range = new Rectangle( - this.rect.left, - this.rect.right, - this.rect.width, - this.rect.height, - this.element - ); - const candidates = qt.query(range); - - return candidates - .map((candidate) => candidate.element!) - .filter( - (el) => el != this.element && isAbove(el, this.element) && isVisible(el) - ); - } - - private async countVisiblePixels(visibleRect: AbstractRect): Promise { - const imageData = this.ctx.getImageData( - visibleRect.left, - visibleRect.top, - visibleRect.width, - visibleRect.height - ); - - let visiblePixels = 0; - for (let i = 0; i < imageData.data.length; i += 4) { - const isWhite = imageData.data[i] === 255; - if (isWhite) { - visiblePixels++; - } - } - - return visiblePixels; - } - - private drawElement(element: Element, color = "black") { - const rect = element.getBoundingClientRect(); - const styles = window.getComputedStyle(element); - - const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r)); - const clipPath = styles.clipPath; - - const offsetRect = { - top: rect.top - this.rect.top, - bottom: rect.bottom - this.rect.top, - left: rect.left - this.rect.left, - right: rect.right - this.rect.left, - width: rect.width, - height: rect.height, - }; - offsetRect.width = offsetRect.right - offsetRect.left; - offsetRect.height = offsetRect.bottom - offsetRect.top; - - this.ctx.fillStyle = color; - - if (clipPath && clipPath !== "none") { - const clips = clipPath.split(/,| /); - - clips.forEach((clip) => { - const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/); - if (!kind) { - return; - } - - switch (kind[0]) { - case "polygon": - const path = this.pathFromPolygon(clip, rect); - this.ctx.fill(path); - break; - default: - console.log("Unknown clip path kind: " + kind); - } - }); - } else if (radius) { - const path = new Path2D(); - - if (radius.length === 1) radius[1] = radius[0]; - if (radius.length === 2) radius[2] = radius[0]; - if (radius.length === 3) radius[3] = radius[1]; - - // Go to the top left corner - path.moveTo(offsetRect.left + radius[0], offsetRect.top); - - path.arcTo( - // Arc to the top right corner - offsetRect.right, - offsetRect.top, - offsetRect.right, - offsetRect.bottom, - radius[1] - ); - - path.arcTo( - offsetRect.right, - offsetRect.bottom, - offsetRect.left, - offsetRect.bottom, - radius[2] - ); - - path.arcTo( - offsetRect.left, - offsetRect.bottom, - offsetRect.left, - offsetRect.top, - radius[3] - ); - - path.arcTo( - offsetRect.left, - offsetRect.top, - offsetRect.right, - offsetRect.top, - radius[0] - ); - path.closePath(); - - this.ctx.fill(path); - } else { - this.ctx.fillRect( - offsetRect.left, - offsetRect.top, - offsetRect.width, - offsetRect.height - ); - } - } - - private pathFromPolygon(polygon: string, rect: AbstractRect) { - if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) { - throw new Error("Invalid polygon format: " + polygon); - } - - const path = new Path2D(); - const points = polygon.match(/\d+(\.\d+)?%/g); - - if (points && points.length >= 2) { - const startX = parseFloat(points[0]); - const startY = parseFloat(points[1]); - path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100); - - for (let i = 2; i < points.length; i += 2) { - const x = parseFloat(points[i]); - const y = parseFloat(points[i + 1]); - path.lineTo((x * rect.width) / 100, (y * rect.height) / 100); - } - - path.closePath(); - } - - return path; - } + private readonly canvas: OffscreenCanvas; + private readonly ctx: OffscreenCanvasRenderingContext2D; + + private readonly rect: AbstractRect; + private readonly visibleRect: AbstractRect; + + constructor(private readonly element: HTMLElement) { + this.element = element; + this.rect = this.element.getBoundingClientRect(); + this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height); + this.ctx = this.canvas.getContext('2d', { + willReadFrequently: true, + })!; + this.ctx.imageSmoothingEnabled = false; + + this.visibleRect = { + top: Math.max(0, this.rect.top), + left: Math.max(0, this.rect.left), + bottom: Math.min(window.innerHeight, this.rect.bottom), + right: Math.min(window.innerWidth, this.rect.right), + width: this.rect.width, + height: this.rect.height, + }; + this.visibleRect.width = this.visibleRect.right - this.visibleRect.left; + this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top; + } + + async eval(qt: QuadTree): Promise { + this.ctx.fillStyle = 'black'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.drawElement(this.element, 'white'); + + const canvasVisRect: AbstractRect = { + top: this.visibleRect.top - this.rect.top, + bottom: this.visibleRect.bottom - this.rect.top, + left: this.visibleRect.left - this.rect.left, + right: this.visibleRect.right - this.rect.left, + width: this.canvas.width, + height: this.canvas.height, + }; + + const totalPixels = await this.countVisiblePixels(canvasVisRect); + if (totalPixels === 0) return 0; + + const elements = this.getIntersectingElements(qt); + + for (const el of elements) { + this.drawElement(el, 'black'); + } + + const visiblePixels = await this.countVisiblePixels(canvasVisRect); + return visiblePixels / totalPixels; + } + + private getIntersectingElements(qt: QuadTree): HTMLElement[] { + const range = new Rectangle( + this.rect.left, + this.rect.right, + this.rect.width, + this.rect.height, + this.element + ); + const candidates = qt.query(range); + + return candidates + .map((candidate) => candidate.element!) + .filter( + (el) => el != this.element && isAbove(this.element, el) && isVisible(el) + ); + } + + private async countVisiblePixels(visibleRect: AbstractRect): Promise { + const imageData = this.ctx.getImageData( + visibleRect.left, + visibleRect.top, + visibleRect.width, + visibleRect.height + ); + + let visiblePixels = 0; + for (let i = 0; i < imageData.data.length; i += 4) { + const isWhite = imageData.data[i + 1] === 255; + + if (isWhite) { + visiblePixels++; + } + } + + return visiblePixels; + } + + private drawElement(element: Element, color = 'black') { + const rect = element.getBoundingClientRect(); + const styles = window.getComputedStyle(element); + + const radius = styles.borderRadius?.split(' ').map((r) => parseFloat(r)); + const clipPath = styles.clipPath; + + const offsetRect = { + top: rect.top - this.rect.top, + bottom: rect.bottom - this.rect.top, + left: rect.left - this.rect.left, + right: rect.right - this.rect.left, + width: rect.width, + height: rect.height, + }; + offsetRect.width = offsetRect.right - offsetRect.left; + offsetRect.height = offsetRect.bottom - offsetRect.top; + + this.ctx.fillStyle = color; + + if (clipPath && clipPath !== 'none') { + const clips = clipPath.split(/,| /); + + clips.forEach((clip) => { + const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/); + if (!kind) { + return; + } + + switch (kind[0]) { + case 'polygon': + const path = this.pathFromPolygon(clip, rect); + this.ctx.fill(path); + break; + default: + console.log('Unknown clip path kind: ' + kind); + } + }); + } else if (radius) { + const path = new Path2D(); + + if (radius.length === 1) radius[1] = radius[0]; + if (radius.length === 2) radius[2] = radius[0]; + if (radius.length === 3) radius[3] = radius[1]; + + // Go to the top left corner + path.moveTo(offsetRect.left + radius[0], offsetRect.top); + + path.arcTo( + // Arc to the top right corner + offsetRect.right, + offsetRect.top, + offsetRect.right, + offsetRect.bottom, + radius[1] + ); + + path.arcTo( + offsetRect.right, + offsetRect.bottom, + offsetRect.left, + offsetRect.bottom, + radius[2] + ); + + path.arcTo( + offsetRect.left, + offsetRect.bottom, + offsetRect.left, + offsetRect.top, + radius[3] + ); + + path.arcTo( + offsetRect.left, + offsetRect.top, + offsetRect.right, + offsetRect.top, + radius[0] + ); + path.closePath(); + + this.ctx.fill(path); + } else { + this.ctx.fillRect( + offsetRect.left, + offsetRect.top, + offsetRect.width, + offsetRect.height + ); + } + } + + private pathFromPolygon(polygon: string, rect: AbstractRect) { + if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) { + throw new Error('Invalid polygon format: ' + polygon); + } + + const path = new Path2D(); + const points = polygon.match(/\d+(\.\d+)?%/g); + + if (points && points.length >= 2) { + const startX = parseFloat(points[0]); + const startY = parseFloat(points[1]); + path.moveTo((startX * rect.width) / 100, (startY * rect.height) / 100); + + for (let i = 2; i < points.length; i += 2) { + const x = parseFloat(points[i]); + const y = parseFloat(points[i + 1]); + path.lineTo((x * rect.width) / 100, (y * rect.height) / 100); + } + + path.closePath(); + } + + return path; + } } type AbstractRect = { - top: number; - left: number; - bottom: number; - right: number; - width: number; - height: number; + top: number; + left: number; + bottom: number; + right: number; + width: number; + height: number; }; diff --git a/src/filters/visibility/index.ts b/src/filters/visibility/index.ts index 19c93c9..0d3e4f3 100644 --- a/src/filters/visibility/index.ts +++ b/src/filters/visibility/index.ts @@ -1,119 +1,120 @@ import { - VISIBILITY_RATIO, - ELEMENT_BATCH_SIZE, - MAX_COVER_RATIO, -} from "@/constants"; -import Filter from "@/domain/Filter"; -import InteractiveElements from "@/domain/InteractiveElements"; + VISIBILITY_RATIO, + ELEMENT_BATCH_SIZE, + MAX_COVER_RATIO, +} from '@/constants'; +import Filter from '@/domain/Filter'; +import InteractiveElements from '@/domain/InteractiveElements'; -import QuadTree, { Rectangle } from "./quad"; -import { isVisible } from "./utils"; -import VisibilityCanvas from "./canvas"; +import QuadTree, { Rectangle } from './quad'; +import { isVisible } from './utils'; +import VisibilityCanvas from './canvas'; class VisibilityFilter extends Filter { - private qt!: QuadTree; - - async apply(elements: InteractiveElements): Promise { - this.qt = this.mapQuadTree(); - - const results = await Promise.all([ - this.applyScoped(elements.fixed), - this.applyScoped(elements.unknown), - ]); - - return { - fixed: results[0], - unknown: results[1], - }; - } - - async applyScoped(elements: HTMLElement[]): Promise { - const results = await Promise.all( - Array.from({ - length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE), - }).map(async (_, i) => { - const batch = elements - .slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE) - .filter((el) => isVisible(el)); - - // Now, let's process the batch - const visibleElements: HTMLElement[] = []; - for (const element of batch) { - const isVisible = await this.isDeepVisible(element); - if (isVisible) { - visibleElements.push(element); - } - } - - return visibleElements; - }) - ); - - return results.flat(); - } - - mapQuadTree(): QuadTree { - const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight); - const qt = new QuadTree(boundary, 4); - - // use a tree walker and also filter out invisible elements - 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; - }, - } - ); - - 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) - ); - currentNode = walker.nextNode(); - } - - return qt; - } - - async isDeepVisible(element: HTMLElement) { - return new Promise((resolve) => { - const observer = new IntersectionObserver(async (entries) => { - const entry = entries[0]; - observer.disconnect(); - - if (entry.intersectionRatio < VISIBILITY_RATIO) { - resolve(false); - return; - } - - const rect = element.getBoundingClientRect(); - // If rect is covering more than size * MAX_COVER_RATIO of the screen ignore it (we do not want to consider full screen ads) - if ( - rect.width >= window.innerWidth * MAX_COVER_RATIO || - rect.height >= window.innerHeight * MAX_COVER_RATIO - ) { - resolve(false); - return; - } - - // 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); - resolve(visibleAreaRatio >= VISIBILITY_RATIO); - }); - observer.observe(element); - }); - } + private qt!: QuadTree; + + async apply(elements: InteractiveElements): Promise { + this.qt = this.mapQuadTree(); + + const results = await Promise.all([ + this.applyScoped(elements.fixed), + this.applyScoped(elements.unknown), + ]); + + return { + fixed: results[0], + unknown: results[1], + }; + } + + async applyScoped(elements: HTMLElement[]): Promise { + const results = await Promise.all( + Array.from({ + length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE), + }).map(async (_, i) => { + const batch = elements + .slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE) + .filter((el) => isVisible(el)); + + // Now, let's process the batch + const visibleElements: HTMLElement[] = []; + for (const element of batch) { + const isVisible = await this.isDeepVisible(element); + if (isVisible) { + visibleElements.push(element); + } + } + + return visibleElements; + }) + ); + + return results.flat(); + } + + mapQuadTree(): QuadTree { + const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight); + const qt = new QuadTree(boundary, 4); + + // use a tree walker and also filter out invisible elements + 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; + }, + } + ); + + 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) + ); + currentNode = walker.nextNode(); + } + + return qt; + } + + async isDeepVisible(element: HTMLElement) { + return new Promise((resolve) => { + const observer = new IntersectionObserver(async (entries) => { + const entry = entries[0]; + observer.disconnect(); + + if (entry.intersectionRatio < VISIBILITY_RATIO) { + resolve(false); + return; + } + + const rect = element.getBoundingClientRect(); + // If rect is covering more than size * MAX_COVER_RATIO of the screen ignore it (we do not want to consider full screen ads) + if ( + rect.width >= window.innerWidth * MAX_COVER_RATIO || + rect.height >= window.innerHeight * MAX_COVER_RATIO + ) { + resolve(false); + return; + } + + // 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); + + resolve(visibleAreaRatio >= VISIBILITY_RATIO); + }); + observer.observe(element); + }); + } } export default VisibilityFilter; diff --git a/src/filters/visibility/quad.ts b/src/filters/visibility/quad.ts index ee3956a..79ed3c6 100644 --- a/src/filters/visibility/quad.ts +++ b/src/filters/visibility/quad.ts @@ -1,127 +1,132 @@ export class Rectangle { - x: number; - y: number; - width: number; - height: number; - element: HTMLElement | null; - - constructor( - x: number, - y: number, - width: number, - height: number, - element: HTMLElement | null = null - ) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - this.element = element; - } - - contains(rect: Rectangle): boolean { - return ( - rect.x >= this.x && - rect.x + rect.width <= this.x + this.width && - rect.y >= this.y && - rect.y + rect.height <= this.y + this.height - ); - } - - intersects(rect: Rectangle): boolean { - return !( - rect.x > this.x + this.width || - rect.x + rect.width < this.x || - rect.y > this.y + this.height || - rect.y + rect.height < this.y - ); - } + x: number; + y: number; + width: number; + height: number; + element: HTMLElement | null; + + constructor( + x: number, + y: number, + width: number, + height: number, + element: HTMLElement | null = null + ) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.element = element; + } + + contains(rect: Rectangle): boolean { + return ( + rect.x >= this.x && + rect.x + rect.width <= this.x + this.width && + rect.y >= this.y && + rect.y + rect.height <= this.y + this.height + ); + } + + intersects(rect: Rectangle): boolean { + return !( + rect.x > this.x + this.width || + rect.x + rect.width < this.x || + rect.y > this.y + this.height || + rect.y + rect.height < this.y + ); + } } export default class QuadTree { - boundary: Rectangle; - capacity: number; - elements: Rectangle[]; - divided: boolean; - northeast: QuadTree | null; - northwest: QuadTree | null; - southeast: QuadTree | null; - southwest: QuadTree | null; - - constructor(boundary: Rectangle, capacity: number) { - this.boundary = boundary; - this.capacity = capacity; - this.elements = []; - this.divided = false; - this.northeast = null; - this.northwest = null; - this.southeast = null; - this.southwest = null; - } - - subdivide(): void { - const x = this.boundary.x; - const y = this.boundary.y; - const w = this.boundary.width / 2; - const h = this.boundary.height / 2; - - const ne = new Rectangle(x + w, y, w, h); - const nw = new Rectangle(x, y, w, h); - const se = new Rectangle(x + w, y + h, w, h); - const sw = new Rectangle(x, y + h, w, h); - - this.northeast = new QuadTree(ne, this.capacity); - this.northwest = new QuadTree(nw, this.capacity); - this.southeast = new QuadTree(se, this.capacity); - this.southwest = new QuadTree(sw, this.capacity); - - this.divided = true; - } - - insert(element: Rectangle): boolean { - if (!this.boundary.intersects(element)) { - return false; - } - - if (this.elements.length < this.capacity) { - this.elements.push(element); - return true; - } else { - if (!this.divided) { - this.subdivide(); - } - - if (this.northeast!.insert(element)) { - return true; - } else if (this.northwest!.insert(element)) { - return true; - } else if (this.southeast!.insert(element)) { - return true; - } else if (this.southwest!.insert(element)) { - return true; - } - return false; - } - } - - query(range: Rectangle, found: Rectangle[] = []): Rectangle[] { - if (!this.boundary.intersects(range)) { - return found; - } - - for (let element of this.elements) { - if (range.intersects(element)) { - found.push(element); - } - } - - if (this.divided) { - this.northwest!.query(range, found); - this.northeast!.query(range, found); - this.southwest!.query(range, found); - this.southeast!.query(range, found); - } - - return found; - } + boundary: Rectangle; + capacity: number; + elements: Rectangle[]; + divided: boolean; + northeast: QuadTree | null; + northwest: QuadTree | null; + southeast: QuadTree | null; + southwest: QuadTree | null; + + constructor(boundary: Rectangle, capacity: number) { + this.boundary = boundary; + this.capacity = capacity; + this.elements = []; + this.divided = false; + this.northeast = null; + this.northwest = null; + this.southeast = null; + this.southwest = null; + } + + subdivide(): void { + const x = this.boundary.x; + const y = this.boundary.y; + const w = this.boundary.width / 2; + const h = this.boundary.height / 2; + + const ne = new Rectangle(x + w, y, w, h); + const nw = new Rectangle(x, y, w, h); + const se = new Rectangle(x + w, y + h, w, h); + const sw = new Rectangle(x, y + h, w, h); + + this.northeast = new QuadTree(ne, this.capacity); + this.northwest = new QuadTree(nw, this.capacity); + this.southeast = new QuadTree(se, this.capacity); + this.southwest = new QuadTree(sw, this.capacity); + + this.divided = true; + } + + insert(element: Rectangle): boolean { + if (!this.boundary.intersects(element)) { + return false; + } + + if (this.elements.length < this.capacity && !this.divided) { + this.elements.push(element); + return true; + } else { + if (!this.divided) { + this.subdivide(); + } + + let inserted = false; + if (this.northeast!.boundary.intersects(element)) { + inserted = this.northeast!.insert(element) || inserted; + } + if (this.northwest!.boundary.intersects(element)) { + inserted = this.northwest!.insert(element) || inserted; + } + if (this.southeast!.boundary.intersects(element)) { + inserted = this.southeast!.insert(element) || inserted; + } + if (this.southwest!.boundary.intersects(element)) { + inserted = this.southwest!.insert(element) || inserted; + } + + return inserted; + } + } + + query(range: Rectangle, found: Rectangle[] = []): Rectangle[] { + if (!this.boundary.intersects(range)) { + return found; + } + + for (let element of this.elements) { + if (range.intersects(element)) { + found.push(element); + } + } + + if (this.divided) { + this.northwest!.query(range, found); + this.northeast!.query(range, found); + this.southwest!.query(range, found); + this.southeast!.query(range, found); + } + + return found.filter((el, i, arr) => arr.indexOf(el) === i); + } } diff --git a/src/filters/visibility/utils.ts b/src/filters/visibility/utils.ts index 3525639..38b952a 100644 --- a/src/filters/visibility/utils.ts +++ b/src/filters/visibility/utils.ts @@ -1,71 +1,83 @@ /* * Utility */ + +/** + * Check if element is below referenceElement + * @param element The element to check + * @param referenceElement The reference element to check against + * @returns True if element is below referenceElement, false otherwise + */ export function isAbove( - element: HTMLElement, - referenceElement: HTMLElement + element: HTMLElement, + referenceElement: HTMLElement ): boolean { - const elementZIndex = window.getComputedStyle(element).zIndex; - const referenceElementZIndex = - window.getComputedStyle(referenceElement).zIndex; - - const elementPosition = element.compareDocumentPosition(referenceElement); + // Helper function to get the effective z-index value + function getEffectiveZIndex(element: HTMLElement): number { + while (element) { + const zIndex = window.getComputedStyle(element).zIndex; + if (zIndex !== 'auto') { + const zIndexValue = parseInt(zIndex, 10); + return isNaN(zIndexValue) ? 0 : zIndexValue; + } + element = element.parentElement as HTMLElement; + } + return 0; + } - // Check if element is a child of referenceElement - if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS) { - return false; - } + const elementZIndex = getEffectiveZIndex(element); + const referenceElementZIndex = getEffectiveZIndex(referenceElement); - // Check if referenceElement is a child of element - if (elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return true; - } + const elementPosition = element.compareDocumentPosition(referenceElement); - // Compare z-index if both are not 'auto' - if (elementZIndex !== "auto" && referenceElementZIndex !== "auto") { - return parseInt(elementZIndex) > parseInt(referenceElementZIndex); - } + // Check if element is a child or a parent of referenceElement + if ( + elementPosition & Node.DOCUMENT_POSITION_CONTAINS || + elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY + ) { + return false; + } - // If one of them has z-index 'auto', we need to compare their DOM position - if (elementZIndex === "auto" || referenceElementZIndex === "auto") { - return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); - } + // Compare z-index values + if (elementZIndex !== referenceElementZIndex) { + return elementZIndex < referenceElementZIndex; + } - // As a fallback, compare document order - return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); + // As a fallback, compare document order + return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); } export function isVisible(element: HTMLElement): boolean { - if (element.offsetWidth === 0 && element.offsetHeight === 0) { - return false; - } + if (element.offsetWidth === 0 && element.offsetHeight === 0) { + return false; + } - const rect = element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return false; - } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } - const style = window.getComputedStyle(element); - if ( - style.display === "none" || - style.visibility === "hidden" || - style.pointerEvents === "none" - ) { - return false; - } + const style = window.getComputedStyle(element); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.pointerEvents === 'none' + ) { + return false; + } - let parent = element.parentElement; - while (parent !== null) { - const parentStyle = window.getComputedStyle(parent); - if ( - parentStyle.display === "none" || - parentStyle.visibility === "hidden" || - parentStyle.pointerEvents === "none" - ) { - return false; - } - parent = parent.parentElement; - } + let parent = element.parentElement; + while (parent !== null) { + const parentStyle = window.getComputedStyle(parent); + if ( + parentStyle.display === 'none' || + parentStyle.visibility === 'hidden' || + parentStyle.pointerEvents === 'none' + ) { + return false; + } + parent = parent.parentElement; + } - return true; + return true; } diff --git a/src/style.css b/src/style.css index 7d6bc05..9d840fe 100644 --- a/src/style.css +++ b/src/style.css @@ -1,31 +1,31 @@ .SoM { - position: fixed; - z-index: 2147483646; - pointer-events: none; - background-color: rgba(var(--SoM-color), 0.45); + position: fixed; + z-index: 2147483646; + pointer-events: none; + background-color: rgba(var(--SoM-color), 0.35); } .SoM.editable { - /* Apply stripes effect to display that the element is editable, while keeping the same colors */ - background: repeating-linear-gradient( - 45deg, - rgba(var(--SoM-color), 0.15), - rgba(var(--SoM-color), 0.15) 10px, - rgba(var(--SoM-color), 0.45) 10px, - rgba(var(--SoM-color), 0.45) 20px - ); + /* Apply stripes effect to display that the element is editable, while keeping the same colors */ + background: repeating-linear-gradient( + 45deg, + rgba(var(--SoM-color), 0.15), + rgba(var(--SoM-color), 0.15) 10px, + rgba(var(--SoM-color), 0.35) 10px, + rgba(var(--SoM-color), 0.35) 20px + ); - /* Add an outline to make the element more visible */ - outline: 2px solid rgba(var(--SoM-color), 0.7); + /* Add an outline to make the element more visible */ + outline: 2px solid rgba(var(--SoM-color), 0.7); } .SoM > label { - position: absolute; - padding: 0 3px; - font-size: 1rem; - font-weight: bold; - line-height: 1.2rem; - white-space: nowrap; - font-family: "Courier New", Courier, monospace; - background-color: rgba(var(--SoM-color), 0.7); + position: absolute; + padding: 0 3px; + font-size: 1rem; + font-weight: bold; + line-height: 1.2rem; + white-space: nowrap; + font-family: 'Courier New', Courier, monospace; + background-color: rgba(var(--SoM-color), 0.7); } From 70af4f6ca978c2efe8a1e68082f72cac423a2675 Mon Sep 17 00:00:00 2001 From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com> Date: Sat, 8 Jun 2024 11:46:52 +0200 Subject: [PATCH 4/5] build: use rollup to build & add CI with semantic release --- .github/workflows/ci.yml | 47 ++ .gitignore | 5 +- build.ts | 51 -- bun.lockb | Bin 2337 -> 221847 bytes dist/SoM.js | 1050 -------------------------------------- dist/SoM.min.js | 1 - package.json | 26 +- release.config.ts | 33 ++ rollup.config.ts | 27 + 9 files changed, 132 insertions(+), 1108 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 build.ts delete mode 100644 dist/SoM.js delete mode 100644 dist/SoM.min.js create mode 100644 release.config.ts create mode 100644 rollup.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..217d15e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build project + run: bun run build + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build project + run: bun run build + + - name: Release + run: bunx semantic-release + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 40b878d..7dcffc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -node_modules/ \ No newline at end of file +node_modules/ +.rollup.cache +*.tsbuildinfo +dist/ \ No newline at end of file diff --git a/build.ts b/build.ts deleted file mode 100644 index d4d601b..0000000 --- a/build.ts +++ /dev/null @@ -1,51 +0,0 @@ -import CleanCSS from "clean-css"; -import { renameSync, rmSync } from "fs"; -import type { BunPlugin } from "bun"; - -const InlineStylePlugin: BunPlugin = { - name: "inline-style", - setup(build) { - // Bundle CSS files as text - build.onLoad({ filter: /\.css$/ }, async (args) => { - const value = await Bun.file(args.path).text(); - const minified = new CleanCSS().minify(value); - - return { - contents: minified.styles, - loader: "text", - }; - }); - }, -}; - -async function main() { - process.env.FORCE_COLOR = "1"; - - rmSync("dist", { recursive: true, force: true }); - - const result1 = await Bun.build({ - entrypoints: ["./src/main.ts"], - outdir: "./dist", - plugins: [InlineStylePlugin], - }); - if (!result1.success) { - result1.logs.forEach(console.log); - process.exit(1); - } - renameSync("./dist/main.js", "./dist/SoM.js"); - - const result2 = await Bun.build({ - entrypoints: ["./src/main.ts"], - outdir: "./dist", - plugins: [InlineStylePlugin], - minify: true, - }); - if (!result2.success) { - result2.logs.forEach(console.log); - process.exit(1); - } - - renameSync("./dist/main.js", "./dist/SoM.min.js"); -} - -main().catch(console.error); diff --git a/bun.lockb b/bun.lockb index 52e2bdb64288959ff6a62315a57b81b9778925f4..3dcf2c0cf3373eff3dbd76b441def2181be28740 100755 GIT binary patch literal 221847 zcmeFad0b8J_xFEFNU3O4M1x34g9;@j&Bh2BiiXpqxg>Ljh)8BKC4@30b44ig7$rlI zIi(OXmfu>Pz1}uA*(bk0?)&lhT;KQUb*|yL*1Cp$?Q8Gy{b;J|PmT!J_w*0e=Lbir zc}@N2FeqfrKcJ$9sNs4FB+omz~8r_CD*=Sz=1WYvaY= z_r4zBYAiXo=jXoGEj^n-D;`fD8$%Ko$bTBcLA+w9k>K%SA|nF* zJ>mO#__TriRF81q2>9*+hFfFauy-(Ic_W#;m6(8Az-Tv6C1}?XR1(w%+O-8W0hIye zM|gPzdU*4_0{tiZgPpN0c)V6nz6liDKL?e<_ALc=Bm8}XJp%c>&`G{}ULnCgFQHr- z>W4wskPG$qnTqk)K_27(4%!ZsA08eOuE&qw360RMIh3RN_y>FIO%CPpp)uz7F&c_P z?h(lc^$+ro&D5c^O45r;{@@%sqns0SdA^U@kJ z8lXENgZ*v>d9*VM+9`qt%JO&}K;MHZfCk73xN)H9w;akbj%7?7%b;_}yF)qCuaGIh zJl-IHF`hmEsezLNxY#5|YP#@|M9>F)^@koQF;i}^CMs*a-U!)&D7?)ZgZEuy5$`J-qxLKpxwF0XrB+gqFa6 zIVgk};{gg&9#aU4>-#CR!}a6PmB)i2jR}T4s)M%Rx;_kw@tJ}mrwfYqSLq7kdk>21 zEw;PB-(ekrPKG?rk2ff;<8e&=OK6Y$Tu`((2^9AaXHfLZ10L6_3iv_pHsrB=AE?Lp zJA1yF3~9{~%~mme(#d2H_=;SnD0F_l*h9QLcwP*ARKBn*Ss&_`fzJfqH_lF;4)6z7rp?H}yN z5BHCZFobf9zYEMK`k8OetcTD*|40~%wos1a*bEfMVT!1rKLMcF&-NAq`;nlnAb%7T z+j)bcJsD86Py6Nheu6mo(R?oti2IJEfIkb0*LfC`KLCpM8m>FNpokriM?0Y&k$!rU z_)|AB^@&XRJWz~>;#+1d7^i3`$No?8^Y`)tfBq5R8Ps@yV7yw|2spCq2JIX6k>Dsj zFMo*1R?v>(r8vo764YZ{pIG_1OO)e#jIk=D9I>D^OJ^A3Rhr|A#=)|CEq$?zD3}q}Wc++dusNaKZYy<|0^s_9F#;APnpVZx!SvfHwj~`*hz| zfjnMM;a&~RcpXLy?6d{NaSRR(YN()j8a-Ap4*futp zbCm{MRA`^h9Ts2@uS2@u%R#)@|6i|%?_&hx3v=QHM;_lLFdo={cPPjGavLb_PgT$# z8Bkf+bkJ^dP>e5}FE|&$`3lZs-uze49>_zL{fI85~j z420#wGlq*6xxUa2<8X)R#C0&3$q$1(_V*0%sQh5??jP)XAJ!Dkx8WqgI#>mHtVjFP z;CwIuIE>ppP+&I#4%QJcA0Hs!8p z!Xuu>Eoc&43}C>^4~}Wj`3I*7?92wm_(PcdQqXpgPjeN_U*u#Ty#VM_&l!UJbWmL1 zI~eX8>|#ogF9*eSQv{0r$^*sqd;}EN!LnI`c3u$?@F)WJQz&l@}%*Ygkd3E|N`l+VO{l+n$g=w}6^3qf%l z!Wi`h#dYDvs0TRogll{RlXqm)l2H>zbs4pf6|7Tle;>F4dAt@-j{Sgr*UR5KoM*j2 zVBa4P?r=Wx2>0@X?(nV+7xeQiD7N>BfQKeHeC}K*h&Lbt9=hN`v_eEMZX$4>X$S2l zhX?8fg!l)C>OpNJKb+Px+y`N{VT=|D_*shu_Cr8%+{S_8{5XK({PY6F>kIcuPY5U? z&?5rx-+h)a@%skDD&g@An0ydF+?Q{}AbHeWjpX0+XK(iv8{lit(0#U0la`pm^WC2;-~{dJtmAagtmkSik9YOnnb_VSTss_qq%1hVsc$$#U2Gs!F z2-*pBCZiKTk+)*fEZf6bsqUXfG z5buVG{nPo1&V6(){L}f0)}bTV$8qifit)f0HuM{w)k6ZK_&nq7g8t}$;y9n&CKz`b zM`+#YX{Y)mL>UXbNJwDX#mD4`U+odm`$CdP0y{>M%5`SK8tHiypJ&yd^d>HcVEv1Z?vu~Qc=_~15dNnOMx zojId*l~ND8`NY_(b+~u9)s%#-nIr3FULSe=$sB`0isSFt=6QKt?fNc!Wr<&wl$I%-hY5pLTs~u6RdN2w zvGwE6b}v_+*>%W&{n0kJO4XgY$La9*vIpbKM&^cfz5i;qll=2x)0zz)x&COxB8U1z zJ37xO-?dog_U#s-Uq`qr-+i$7!G3JpVrOfsZa(gkje`J@-osZ|1mCtBt8Mru0 z?WFfKE8WLE^;{&!c0Fx6C(-5RoBWxf8;h^p+7=Ksd20PQRR_!XR*Rg4Rw#L%UhT2PRI1tVesbT6?WXBf2IX&Xec^n06jX|ns<9%?wW9V>5kfH?-A1Hq=qlLy|1IIlZ5lb zBa+3^H4d~cNX>lGB35>F4@hg(K}M%X$LqC~&7Rmqm@22g`Nk1DH_$#h72q?I+Oz;(~~8uh^8g(JMAZ|iP!KiYER z@++G2o~P7VI$u2Iw|4!j{T5s050BJV?NB#+dD@GMT7%V-^DRr{cp5Kb<4@jLBYF4z zYS$x@VV3SSQC|jZ+4JUTT;h81XT4LT*Bzg{G~24(mg(!4?s73I>Ts%L#G(A{A?xRt zZ|`{OQFe5N^Buc7%jGV&qWhZ^-kP&LPxhYJ(!Oa$u4(7uGZx&cm~!Wd%FzAs7iLKo zUw!cPO>3n9znfnMDJJi=G<)>T=E_fAcH6#7hY;);dHPH)Q*Q+0$zHO+J$G$6mNA0$4ywKgm zDOyjra)ypdx!i-9xwpLf$ZF2E>-O%IntX=jy`bpWBe%lq)4$w)yhO`%&@|m1TVD6D zoobzwecD#)uF~2bvN1kS+r?g3f6{gLlakTB_ax{YFKsjE%Ba5K+KYmca@3c5oIImD z{P>n(U8XroDSjIJW=YvhqwhylhcsWDB_p=^@dTp|G44akM!a;^?qB8o^kr?_^g$i$ z=0ug>5$z;xwWgx|^bxN2=ACY*cX*BhFn%~y0mMd%$?@;rEg*`57X)LImzbi zw?3Aa!xnqYX=RswWT&g~on;SdZ`o;Gu4?Yuze}!ZO3&Lvw(R)aEBoei&s`CR7OLOZ zckk-1{^GK^o%!17JBCuf+a^^gwR>3Tdt~9(W40l2sbg0q9y)1|bS8Y3e$w?aE46&n z9-<%OckYS%oTk-U!Mt}|oaxs3;&GPRZy&slP^gVqXtnNMzYI$)_M16ojOxVqD?8}U zNJ&v0>)ADQw_naNjt8#UTUb?J;Dmu^Pn z))x)$+v@fZyPcKG9vQdmXgyA2PPfB5)@**&Ty0{1icciXBhP=C$-!Z>0&_|Z9Ncpy zDBCoF9%vUb0lji%( zzo*^r)aHtp-c<9&DJ4egpHC;;jhFenZI7s^Om)$yGY`%^IFc^oHGD@h-51i;T~ofq ze(zk_T1{DUu9i{Ku!5Sg*VafhFCHionPL)iRIk|%nd^Bq;)@2fe7G&eg6>OQ-%gJ{ zb>#iJk)y9T^Iy$!h(Gi+EmJlux4FBvFgoVs+CeqC z{+pDmCELByA1XQe>a4U3_4pAc+w>l`t0}oHGQa)pX$GUG8h0NdA!p(!SL*ZhRhG&r zwYmbg;(H+*ZA7N7FI{!SdBJmo%~3ge{x_S4FTA8G*j6(fVH73qO8ab>V)N`{wwm)|vZObbfP5?&j#C#}8`~SA86J+sWZ% zqypU+Nzdj^lr+?>Fv#%h@I7br&AabMb{cgmca}uhg0Ab&^U6+KoV6nKp!lOGDb0lv zt!rx{r40+79?v&+r~AIC{GBO%oOgD7eX3+$s%mB8(7gpys*06OoO~jb#*Xu-Zaw(> zBjaf)cWIv5pD6BhGG<1zRa@U&w_Db7QromTsc+&>+T01;sHv&gZ(s(0WYUdZH?jxo z8m*{Re<#*S^s&2hb#>^N-0J2VA5>|Nb}k(3R&()@&*-JqDr1J&#KV?>L@ znO^J6qED%4waA_@v254W`jDQ z_d6eYvhU8>wE?veeY?-itX0up_-U8&yRDl%-`=noQ#@&WT?S6Oe`<#`E zGF!&GJbY|+b;@KKhnlq+w^e4S*Ti@p9q80DZPt-)=8ke-`d<2O95%!uy`oLnoLBGP zZkp+}Uc2M1>`sSztj;p8Cjt%+Rs(PY;e1NCGDduzRd~UaV+AhqRNyJ75&PF=nl;KvapSG_0|KM z+B&ZswNu1lhe%GJsme#ou5CKUuS+*m?KAtscJB!@QwD{MFjCsnWt!oPh2hO7JukOP z{M6q0-mvnD)GjwWrQIFru>0Y2&0Y_-SE7c+G%M=47UI2x^^G1=1ji9QO8|o>wVm;3sw?O^ipECZ&qzHY5a`JTi;J;buNC>cE{U(JJ0TEyU)F4w+q|-U(9~UL+=O{<J6#Y)9Xj9yfsn6H+28YajQ>t-ymig+w$;!nSuqH}X3XKeUq7D^nR|7*o#mKCOH*am?El`fX2Ql{?@wHMwIOBL+|B$q!!=ba zBQK4;d@{+)rLKP8v!SlX+A9R!Gqv?xI@jdpH4pLd%MmY!XHlo=GX0xL zehYZDPn!0jxce^C*W~VZcJGtv77|Ym`Rb`axjYu}qZ|}Ij7k_uiMX@+|gROA%`kQE%UE;z{01j*Rjz*<4*79aXq=&>|Jhx+rB`)5kV77k6rf zA3PLj;C5Vf_{o0N^ETefRUA{hahrtM)-VxU*J?4|p7YhC_qDlIf930Rhb3+^(@e(9 zYOV3D*h$pZ8b zd7mn~QFMh*;^qa}19sI(r%svg^SsB<$tDt;9^ISY@=4F!?i=ozk8I}@_1Ni_!H}hH zGM#N*ELV3Qf3@}C!m)>6cU#!9+vdUBGIyGHa&Vuv!f9Z<_o@Rq_bxu9^FT!Y+54AQ zzxE^lZEey=b-3ThyxD#q`6*hfBwFPi-gkfe)5`%0vlGLQblf<1_Dn*C)=%={jWaMy{KAil`!$p zvm)_sx;>Ar>Q%I3%X-rf+l#bwb`H?FJyR-c>f4OAl8+PP@9htJR{hDQ>f4#wLmq`Z z%yu6Zc;7*0NB`w<;TyV^#yLvKJT5To{_XNN)oDXTp3NwVv25w=6CQWb+vP#-)`joq zsH~XNVa%+k58A!D?(J>#^@iQ^{#xQ62H50(a$i5oc;lt-@yqHq9@T7H(fiHxq0(&@ z797+Pb2BK9m1_}s(>w6mJ5lGP>T0Xxg8IYXU)4ycT++&xSCn;Iwtnuh#JNhb)o~B{ zxr^o$z7~I2;%+cJQ}_7NpyLA{l}FxQu_VHOl)PPXYkE#R^*G96Q=cJ)e>z{$xsT3; ze>z{$I`n!IYrnDj(zZ8G&%fLD(qKlP>Sk@75`Uq1Qcw^O}7U*E6me@&a1{yp9H+0Ordc=OkG3c40OEvahZ2k^3%6d4UWIjR}XfY`u6ah`<6MgKDXshsyDv6exjq7q{gC=_ckaT ztx$>0ne?ds>OO5_sdf4FA+Ii0xW|nz^$%Rov*+Q$WXp>FkG_qkb2_|U`=2^JB}PRBVH67_5{8yRJ3H- zrFMT(;)8(i4?Mni!!gAVmqh$N;0=JMaTDhPLmT2B0B;36jy;w^mQy0$2|%1b9D7W{ z#-GUW6u+?i72rpKf39&0qWsZ+4RG8Kc&_92D?br<+9mc^Wk-zD{n=(9C7VQ&%0C=1~%yW&M&3^>m26$n7v-vSF@i>3Nj6IuQ z2|VsUP37}|$MJ7!{7Uf41y|r{>}Xsu#>Nu03jrR-A9?Hpl{dy>9kt5@9@j6GQ5=|W zED`?(cuU~1|5ygUGvSno*N0!q;QGTnSKsJ>_)y?6e#&!l&fyNHIuJb_g>%uQ{t(ozIOR3=+M*DwD#0LS--v7Dw zgZSOR)BWpjKH2rezX#qJ;)iX#;rvSF?6$xAPVKbem*!T$3p00Uk9a@e?SY47C{!Z; z9PsAAX=mZ+T<@Ob^<{I`P-Dwjn3Y2dB@f$sqiEyp7*hN zKzu8>XssCkRK_I$YU>C*`loeE<$tq5?bZR0`v)8Y8m=8G=gQv#-j?YT174>;#UahfUq+xEm5?+*UCt{bvV z{Bz*3|CkqM42bUte>BLx|5G{n`IAz+vB2Z{Mc;G}{}aP5Cw>*xkX1$Mi}r{&=)mI*V8##Ufa^J$_|3rM`(rr9{bv*H|1A+;0DOPo;WpU7(>a)wO$QT(0YkCm;#KhAqo#?J%z{=nn;4abjb z4CFrrcwB$vpUS~hqlwxTGCa;dSKnxu_!eqBo)!4Vxx+GH$KMWk-2X6s{2dS+$2cYO zKUYm~|3$wTzp&Teec?#fUx`Ldf*2#{xJr4oxmwk{IZ%q<{v>S|CNQmLbp8 z2JtU}r}4)!uJ;h)yK4R1e_{Crz~l8#>!0jme;P~wwd?Y4zdvyWeh_Q@H0G0CNB*}1 zKaAnA4`S>_jeIBmJ@76p|H6)+XICC?Jd1C{}z|+p@+F-+^#R#EW z{6W01>+ixZys+y}RFB7VVEGr8cLLst;js@iFI?wu74ZMk|7*b0{)aw=_20V3&+D(n z-vVdq$&V}Wqgdld^Y}M2#P0*1ef{I_0k|aMCG>w@zg$>sL;Nt{Y5uTG*!Yuxr}0Nt zSpE+1uB`DBw*Lljc%bVK;}`b)G#7X~mVaUQ&kMlg{hRtO?Dfkt{4xG$gXRUkHkzoN z6Y%)_MtNb+&oRK${R>&L)2MS+yJt-N#F2942f)J@-G7l4cKlZYZw>v&amVo!w*N)I z)BQ_|u>tbeMEz8OmnZoAMeCl*|7L*NjRKy=9~oi$p9Vbc-(*i%z6N+JR{YctuJIee z%M-kQ$gi;LKN@&zmVaUIKj(mF$1m*u-2z@7v*%CP_^p9w&mYYL*YR5eJl#KV?1hd0 z74Wowp>JX5&k8mV8`k&>d;QD@9?##@Z(0Xj`+oy?+Q0wCuvv`3FCTdJ{L?)Cj$xM({|$I6mVaT_Z(s8t*H2T%e<|<~LJY=+ys+_~2Oe$# zO&-6m!2iqsGZ=0jHviy%Iq-P@p?zQ2^>Y@ z57qxoi9ZWGjvv>3i|i8L3NBvu{!Qi7?oUeXoPoCj|FrJ7#zFjY;PLv!`Qtiw7!&dL zfoJa@SjQz1uM00faR0{V9x4-te*!#&5JN2K-)vAjb*mrm|Nq9Y>xmBm9{uC^H)Z_x z0*~vD>vJE)K>l9JA;g#O9y-(sP5e!%1YOXpr;`=1It{{Ez?~AV4ehvG7+keLbkNXGh zU%zqn+amJ67kC^$oIfi2E$~YrwN-+{5BvQs*XxLQ58yq)Kj!Hg{>9aAv&0twkK>Pi zxvqUQNPJ62!T3|2s|{*z3A`2f$Jm8gL&Ps(c&_7yv120s3h>t8pW-GJwEFW;;+0_X zX#I0#+5Vjw9_`_Ws}1rW4?J8#P2N9V0&o2fye|Cl0$x8&UH|^TZk{ehSM z^Hlw|hQ@CynkW7Dc8?- z;Bo(=^OLaa?=A4|z_(~T-2bDN;vWh(Pxuu?j1;t>vj3y;Zwjbw4Djf`sq5zf@Seca z{qHxfep^KT`EYo}@x%QG$B?TX;x7UZuP_?+zqY`0-N%R*gNp~xZ(Qe%IzxP4;4ywI z$2sI`hxlmVasR_S@>C9A8%@+MlNCRf2|Iojz~lJg*y9=yme+FT@w|cO%2B5%{?))+ zG5x1HE=v3Z;Bo#i_NH9FitzFa-=C2^F21o1wRZv@=a1{WlYQdn1K%I`)=ULgJH%H5 zZv;H{ohys3h}RqOZ_hvTfQKa%!}Ypjy7`~~Tfo!!31fremx5nD;rhkA92w`J#5({_ z&u<*C|C$gV13YYj|Lvbt{$ImCe4%zFz~lW7^Yr-x@>4PKBSs3=56(S)@IJsL5x*Ju z9^fBYTti&1W8$v>kNY3;SjKe@i5DOB^WUFPIhblRQ9Bpl@%({)u}q>->PIE3JeCPNe)^*Y>xb-dje*+F1RmE9#x2Ylg7{+K^?}E6!`OxOA30WV z|EIl|>|#8PC2DsYc=)f$<8Lrdz|;9f*!UL%4@c03{S)KmI))U#*!X{Yf9(Q1YytoI zC%cV}9oA91)4=2S(fAA7|MqTz{kIk4P}u9&8+beLPw@!r{}}K#z!OJyxc0v_99|89 z$MuV`lO3+S2k^LmAcxN|!tzj06gA5X#B8_OCmmXqJXFCmTL^eKL*|k{Nw)7 zl=(C85bVEr{h&><3tt;e)Gio!T>sdZ${GckN@|<^JCgsZ8^9B1S*%Ovu1bk2MkN)Tx?r*-ap8VwikMmD?u472; z-vf`&AH)e8|5%?N??0P!37ly|{*!^n`v=ZHCMp-?{``~L-2)zfe@St-`7`oU4e{N4 ze>^|X82r@m4;kX|f4mZ2!8QEL2`5T6b_&L75&{)LVI8Sn#vr{_*#dHi1oqxT2M3d^4X9`7H> zVV}5;1NFakz|ZGTuGawZ!x)~{p0Ioh@bD*mF%Wt~-)S5%-&i95&w$7C6Sl>*Lvdif zu|&MZBp%NSc+ArnHpWpM@iT$90iNbfSpTPir~R)f{yzhc>lfpp55?G+Qv4x-g7qWL zG!{1gKpq+@vjBm8sg_VZZsam z-vz!O@R+AEYSWlfTcz+H?{BcZu;(8K;Cq07fyebjzPZi=@j4Oz_WLV$;Bo&VdlZKt%0K_4_DR6o0FU=Rt}I$5z8ZKd;JIFR z$YLViB2sXEl7O_7SCYJ+&C zse*t1*VOZ05b!48UzX`Gts}1e-v_)E@VI|rqPPUPKmVk5pMl5zlV7@y{!9vLi0?J+ z$MX-4p|InZ2RyAmv?(kvGX2N-lPilop!m&!NB@||_^BM*G?u8{RN#jIk8PX6p9dbV zfATNv{qsBUwEvSbykWm_+`~fVX1eN1kg8#7_gB`rj1)hZvsbjr4D}$p2U1ts#D{9N8t_B1Uli z)BekK9EqO{JYD}}n~M^E8hCq%ADO1S{}-PlI6ve28)3$P{0{;i&+mBu!oCaJ|2W`H zffvR%xcP7Wp9CJCzmOMcg8x^*TLa(J`Rg%Pz|;FLQjD>&MB^6tnn8%{uK;QoTQNcuQTz}^D~X>-^@@u$yh=Ear~Qd{kZ~< z&wpr->)2szz~lXg)(i)S8csxHKk86)>4CMbY;~!c4P&s^UG*LVIr2_xx zm*%ihkf|hoI`H`ZgvO7`|7L^Q9S5F$ei!!o`wTqZ|Ii-xLD=y#N%-;ivuK0vLtNuu z2z*cQkAB-W!G9L;*nf(f_93qR-vDpR>Oa?YNaNps8INbh_@}bJ`Nn!`n+QDaKNvrj z2|Itcfyd_uvL|f(3WjVdB8S}C5m?w@Ob@moj0;cd@=ANfNupA z6a%$sOo_K%E;zs7zD;w`7)N!)zXX05_@{dhl~cPvDYfgfLa={R>{R|Ij>@UsJmB&A zm4XVcbB{KNF9LoL@QSQ5tp8ggUVWwD{Uwc`u=5`XJg#4yKV*gFbAY!79?xG*;p-Xy zxNkLuw^$|EKXB}u!p{XBpFeRt<>1404r%;zfp-KR*I!foi?0^^`w>atX$`{Hzb5kU z3H%uFkLypEM0_^zc>M_D8~qTkx<;^mF^_Xd<=CdNMD3!0w+8>%mez1%9Mutj9(X)I z;r=B{l^m4#*XsoT`(sVzoz@G+zp4B-;K%(#{Pn<(_y^uOiN|yM2Yw&${{O%$ZQ$`d z{(+AM{$JuR0^au@{P*3+OEJf5F$KfpbZ>pn>PN3$)0-#_Bsk4f13#}MG@{DwA#z2;JXsfYA{9EAh{7-pd{p+Uu z{Qj5hH0B%YsGSG!bp3Jl&F0qwZ^P>U-~3WN`7Z_D8hAW=V4`spUd9g8%(GVa6UKApSV;`20h>u1n@!G~)fh>rn& zFykL>2)qB?0Uob^^ot*|3tt;e)J}2dkH4Rh2SV8OKNk3&;2+nWFwZdLe<|<|O#jh$ zi$t*zY9FBAN1Xn@z>n_WBs8G zTmxJZ`JW5C9TPv6VGLXn@fUzMV0f;+(LV8?fXC-Ynl~!{TMX20M4DjzVxDX4Z2!v{ z9%C2Q20k;>b2D32pC$jyTfCRFUtLik{L9DFb}rf6qzLo0#eHJJz>^tYBNN5%1l$My!h z@c7%1*oyU=;e&qR@uESKK`|va{QOzbZwgcXJH`6#Og$>v-NEG9it(ny2mSAZ53Y-Y z@WJ+n;DZSj^M~Ps^;z)2#8#}&ZYUKfwmStMw0{mhSbv_;3!v!lGLydoiU}3l-(>U- zC~|kAy+4T^r`K(U=XqY8{Fg5o%|WAe(Nm{8GACs5=&gQESepzt40 zo5|~91`;aP_W(t|MoiuW6z%t7%FRJBp<+8rMh7tEs90{#iIVyg2f*)vq z5GeW|3X1#)rhFt*?#h&pXYvz3;XmF)Chx_l52OB!27-!1yGT&<6UAsWC?-@~$FrF{ zDt?V&@>~?{&SkiHjLv8H-zm0V0QIPGO#4Ml``;;6EoSObTSI;glmDG!)mo+=6~C@y z@{K9Rlf<+`#rfC-ibb25a#Z}fg~|U;(au(;9<>$Z)0jLL#ir>@dsJM%8KAf>4l(6y z#iGMZIV$=;%H&b;>oF$JR&1BWl%wL;Y$lJ2=gxDWI1U#rrm2s-!Scer&#qCeqcY}gQA~W zrafD+=p$3kR`mCkDQBw)KPYkon0i$FYRlwN(WD)t_Dne{j^iLuEE>X;qvF?L zOdb{YzfqvruQ8x#Z!A-fisf!h{&$L16W|A`2mC?{UFKk*F5R@^VvGUce)ZXKiRK{3uvOnx(? zTR<_PV*6xJEJ}eNIQ~09G0rrmd=FMaLdCE9m^@q2&VHu;0HX()c5KCXbD8{XR9XEOT*w|RU1%j-xd_dT@e)DXLe=EwHfUOigD_L!hbv? z{J|*lJ()Zz+BE^iqTWp23^R~W(T)|9Z%ol{e}*5xwEvyr`m$&0xhSsdp-g+WV*g#B z9P7t}qJK9~?9T*7Js9-@#e|CWd?t^I@%S_4lR(iRP>w1XgX7l zit+Ac%J(tls92uClpkQqf2U|Slc`6=uZQ6WUJp4;Ia{&)38ows^Cv-Z-p+uc-+WN` zk9Qt_{G!Gx*i;S&^%un=C8it|zqV(z6DZD;Iu`yxv0jsDr^U2m zEBe)D%GrwXcY|_N9i|?>HoS9Tzi*+k|Gjr<_+3=Pc>V9a3$BXB-@o9x{NH<*hGF{c z`xhLa?SkqC#p`eflV>a5fByI0MX-PV@4buQeh`g|1rjRWZ~ph*MKCV^d+*Y)EE?Xw z{O`TX&+lLGl3~AZ!I8o|9(~yFTd*ASHlUWcN&i8S!}l$iQ1R>k-n$6)=@@2Q8q@#1 zclqCYm;b$Y5$qfPd+*Y)jsEmr=6~;9;P)c%@czH|ETdr-lblq z9y|8`X99a7Jb_)dX|WS$dJkVUao$xwb%o}qRTUlPTsrFJ+x1Y)s8LcHuM<;uW{&@! zyhAj+ZgW2+y9H;}dmHW1yg2=KUEi|A48uDj3s1A`GPn7LsKxT{zwJC=)grHRu>)64 z7Uy-YN$9=y+{Pt~FKTYQRrFNnf~xa`k8**pPp?s(*890#vomw9q~11a|f+-9Ip9^7Ls3)Fs8r@5w}Lc`*3i{lJ)G3$2R|`1x+#TlOVr zVfZkIEW>vP6{B5NTYcj9hzPmdl4Te7KuTga*Pgvv-cicazH>~ML5GIk>DzK?!PJAt z%4_c*JX~lfDl66M{>`sb&#B#8Hf1m`Rb`QVo#gyxE(7=1SE+6Icusl-%Pw9kl*CT$ zNwk&l>N-$-=id2yySS-bxS};lWoJ@bQ*HYN)eBy&9`gOpg_b*vrq$FRo}Q3)ysXBu zB!Bzui+qCO^KZ!Q*pm3IBO&B4R=E>Br@aSx;{^IDEr3vBHe;a+iCV59$`xIpSich`Ke7Mk5q+XI1om zUbo{W->|=g0?RJ`u7Z--^=_#zDoMQnx(CPuAToIz@HAjGt@~PQmR)@Iq9kUhedp@grM;y(o)UR^ zeSqPPIU#4HGneTNZa*Vv+Tw$i2?`R+3i`FGEPlJ-X!*tm8Y8aCh-F8X_H>t>)T(H> z$NX}ZUHqLMC9xTSHk!E^86jc&K6+&y_t6hZFf@Lw+JR@3rP20@TKa{a$r-s85?}4| z2RIrP&a1sEvcdMVi9_NPv+wgAbh1-Ev+UCQUbHPHqdv4m&a!mQEvvFs`rnNEEHAh@ z)+svRZAx*=F_)IMz5DFQ)=;<6kJ`vvJbJACFs74rlJOpYjYn7F7JYTSwJS=IWfy;! zPDxBQW1&Hp&BgZ%YTmb)zR6JItltK`nfs)Z6|3s^M?Y;-ZI=1?imP(>6XSQOh$(#O zxn=D&WrwFblIjXm;HO}tA~!1Q~hN2)q`cX4co4VbKEk2 zm341+kJ{I6~uKQDE8YUb9Z zMOkybOApV+XMA|cvWvf)q9k@TW9A+WtD$4>Cuj`_@e9y)zB(~y;*p_J&K<^-EC`he zn`T-v&TWEPH1lX(8OoGnN;YDbCPAtrPp4n8#3e3JGF=B z>|1TsV(-6lWE4~^^3}-_$u*^WGfa-yYdlfj(MxT~r1%kE=jeak5Vm|~pQyZT=O326 zHX60L|7`m$1250ty0}mJoUp3f7G`2C+EiHJ+U?^nSVpw+eYEmok?+=`Q`H;w2c_1AN_BbvXw0~es^j!8d}rBJ zVB3{^=3@C;bMdwO11%ipn|2wPw8Qky>-(CY9PWFK-*06(YW!ejU&pWe)^C4dTdTY& zPTo1zK_}(?%=XWRO)R?mc{l#;2kmEyY`eOh)D3rQP8eJj=Y5&C(DBRg_X}?BbT?G) zwadHyUeDPd5{}Q=+;smDqMqq@OZW zxE7-@SZQ}n8^`z(1E=CU%eVK^)@*+Apjf|F2U4<+du6B3>f&fsc>2w>*uDOD^vdS1 z9)9-n1+$}%ipQT~*=@(RD;XB(Qt;t;;rt29)Rbq2&H5%c=4-pL{an6Yuhw<%-`=Rj zo;3MX@zFI3>rd~$rWm!oh4yOGi6PUxFWRr)c=_0}aF$(Vwq3u++6T{7FY%iB@VoT+ zPuF{NEI2*px}2`jCTE+^@0Bk}Z_~EU@6@`i+sy*`$!gh=|3?rYkQUzZU5;CReC>2~9J&2`iB%HOT*ZzVhQ zb7JAplL36085*|QEp~d#DfdWxW-AxRXIJRrg?iZz- zb}xHTTI(41QbI9r+4t7DDWO&qB5cifC8lJi-OAYhByZtWCB5C_ZlyWh`1Y7(w*%Ym zbqlkBmm{~CH5-5W(@NCe(lKgwNuR=bh@->_Rjg6luoehb|j;qVkUu4 zS32D6Qdo8CdaT$8efNdyH$EQl#klH#@*Iy0@hfXbSGbq<5S!l0e1rJ4+0ng*4r_n8 z)w8h~b>sGaaQ^-}f zWgb51DvRTX_g6V3<@93uEVCWY&6iu;a^PjIVA<7R+dcVFDI=rRd$Z4bn=Mv#-_58&XcEdo0{8{$#jocz$l0{JBX5y6U!Sx|5e$&vni17IxAsb@5i-2bNt; zw%rk<_nO2n=`y!x>FS&Na#kpsEA=-?I3MyPO5aSsYPa1NkNRyA`swXMyQp>wdYE?Z z`CG$Um6_5Pvqn668oNr%K96O$Guv*Ty-Pl%d(PWGX8AV_(KUUQe2l)7TnXA!b27cQ zNLA{9TF$Jovc2^znjiY0ZW0qUuZ4BZZ2$EIh1(3KbPCFuddrw)SBq`;#38>u9c`VP z?_a%l+KBwwX)R(kyBygS_(uAmPpb{eB;e z4RHKol2#MIK0kC}+cmm#a`s*S&cy~w@uNmxzqZ%7O376@{>3-N-QgnldhFdjUcqCS ztJ_Ia-a@B#Pg~{2$;(!J$X85;&r;{!qKID4dP;UX?@yi zy=Ttg`MX389`t&-B3PyWf+riE+ql{*KI(eb#O%|tyfN$dcD%gs^BU30L{*guHm$d@ z>}s>^W~59ge$mlN<&(ojee2Kx#^w_qnY8ul@8-kFZ;wy8G3LIU|FN8##h*uR zuDj>8I(K1~$hxb+)&0Zj-bCc%-{R8#(2Z?(q=mBPZe`oTM{k!b%8OhQv?w?GNx-aA zhO(;@ZhWlE+;2Jkf#P|&-F^4WR8X~DtG2h#u&aG!ayH)$P-?e4qT=%|mR%jT-TrUn z4?oAOwZB@9)ZJmi7&pJKd;i@^qbO>)YBrO@56SMTvL%=P^W%gD@N|! z^?TQAdbFM>nNc)Og=JTlZTGeB&1unt@;vSun%GCHS!j3L-DbhNv7Ngwc{oS))VP87 zD{oH<`cmU%aksmgoXfgo#kYnot!tc~X~~z0+q^GOl405H&bE7F!?CZ)edAuf)PMhI z@4ix>{2jfccpVllIs0i;e_4~d;Uz;S&uhM7@nOl?)>cC@HKaZy1?PmBh5AIEkJA#r zxJB?c!-D&e9^0<`4e{m^57h2#yI@YqoHLPgKbSj|@;wHxcYnHN?U42kJ%*&dIj^K) zz4VrSidXSDV=Zw7v#QvvvTt`@e{g6s>+F42y!bchl*D$&S!mZCi|iM$e81z9qmlk& zjBY&K@aTQvyPH>Q)m|*=7#c88Lvq=qoKung4!5!jkXtt^a{Q+>vp1SjwJA#iU42=0 z^~orxnBlQb$+u1!pC9*let%PG_gzO$-z!c3lHf9F?BJuP0#r7O-xxk-!L#9J7T5jc zUc@grIQHqmwzqumRzAoz@sS|JLZgBxcZVR^Q1jJL+n>)$6TQ6;%jm9o2Wn z6-~*`5!*Jo`S0PmdOKGf8j%@L7N;BXHE*%_>{iWdG(CFUz1a4H>%Gq(<5_kM|Fa75 z)_#iDU-qI<)}@ujU^kc2fv!~>t{Sy=X#1?6QLC2@n#%?STj<|D;-<6f>lN*FlQSYE z%95k9&xhVq<843NSEYkI%dQdI?rNo?^z->Ot-n~E3T}NZ>C?fZYOQa@53#S{t@d~F z-0xvxFX8uSwf4LOr|m_TB;R!{T~Ig7d|{Sv?2u)iqu zcKw}U1v$NjsJj?tIS%foYxudszqM6By|j6=?)~hx*~&d@oAY$*`tMun_wZJ+?3%Le zw$*h^-6wr~nW4ugyOC4N7V{i!11B8XQMcm40_mwcyr!AA{4iZRro)F>+Hw2MkJSv1 z`1srH z17}&qygNSqLbI>aRB{~d)RjM3FzB^(bNBZ;?PER8bXJW>#=i-teG>oO7A3KOh!4J( zG8Wi9&pz1Pa^1?KuiA8rHn>{u#$Tros()mM!J{Q-5snuDzGkfUQ!E-9vxO> zURpLgYrS#D18t_W?Dip}pklT+U1p6ID?Qs&M^ndIQZ#!=Y`@nok#mO>99!kt2zLUA8%E z+4-wwx<|jQ->~3RdDTZ=mwnx9RkP(-QnS0^qtBgc z>SQ;jhM27!Fg^a%jd)*|-tWdL1}v=Tbo|;&H}i8N-*|0%)W6VF{l%#BwN8&%cJbfS zQ4%{ZANX)sfbE<&aaXD%D_%OOSNUGZRW9GPQEh-zHxG|?^|hiMG_Q#A`;7KZKXGD( z!fw}PrN!N9xAtkDtfih@KbU2=9~lJ|>!;QGUhV<&*!IqP)dRrRNbwlAJ( zS;gLqxR!T5)AY{B&%PT+TMcQkVPp2D>1Aoz!T#kBlkbhU>^n~73Cpe}+iv1RH?8a! z7u5^9FA8ow4D*eoi%n}Sr~1zH%`^a_)gaDjc&7d=&t&( zX0l#M@H^8hUA$R#t=M)`pDBx2uY4Y&eC@0G-N~6H(?docy1mBOrgEoDrQO@E20F7R zmZ|BOubzMB&YZ$^$FHWE>gTO_oiyjQ#nm?EK3VMhP=B`FT9qlIHs#KDk&jyw(q=a?7DXEyWI9_a@Hqr`m|1-l`ONki~S14mY$iLbx-%d zQvUE&^P*GRmzlpj5^gbIpv;JUDQj7F2e9qlly&)_w(O)=Pc@}6ira>|`D8`ynm(rB zedt_^%Vn7ZFP(c3{^^a9*53X0>voMC*}ulZ%l=uyTN$26)zLDiKJtbvyZAeON@7)E zSMz68OenE??rCt&A$Yx+;kPqwP4beZb4F!cj@m9!Tk7$${8GWH2WOkB`%5I%Zyj++ z>16Lk4sGB0Uo?Ds#DQhkmW+amJv?@@&yJM~?`_tdd$Yq=Bby#q1C-CD+zR&jblIt; z%+=%m9aJm#XoT^~#h>#w=>(UgEPCZXbI5FK$wj@o`j|eVVLHO8KG)^7V4JAQ_d{N zEH~KAem=Hm+dX&b!f;VzomnXtOuKy09kTt!;`>Ecic-r}dLOBLDwdM%snGeue4nw- z&EgjnwBHq`+i&@ry6p#F?&hD`n-;#ozkn651KaNEbiKJZRFh}B`sK`9JgDphIncZL@?rmR%>dU9G`Y(UDgZJWO5GW3N5$+jhnosqtdFRFmF*70+$4 z;!w1e>++5xOZ|#?-)?A`eO_zh78xI;+e2T}D^YFKZh3hZmfeADyBAv&$GpDgxO%{y zc#R%o%fB9Y_)=;{o1{w)167l+T#Bw)8(vzLW2vo|ml(O^TvpO&Ma%h8uhq;yheZsF zm~kmAhh=vV+pg#7-P0#8?7m;wvxlB*Z_}=N4}v<>?ww{Jqx@{jf~Quh#$ne#_{vy~ z4LuZJQq_9h-o;;sok+3hmQtg0wBLM%Lzh@~2ea)~uKXI`Z|w_p-J&ZU=Ds%!$~35Q z>=pCsc;vz$-^F<+9#S+1+@wXY%j%pk-wyVE0F>_Pdk}m3Ldw6FY?)E*vvO9!r z_sOu7Sq^5?^J`Y?xE(fg*Bha8Ld|Bhan+&e`8NGN6<7HBo|4KPY+RN<@JXaZ-#$(s zPVYC}yp-?b(qhy(whfAbvngeDptH75*bl*w>bUtih(vJ z3i279%XIP}z%##?;xo^?9D2F8=9Fyvtq@vD)#2JlN+F__y-^C9&O; zyHvftqditfzE)<^?cSobvikXs+TeF87@&Q}Ymg`XG4ZIj>LH(hs6 z_ct<})?COXEq!|(aW#Hh{GJalMBFyG-(%%u<2PAjv#w8~q)F0qzf z{mgv#>YiIe&kho~)w+k2*_>QogRKR>U z(~ly%+q`^T<@6?MX~y~%JA3mow8SGPSDt-u>AYaUr0k(5w8CWzZj4rIUC%zJ;=gC7 zB({7&`=Mh+I_pf_7rx8sgmz`KsuKz;KZLmGyp2Cy<7&>|7oh0ep~B|EiSa8nTg05* zT43+LKCSt#lzI1yR;GB_v}46Pij0DaT{yJx@y6_A=a0!Db-K@P@3-)G(kq?mbXzvO zYlvJY(SuteCLAqMwzH5NnDN};#I662s=Ex!s)-f`OqU2qBVE$nAs{IsCEeW(QqtYs zA=2I5NJtCP-O}C7_xhd7bIn42V7Oq z{qu=VsgK9d{Q80BH3L#aPBA6d1Qe{`P|ayQzCdM>^TizO?As3=0ydT`=7MKx2bFgz zH>*5EOwfirS2%K?{yszbZ~dzVy0ON|zZU2JkjSg<#QJ3G1P?V=wWb={wy6uVC1WMZ zq88!9acq?3_baB;u`HiHry2;6OcI2=mXl<5b)fk9B^q$mLAO!P_T%!%mj_k^cUXIN zVdC3_I-6fqLS@m+9uCa!XvR(~?+|>~2A=q>!r)Eq@3amlUzaK={XVL6su98S?L4FpQv9Jxkb*rgBSlQp%}cG{K8? zA8uZnY6H9AXYaEFkqL_h zcp(^Fl1c;&<&V9C2gc#=`x^cQ%&@j8uHmK)y1{&V2FL6)&vcp@D{pz5ra^f9QWdVR(8)5BgE6KjRdt zFy**;d-I_2Q4dR8%s_?ArD+ghIsfNIe!Lyfl!YOU+%6$+K~8LvkJkqnzGYEiQh~7 zLLn5Xslh3~GnXP|@zw)7Pr&PgKIrn?y&$qUiW*J!BYi?2lM`HC)25cN=WWStDAK> zZPXtebY{40kTt^G4|v4Jzhx84PlO65U-+e1kul$~)OCmB!ZrSewlXKDr3Gc`DWbLo za1B9sY*d{6cqh{zCa605vau%3m-{a4Qo~Ee&H<0PLdz2(|8{(Kau~`4mf+?&g2=U< z)H0CKT7Ai>Tj!|u>cai+Gtd974@RIXx+G70A9C^F(ttIH3Qu1z(lokXbP$~|{8GlD z^r1g+XYS84PnMbBZfM4Divk5pOA{sK4L`~9B4x8ix$s1=Zf^{_Vk(NHIv?@1;p1Hj za~9S+-(y_uwK$UgVEpi+2WLZD=n;sA6iC&L8;m-ak2@}Mmbui$ju)AvH6sX@ch{sN z4diPAx<}I6G{q|7%XWUOSK`&x*<>m&qIj6W-}BcijckpgvY5*8oBufV+am6l>KE(S z+AuUS2IDo7{cYC4K{}6D^ zLHB#{Vt`gGsk>71;bREKy8-d0%%)qCE!Gws!5$_f!_FC{8)lLdW$21Ffs;2e$Is(7 z_aEzIztX=)N?<=wYXUzv3($3HD#!2CzBT^B(K)T(<&RA_=vuBHw#RUV@SEdvnc~#> zOx+i~^mlps@uu#fIb#()^#y7=%%35&ulWtLe+Yy7O-s<-aBvx-H%6ZmrJ6F@xL9(+ zG%~rwON^*E7Z7AEyQ_hs_%a`m;h7R`Vwv%xxH5&(POcyyg2k?THE^#0VWE zrWAMIL3!pIJ9e%$J$*V5|FMv#+TRXohDq%FHM9CFX@tF3C$A#q$-v6Y36tyNoG}eW z4m2v2Rc=2%{>3Z{c;B=J-9wp#gfroB$9x!GB}~(~nnlbb`!l0BABd4|xe>ex4D6%M zZ2U}VlDr;=0fw=j<2|1`qC915?^0!HE8IS$4j^9}&?PV2F!y|~Lw6Lv)o<3{)osVc zBF_Uy&7o%h<@KXyfMz9oV6l!nU6RbYJ^a1<+kIi`R5S|fnKTqAk!|<(|Fa*m1>J&W ze0Iq~S5_vccGc=tv)8Dn4>#r;QF{4Q(|{(O!XO^I%7fw0_nbE! za=!<2k_1I2=_t!n>LPx3rhO6ZZExk^7Y_#KgFWa*ILZHF_a7M+8}@>D`(A|WV1Fkt zu?4e|7OCG`BqBr!ql@{L>AocUN>qdO7fkQY&Ym!;%b34s(hYf5hVW-OAYTX2t*}ev zxWix?*5+&rY%XB;4DnSH((=){Jv~k(FSo3bx2?3``&^;5_*|GrVL=?p)P!K|H@;C+ zHyDAXUB25r0=SN#dsJm;q<7d9WIB!%OrRO5m$lDA@qyRas4W_f?dWWKSdhMi~64h^wQCp^_ z%H{E;9901xO^Y}Z?JPwR)1_Ft(_8x}d#7@S6pk!J=7-%hocdjW>k7Kt+oV;ok4iD0 zJ6q_JP#}Lfs0Hx8AuoHYM=z5?Wh&bURc0sXK`NEa;y4! zz6-Xl%?LJn-h@ziUz}`jKdKB5_FcJyuJNx=f8^qO`*OyTmm&Ba&ahAT<;sF>H^&9Qe zcGa;ylVnP)7iNkW5dM)=h%I;eeMd&iF@O)R| zx?;)Rf7SKc6;EI3ol;vygS=$D3c&RN-DvC@`n>EisgjXQq81~370fxI(N@IED>*MO zA8Kf9zsI?_L!G?Ald_ZD*SPe%Fd6m8ZEc4rKSC;WUt=d+`vR^n=$2tHk@J{C58q>K zg_?v3+}h!mbRWc_j7Xl7W%Tp~nNwGy%-&5VD<#n1!Q9`lTH!_GcSFSndj!(5yL}uZ<+H|XQ&gBP?=zrFPcvJrnx40`~Lm! zV^WeJY1rN#^<(7VkP~^%-2Zv^&;QhS{6W{Hp80OuEGf7S&2GRTxWgJnMaMpZH#Nsd z>6k^Ys(}9@roB*>3n>JVJBSz~%D(2S(W%-Vm%`T!)1w0$J&iM990EZ1%w!r80y9Cx zY^Ho^l~b*i(dbI+hw!}zq_+z1%*mj+G@IrfWnOF=UpgayKaQVRL%JdHv(__{+h8_j z`#|?N;0A&&?*V&9u!QBez>aq|_6z|B9$S8MbP&`JIcBFWFO)JOF#HM*k+;<>nd;hm zdp@Ke;8tK>DLU(nBubl6;7i0~0&WoKHfJ_J)k7qT^LP@W&Ss?&PJOvw-yGk|q;D1|?=HW&4CM^M2sVCA<}C1Z_r7k0IS@K{vrX>f>rEd`skyHz?had*XqYhu^WU zSpYW#bib~zvU}CTy;r@t9d=B=%7qIzSYLLvpAc3jMZk@x8~V{!kie+B=xWH)U5gWy zMAsSSxTt4r`0cV?H5%H?Yz=TjLH9sIE`j7JI8!;Nv4%=W!JSHn%@LZqyD+#i@v@zN z)zhG$BIa~m)i|U?u=u6E`fTIZ$LlS9N2(UMhOKZONU)zE40LVbv^#~67}JcgBEyWa z?qwejM1;JhKbvd>Hk`(f1`q7~=`dY;>Ya zqg6fQ6Af?Jb2t2Ef?OSKuI@?gd)3-PQdusdGPW@r05=kJmqZH?q1naQtbB>1)!*H@7%NUPL=p zR8#MTXkMU9-EhlcEc5_38gxg9g)27SrtueCs5MY#PO*Bq@@JQc@FBWKRktnoQ+$Jh z>U|Ag6x*`kEF1N}a~jzaVO7vgI-0x7r*)3-QH&RGV?ei_uA6q;@>(5sT*k>9A^LJP zDqn{!+~S2}r&&c*#JZ$%H9=Z$Wbp@lpJbB6gs7L;O5>z4qN#IJFT0^^$qx@U+nwAm0J8{M)9F{cR4VyY>vr7IdHyrG=mi8(J?>CFU}#x2Uh zLj^Ad~->&=yZ}j zW-C|u_a=Dmp8~pZ3daab1}s^~Nj*6MDUxXDLLq$?pPijE#AZCr2TItSd_E{%W_gjL z7mXU13z?E+m#Zjb3`gWnRt8D1EA{mQ`KE#{;y8N5gj_pzYlVM{Smhdn&*#yI3qg8c zU36q8<3SMzvu^pGvYeaW@OSj2j#)|WlRJZ!5)dxIW!l1KJ@ zea=3q0=Vg*+aVBJMTZSTb#={3L?E#xI>L{9>ub9?3ksU?zeU3Tu2VBWx7T$YrG1GVy$+sBiIRNQLlP}ta^Tj@O3fciwv!NK}ZFUc(S~w8Kf$^zn^v@|K+1>suPu#|)24y@tnZ*}OW*OiiRM<> zE}eepeTd2ek@51-UWz44a{YDfNYhdXOPdYJGMqc&O;1CQ{IGIaYMQ*&NsUb;M54{4 z;rAt2Am41z-7X=Y#v-HMq(>l^R+aY+xrUjQW((z`N?9_3h(0jE^*^u+JPgak)a%sc zo{38M&hYfA@5_x9%g5jk9B0o7fqy2Zm*O${cbuLasEBD?}^2UHtwjRHE{Qs)mR zTQU{l`rGf9pb&S{lCxpL9ku4NwY)1!#`Hf3xX#wdu>|C$f#+nopqueJyEAbhpw6XP ztLOD;NSsS)FfopN@nOSefat|1GMZWZ)~D8eQZ1d8CB15$vc8-kM#yS_I%*t zlqAS6$+q{-Q)-<9w@(h!;|sVi%m-Zvx3}+FF_mqqN^=x17zQ)Oep2W@*}vp1%H^Q) zD4L~hWJvKmwEhm8jgp4YC&cv`guvjEe`gqCH-;`HU_$%=jBf$x{uo$ruRfimQvY#l zKvO@cRyB8ua;{pF9SL1*JNn;oN*3wr!iNeB-bdD7p3yBM*bC6zdcrRmLS&K}+Pv?L zbpf{!blJ@h=wL6TEIEtC$b!#bnZ|u6nwqfGO-*^uO3b0zEsjH1gSjp=bzyIGC^|q1 zKgF7_sE%~XIx(`#JzFTz#~eR5r-E$_zs?56oW2G+>q5Yw@)!;i(7XeH^REur|VUmd*0sOrRSbpdoKmFH0 zz9pdRFNzzlst*6ADt=SXanD-RGXyR(>x9_|rc2U@WTk>AAV^J7qOv`#7OGkB@`m}o zM7)$xv~b7PitaIdouM@ia7#hgG?nFKk@<7{^^yMDK}U`{+-BpGKUNVroUmM_lv6w} znhQ-hhNPaTOv3#eXr1RvXL>=qot$H^sQDD_0jAkxfLjK-Lge{LaFX@;)YoPyu-)B( z9B&Ec25}tcYLyL~1iHFO$!Ek_XtppMCc3^dqYx)aiF%UEg`j5TRKfdUoKu*CeHG=P zdlm3j8XqE*o}iuKh8d6R2XkVkK?l(r59I{sNCKUJQTGphvQF;NS%GpyMp7F}rK^Ka z@0u#gLWk{MoPPd%?Fr;t0lLeAjO<;FJTUz+%JkTkbdLlOTGc0?Uu3WK**$OsS)vR| zU*L~jnD|!s7)>DdRaJVXNzX!b7c-9cy>^&8P=rir5<#mtG6PzS9I2}P=+4FQt-FTS2|GJ!rn3!Ja zgnc`a%j+xWWhvMvRt>rnYSxDb-=Q;HLgId@sj1LC>jZ3S7c{ezQtm6Mzp!icwC*rE z4)`Ez{1g%Rkcr+(U(fZ<9ABGWqu9GM)tNya$hQV`twv<15!PnyAtf~l`Z!adLt`D& zzsSQbEQoF3vFV!1J`n^aqYD?rL0Sgi4Hg%tG0@KP#onqZ81ajTQ<@wu0d6hmTA(#+ zYU$;igne?Y-snO(EvlbK&GincyPwZn>FW3e&-!h^h?rncd*U7Ju7QGI7&SkWhgxW| zrg~#xRdsJYSpTg9-Div6*{duiF#;q`;RAXx3uEZ~u71c8F_curGrZyY>zGKN1M!XM zgO6c9XSXZw7n|58J<+u>KxVA_6^5tHNdo!SgD!mm<_rlArb-e-H2GBjvgiw^l z;pRs83X?OT3sb-+@J4+mq+;|tD-7Nz_yE~w(|7!+ipYj7gsrIxWy(MO+ofmYg3ge} z()ODEU>>PFB-JH0E_OdimZ%%dI7pyJ4I+h2iB+vWU_-GvYm8E;PFb0i-+EiA%P z1T_+h!`X-H@m|}k@`Zr~?E*K%ruxVc$hQe}vo5p0oo?(ZELy9cgtZ80glypOJ=gZ2 zGTUj5sz!TCiBPmrGrT8lRChIm5Vm2Wp?I}|CSe-m5k1KElFqK%4YhtK$}dJJYcJM^n_cYe#@Js7mB7$|I4@Yg--8|HQ#bfhyifhKsWy3r@5D)fl8w$ ze+1IyIFBe$2$hxQdMgUHg(zRT|w7p&sMnp zy|0QYC0iv6>H2Ir%j80;o7YQju5fU+SY|aFi0en4g;5Ev*E>PiqPtJ|Dyc5Z$&@^S zU?}h8vZ2R}QK(Fb+^js>#d8j#KNt($iX1Ln^~w$h@dSfIHo6$&JlhtDV`x*8S!Bo+ z$hQl0*~_Y0P#8B7Um#;XOvy)&e(O9uNe+H*!J={@`!Li&L(aEq#=puZC0uu|k9+yt z_>!s;QCa1)`5h{C(m(CK1aN`S3sDnygsVF)`g06M)+dx;VDMaklnYd3v|zZ#kV$ zwZ^Bnj*Bx$MbpZ%+fO@5e@WXwCq<;VFzrP?kA11cl3wNMJ>Pu!cJboc-a#}$<@{OO|fNvttLTrPMEFU6l6i z6YK$HR`XNSDiKy2!FLmEtxfF7)`r}{P4%MWMs0^ghW+pQZrgFMBJrXVTL{6v%%7lp zlD_NsBEC^I@J=-3oQ?YMY>R^|`$CA-X@VeNHvAv6bU&YN+J2Etb zP%VDU{Rwf5u`Y=DjlszN43$%K_jeVv4mA^HS->3t-5YNmV@47xS8EwNN@uH@Rvt^# zEYwMte*9?Lx#T|WdUsy@c^b6#Xe*!k-={CUdy5?XEl)fn-K?7*oP3WD^8j}cbc6Or zgsWMn1wtz=15|js$cj$Ydog_02uOZ8zv{uA5?4gE>*08CTK727%iWI$Mkg%r|{g1?`kjCX=4BsK=DCAlYt!OS&kJeHpt4*j~W0yy8?y1j3KEtx&#~huvth+T4;UOmP zoMIFu1k8c^!covgTKyWd&Qf!{q`pp`R{tmarV^*Ca`Yo5RY;J+fgAjY=}dS*9o&b! zFWmT?jNfIWRSNO8aY^gmP9zb)Hxo*N`@&zKYq2Lr*>$g1i8W=jAxdN&27$WH2M?QM z$)igXsoOC+&7AtjqxHW>^=a;dC+WLZ8qBB4Ibv-PB3uK%n~HBjTLSZ840LVzQ>Rb( zdnW5cCNyas^V=`j6gBuU&?2*Fe{m5e z8*Xrk`3nCQ2g=1TTL1QIp>a$0Ui>$}{SCUGTn>h47t_jbhkGHrk^GdJI?jE#vaOt5 z5ht?BL*z(F-vlLzE341(jj+w8S`7<|J<7FS3_X399x%HlYl$ER+zHSv$iQeIQ!f?i zv|wO6p*nURH&+W|VH*k9x8Iod;&WAUeq$J0e0{u2GnTMO?z+;pymeV*6a4s?b#RB&2SAeETK0u5t1|N+-x5|2~~kGrU2{Kc5=hL zPVa+r2h8NTR0L)TN0Zyrj?^u6pyF52`ZEVf*5_wch7BC*uO*B0|+Siy8h@u zG;Hw+dR~QD$VJt#pu^RZ8!wQA&%*f_)4NXYx?N6tV<1mI0{KpZ?(Hqv{RN}^JmP?i z0W-cEnSvP`Sz%?yI#nQjp@4_l?gyL%YSL8Lf!_mGXbi;2(@=;!;`7T&i_oGg_0Q)1 z;C*ujbWcKA8!-~3E;OU+o@nVHtn=odHtbN(53I(vf>SQZFpjDC8KHHxHSRVrtsqBM zEmgP$_-;CIJe>sC(qB7uTmkvcf^MvQHNW?Rg#C}4pFgIqNV$;+>QYzHJ%g;4zZn7U9OzznTI9%CF04W%qi^L# zvd(X>edN~3-0QZOn0B*0hMVsfq?X`4t9w;<;su|fdI&!f+Ds!1<sJf?K>bjGRTsAo-Os9_Z=d_d#>}@wiv0%?`@jZxe_I4yQ4bl$EaQyqpM-d8SCIOm zLAOC8 z<>qa3i_B+*7?bb&Ug_mpr7+Ojv4eFgT&&(u&_h-S)TH#@{XKoC&PCkZ&4HQ@u!(&; zLi0sWa~{MTbEJT~2D%n0dA$P^VW}jt7ldMhGMxFUj9#?-0y!Mj1zw?Nf z#u%>c_0*v)0NhQ`by`O+;iS7MN;0Go=V@DB{msb1*{qb|&@K;?9%mX{6O`MbylKBR z6D%hzA~NV`qCk{)4@ve~c_)Gxp2~#|tb1;OE~E>dZRy3YY9V>qdLl>Hw77kj8+3AL z`KU=135f~;o0LnWz9_NtPi`uAgCc*G+b5*<#+=Zz&n6Sn=V7>Ye=$f~_6zwp+ zzvEKCSViSY{j4~YcE}$qSeM~@LR<44yN%2%)Y~~Q!wChiq%Y=F#1gZZ@Sv8sAbL3G zdO-2#(JC+w+n}qQk606<>*YPnOs4#Ocmi_1%q}Btx-|Dsdj2;rrR-qb9Zw|De5l3-2q)jr#I^jo+J=GVl+$zxo{n;M`(!|$vl*t zFjbp9_!)D`X#X6fpm zp}OE}BJWr|BDYF{x)KatJarq(oY-_-gH|T)>wVf+#)yXB_mM@6e0p2+WIo`#KpEBn z+mHbXb)amBKs@T}5L5B=#x$hTG=t zfuy2yc5c`Nbv#fxe#1w1rR)z&1-Sd58@4#SP5kB!GgQh}pJN~~If=8_8GLxVP=B_d z0NwP@%1_lB={y!X1NJDS%|1OEho^|_D*J~*OU%cr4+hiBzX10DbS+83I&Aj>NUM3p z-+UzJUm!VkSQSJMoj`|(>$4I$zG;bYC8<((^-Pq9x%}jdgcSD2J9|#7%xYXa13&b% zO&o9!LHC5lA@KlDLHncMKD}TwpyktDT$0$$4Lv%E&^9L`Rn1jw5g^$Pjq=_-hkSCdFaNYuZT#d0))- zD3C*Dl`LNO!oBY4*dJO3+*8nP-^LAu>p)e4q~kdW{BUO5zwlGzRMErAl^GxE8_(e< zX8O6J`Pw*I*Etjn^1Be9(sKKt)oY+-kdy(rC)T`6Fyr@O@0@ zpo{l9K>e+}>e%irGQUBou5tV-2UeCV|jv>J(c`=G{`^NulE>CJ)hy#(ERLgSd4Y@^<}s1Ob%DM@yl zQKQ6+=23qXx&4~Fcrmmn9kNZg*V?lWndKj{i$;`u5bdaEoa~<}ii_`XTjS;c_X>1< z4v6O?(34byc0+#aS^FG+f=|JK$t_9bl>1P4_LdO>Q7(0jICs{jpowg82Lqzu`6iz! zr7IJGo~W-d>P;9i4ndbUvR15H|A17v&FXD`W55e(b~4FkwH(g@^IPoJr{U0I2? zU%5F#{E>!BTRbs7lx?Cw3#U8^>tjdo55@iX7I1GsSB?@vK7|n5E<~E8`Pb3)jZ#;Y z?8BE?QWx=+g3v2vB|7TrFifG_P^)d~Igm^t!Fu&A=sw@1 zoWyA|SCG$ApES$Zs0_qCzkS&tJ-k$!+#A67N}!D)eEy86Wrs$Q;cCJA{M@RttEE6C^=tGjVjbKU-h(dvAI&yx`QPufBWM)euAR699M4b6 zNo2QG;|*+i0J&d;!& zRODQ`TQXypAq@AkZr4(^HfeH8c1Ic6x<*S;c7|eO?QbqrFkBFYr6zq%lc@h<^)ZqS zH!mpjp?q8w>?e2xU5#k0Y8wPGm@wt9h)7ikgc}5v z2HzNYXc1sQwmD@Wn=#8;2gw=d`7IC(E)RMpuSUJk(-)f6@?*!AQvvIf&!D?B1~nxT z$W`(P(MS422m&Dt$0!!1`E+XMhy?SqThk)1wtuOG{Ix(a<*_yQ{&h=#M_+duy}EN( zz{LRX0beRGzAvErsTF~}#r4c3LYo+-&1^CwyUgUfP8uC*E@gm}hQc5*W#|*7Bl7;( zrc8Z6?o1f$lL@)hLF*4ssQTG&%%cDQe~SL=gZ{74 zKWt7qN|CVw_2;>^i}EYG`6=fz8;E)64k!wX6-imRB}6_nMe!WcYm5DkK?ignAm6`h ziGKl85B=7M+;8VMmd}jVtBgNK?oq2^dEs;IRL&XJ(pwhcP;xn{OA>)yF%QBeuFTYV z;vN&TeaWhJ(xfOYLyIkUfC~k>pWvzWj5HeLspKpI)Yu}QoU$?%8c#F2{<~Ay8D+HA zi6%lA`A;YLCV$CaOTy~&57yGP)yt}BWa9PIw5GqP1KhvQi2e(h_QD11Qht`yH|3NM zRmw`vkqd=4ks-~CYe9K61Sf*ulD8JFLJWM!3RU+fDbur;IM697zq9bA^r8DX{z`ZR zufH&$E4R=#&-(G|qut^d>BIZmYw|B({H`1+)a3@dGycqc+9cl+9N~LC`T#e1H8|3n zxtYGP-?))D?{I%4#*?Jl*P&?Ip+zs{+ca|TQW;Mw$M$Y{9TRbnY{nYh4n4plC6*Lh`1G~2{k8smFAzbO{0C+*?V3l+ zlB~@1GJ6?5&6j$*=rW;;N}0@UTPH@wOamO?@f-6sjayE)DNvM_YUtGC#jry zm^NVBCELn=-3EBvAG{~&k#TLHUf-gnqZLj-`qDN}SecgW2d?}6)(rjyjL;bED)Y+D zfQ4ewfjM-+=zou-c`K?zeNGVeBMwDe2w-AJI+~TP(ar)2HD#SpJs6^5akHQ%pwjd@-rLV zx-tEo-q9-)CEPz$_H9xueEWUD3}O9As~fjgs6>wI8K#w8kIGHy!+GF+6BTq{ig)Ua z5^&4YWH#8#98V3M4Sl1)UBNTjr)^YiWzA3!^7Ax`_|WgI)rvW+gc-O1h~h z(Z8t3_i}Op`J#dDG4n9#__awU{_A7QrS~Nb5?_SY(i~|%GRCpO2e{~<+u!f7 zjCZ0xuVi%p)rHazV*hiN(OtTo3>*7U*WU@Je5w zlF>*JOzF`*Z6=}ZlDN=pCXo!$6F|xG%C^7qkP3p;FbP4f2s_kKlI2c!OU{r;3@j?9 zU&l`Ij*JFeY|zbp+KAz|*8a|OiI>`(HfY3ZdpZe?NBz7nkZitYLX1jes8(OR>b2_~ z9;rF5OW4m<5Q|_^Z!aNvg&k@v{T-}F;ef6@>KA<2=l}(lMhCJ{n{=LS!Ti^dahrCu zJnutc+iA;;oAgTk19dVp1(}XgCbwl9Ok3x~Z2e0we?@k&zzBoq$GD(dBA9gw-6dv! z!1mG7B7e@-c9BOZaqVGH__O*Whv9FeZT8WU`GcHa5*5mWXgf7tnv$`^*(c$9JmbF* zTeK6!fN}Uc=l%r@rS5qPtaB?zb4agZXbdaL6yd#xwW8|^g}m@QBxKfQt{h`aR=k z<>WsC-&=D@q9kszGXAK*qnj~4)Tu}8;Eh-?g7y+Er5#gTG*GL)cDla!$tG)uVJRs<2eCo1lj-Jc;D5pmxP+h^w3L6wQAph}^e7r=sewH&@IA8o(t1UHbJilI>{y-+w6ag+H7QD5vZ8 zJY=zrm8u9dU7^E|4tBO#p^4dB?{4msD3_%h_p;W0M4yg52*;hOMnbjh0>_sWbWt*e z_vj{A0uYXT_g@(DZ^A3dk3G=~j;Ew-XduVWGu<1pemCQmZ$sWNI;Z)4O4DK`_dlm= zUC^FP+R5Db?gHdX2D)AhuknM%B1n%jg>}6QmM4&{szN9@_2^h{%Qnb0f^)0~5TYs(Q(e~=qXbIQU|Fq%Bi)ZyC;~r{d&KX?)Qh+Y6 z+Hg6l;)ltEV$PVKId@JH^BVQN{q#vS5pxN!ftJwIoNJFO)Re~?BJ-GA>_2xxf=C(% zJ*L+}af9V?8Y;oQAWG0}5T_sCrW!?T2?@~lW(v2fGPEm=L0sq2Yj~t}A4oco^umcW zdl)M~g2f<(^=-OHjvqF4fOy36`GLjm`ZWeT7kLM|g-{)^XOKooLrya^1WupX2_SPy zMxV)$`Gm;cUeg!a?^={&+d#&t!?=H>(KZR+MM>fs;%ZL%p!Btf9tFy|<3H=)dh%}# z^k2Y?XGPQ?7p&Y5^9Z>FvqLq<4v@Tx!sD8WHwBoPY3G`H(pD)2afY_#^UJMduILn@AQLp{7_^ z5!r>J;F3YilTevFpMy)g@Q+0G=H;!72o3vA5dCk#KHb0Hk$(Y0o1cBBO6rY+%z=g& z^}hUDZ>ZBuf3+G_4DM&n`c^0ov)zN?MgsoP%ct*c9F(-A30i~a%tf+rMLOqRF<;%i zfqZE}SH}*a9hHb}4{9&}Reu?yFg;d{$6@%aizdPJl7@=9l!;B1}O&0-X{u<4P zd5w{d)TkULO?Wx108ILF@N=UB-IaiE@<$jfI;{cALaFtKbP;LAvPV&*e{3;DjP@nQ z^V@H!lGp2m%snBp@SPe7BPtTiHDTTgLQ#^EA_fNX2m<;3y%zrqm<6uPN9y<4AmvsTP$^|EdM)byvJ{i5|>sk8ht2T!}gtgXY5(xGrG?-J;D}X;sKiCa`O79z9i~kujpKX{}l96W(OJ zcDzB>aGGBDB5hi4#-Wc&Bu+3I!=AqYaWwD#wOaZh%T#x&!4en;CeWo+Ar*FXyR4VP zBqdNT_gkXvr3uRDVAm}Qpu8uh4qw(_2*6Uu#{bE-)TuHRD$^O$9AgG;sed7ZaJ1DV z)DO-FX3%|5Hi{g-6ha@%{#{BbQDRa3s076%v^HRi;HeXTCrw@E-9Rn;3X%FkET<$~ zShGVJ^+=hHl!Yk&`vujRT&4h!FAM1M4J<_#%vz{M;g63`uD#JV`S9s(Y~4_(lrM*( z;!koEMEaYs&U0tBfU#)YxmBJYhz~!sw2mFDt$!>94pv+IU8Dc|9{=sF{TDDbjN@ui zwemyFd)4UJ*WVkgMw|@Gb8&v(QPs|VrnbJ}Yp_j>m7s{9nVFZ!@?=R=sLiXzi|Y}B zSa`+n-~Q?E9`m33w|4z6V8SZCDJ$}BIH7&QrkyJl^*42HmK{b{`%Lu;em!DpyK-Q?q%ezlwi{Z5JY} z1YF|g%^X0qq75V#0WK%#GU-B_rQ=9Say~>BcMgWvrVPrNSzvz<-FRQ$wfEgxs?5|( z`oz#mLH2PQRY3=dt5*Dzue$3zcW|MzHSfzb*w^-V&-E{0$j31B6Em({QwTA+UtD-9 zxL5r?t}{_KB&~-8t_O(K%I|LT4f3a3I2FHk*0^AtmVZ%8;lj1tsVNP9drY>k2ITwq zp8qdktX7`RA$31^SBAg-eM0+F{cT~{a*_B43PtWeYw@OQa&Z0Fn|0QGHq{8Ek$*03 zEq}-$xW2Ou4`AeByJeP|ZUI~#(3R=-S9DyWS0Ze9i~Hiy$>Xi23lHxowOtdeB7-ucpPOmfqV#4KL& z&*1&|?^^9&!2HCSd+(05#oweT&W?I;O)JBc!L7}m)T+MCHAB-|9_msqxIed`ZGg5(k`9b%sO3Hxv7fJQz6V{#81Ee)uNMQi zpgg`X6A900uBkme;j*tLR9H!p?eX02Ur^zws|m2qjh|O~5QL6h4}Bp7Y7yu$__7^q zMa=&0wf~Lp-)qpnfGIxN3MTYX7s;t9o#g!CjxPBmk}u=&I)? zb}aBt2D_0Z3yW!oMZRhi=cdnY@3>5zhuMdoB5+&cLa16)V_{T=@T zhQ!c`1o>S6tKG2njN_od+8Msgt@%4g22UavFqb&6B~9_g~gRDW)s_e zX<zZWRI9j8|?MV7`BA9RC95fFpW9B+wi}QvXK;0eU=j=J6i0RmKll(uB0V{`#-i zmK8nu8BP+`1tSqnzIy-tQBgQoe-QSRQ7!hMw7v#y0ORm?ulz4yhW~_N%PX9|N&ePj zOkywoKDFlcQxE3QtH8oPP#&qiI;Hq!Hn$l>)x4Y{Q|`KO?;5NiFliv-T^hGp2(x#+ zR{;0#+4sMIxojqrLzoIKncVp%QYUBm$=jEjAlj>z4ega_g0waU(haA?YBzOu@lm=O z#MUraj{G(v@?I zGO_}cbikDaU7moWK%9x-Zz|m3%B4s;9uJ=yjkRfqnxX8F_DZztiE0qMX)1*Zq&lv~ zGm*XjWQs5jJsyq>*EFy~5PyMv^S5^RFJCFpmCn+spuB7K9uMfTSb14X@6Mb=MUl?C zm{Y1}x4}<(sU07VvBQ)cP|kG@ANh@)xp{GGF4EWF_BvwVob@xh9^guYt{Yx=J^K+} ztho?9={ri4R*Z3VtZ5pbo!{-zt}e4omGj}56ZFVkgjf2?1`SYceoy+B+aI!izWFF{ z+4q}r9PF!*0bS-T_(C3IO|)(TXa8^ikG;2ms^WY1{||6zB&9o)kPr}qP6ecq?(POj zk(NeULP|nH1*D`wN|2CFX#tUt28sX7=L73McisEF>-$~5`-^+mc@__|&%>VAJoBDC zd-m)(i;3C`A)K|3cx$X0`a4YS?o_w8?wCC{ad0&}XOFufwZC!vmCn08a@gwaj$$m2 z*)PAFk0ANVL%BY64czy%6jVg1ALX8@EF~xe<%Y)0s9P!Aiu$z}i8T}F{=OG0A;@cp zqRpwz{H9ZqwvTDu_56djkNol19|<=@xC&72eg0Qt?c~)vg7H88yw~%?AE(q*#JCoc zf3h}17a!Hg0_$&$T{25cYEdQFWB+CnSbld<&&vELwymk9cHY=g-xsA ze|Se_|M7m}d?HhjeK3zDxglqK}2qP7szwp?9V&F;NcTszAApxx>W$ z>$Z8+7ZVx$N4IicCzVX^q<(+%41cB-_X(DpOVgNYuJqXrPV-r8moPW+Xq`|4hSR)` z_p#v_Un3_(AzW1`*Q=pni6=(A-DgtR^t1At>NymY{c)O(H_5%}WW#6^zupQzo>L({ zPv_zB(w(O_pDJ@X+!LE-EcC35xJw-VN)N()0_Aorux+&BRx2tC5xO0q8_zezhE9DZ ze({Ovd6wT)%UkP0G51?_F4GT9QfeG!HQ%YWU-&xVz2H{$a4$Jptx|;Qs|MxXG$k|& zS5(P6W@g<_c^cciqCL7KNOdlYB(i#qp|x*D%!o`$RdF773Tbp@!bgmkJ5w7;s{|cS znAiqGUnx%qk}vo_1+EM}K1-8u2Uf}2t|7X#iS4$O{p+!qbAMKv(d&~zY7+TiH$_hV zB#w0?HwAGaRjSXApIU$3*YV6H+Rz>`EkN=wf^apUT!P!$wUK;^zM5y|a&c@z(TsO2 z(oLv5!{$BTnEtFxKGf*(aP?|eY<3uA*eexh zym{in?8oUR(>L<0JT%*)`w|e97DvY~1;KP!5IXt_YvikY1FBu1UgvvSt z)yDMJrV^x-Q{UAlRqRS)9OucqUwyPqX;az7(Uame_nLM|&pOw7g(w+5d`D#F zBPWg+)*xe7V-j@p=*4jTfu|6zE|kk(eND*SO4i_sL2Zm0aaxKAvW(aCwRe$w0_(O* ziyK9Jj{}zrl{K4Yci7)BmBgi->5HC=wqSp7&1u-%BF*A!ThWvNJ^s!M-krfSZ)I-N-22k!{sO*4;Psz#4WLpgvr(c#7xVm{y2ijJJ zACWepb;%gFecY@mrTFwxlX+5Hg1SNR1DA8j8Ffc#=>B!xhzbpG4(dt=Ln!yFx6)T@ zMl;uKGwy++szw5y*Ym80jTm(`w;FHq z1NJu1jQ@7k^Hcv||qYUS;JKXjK&9J&0k*B-ZUg4N}`*7#prXtCR-p!2)!?Bs8Fs8 zi!H!!4fsl4_M1L7kqVp_qK)1p&*@+xmif^8H!~<#QIiSzCfPM}?iqctv|Dx5ozwM= zKU6A4L;K1f98?R28>urq_fwr}WL7#kERvp|j@bQ;+B|8ud<~ml;XaR6I3!be!cc}3k+VTQVlggU%^!ENW>J0f5l+^2&?>;_=8gAx~##pN!HK1 zwdHDQH^%8j`zXQhQe5c(w&g2>_m@UqH?hH={1mG646TTN-jm;CithNQ^{8#A)3-^A zr(fj*%bEn>m7{E8a(U?GzkMIiy|3w?H9avwX9$mZ0zO-~;(~4C%HV~LuNP{(7!{_| z`LloP3toIDa@mjb6?r>tb;9?W!aQPdDnmz=7I(}$yq!RAVmV}pjNLi<+%Q7v&Oz#u z;)4(f7o5YtGB{qPkKmT2jc~uF@e~H^J)?FIe|McyK~K)~b8JzX>Zn;v(dR9D0@q#? zO$I$L|1CrA*D2g^D(y(*`S!v*-~~80dzG&>lxwv=U&HCyDtgmGV*fWbF;OAwc2Zf1 zEYm%s4u$~YCd)U}=pJL=_Woou_#x>s@_oM@M#Uf@mP_4p%GWZ}Hh2!OgvW{oXZ=J;J#yX9_EDVH%-YG~qK)B$T^~&JI z1F_otk1$&4f3!P$o}i303kW~%;>=1Zca0{HVyItw9D^Ty8vf(cMqqv!QRq0l=&=N9 zQsgjMV{}z>d7I^LP79JXx4rs`&i#s{k*ywkeq`;I69MODuiAqnlsgwaCf63tGOC zU{^KM(cJer)bL-aQC_70f_>1H!RN;V_MXNQEyTjwvvDap?u%w=rCWOKQY}9) zeejWu?-sG@yBHI`i8Dr`7{L&jvYYWbU7H1=7B?vxg)7D>{xF2=0_EPeZl@jfxN{-h zO;Ipbj8Z|NEIH_EVkvKvb@GGeaBW}3a{I*xzpo4J33vCkW#<|P<{rMlzV&7Jb}IVZ z(x>S{2-g+Ly_UiA;QIEvip7hMf>E-6-9R=;rIX*EMB-*dy-y~>;81ptlxZv35%HY#li^eP2 zO%wb%yFm_&B`pQwIQ#qgbeX{h%+BTOZ_rLO!^xa6{yo8>H<$wf%-r-NSDye~i1I=iDf=r{wi8OPUZ4>4$JVpj_MVn-$!iEY^BCnAq+kw)wwZ z;s-Me*h3Pz$cWX=Hn?Np&d24>xS6CSFHjAI$6oF?!oN)T=Bame$xAqIC;3CTo=~na zHo=eEFU^U~<LAZr8pRg=ak-SEwq4^y=(8zolMKSmE#XHmz&> z6Ge)+(c#(LWgI==+~JiDUQljNbtaV$T`>C6R7+LAx0%KJHFi5vw*yKi<*J{Je4QoY|cD@Zh{HMbpZhJ_%DccY}gZi8@r zpj`9-PhJfeI??pfDQz^#$PWshao)3nFH*Yj0{rjUwWxhI?>xqeE#zM}bWRaAqr1MD z)wuJ5wf^?b=vNpKB{2(x`wYtcvuyEexJK_n-l`*E->X`gn6ogJNVxNqEA;s63%wNy zyU$^44|nMa-69F`^P3HucTKk%#w3~4vX`k1AEKgfL%6n7NKvbIjNYip&JtY5^^At&c+6y^*;W4Dq(*J;TvHWM#6K~fynt}Q zIfE;MuODX*%fFupg_qLLm^|eE5!|@0Nv^u#pjX5|<+aAT9(!jTwSlWCx*k0YLu|Q7 z^m!`MhNw4@fn~DkQ}*oBUotjSkJp~dP<|l zlZc6Tfw^e`1ugGxx@a)(M#?;(!6J|+k?AtxsCHG6(V>NK!M1#5@MF}wz2BqdL`XFQ z#O)3z!_Z&sD;&NJPxXy*TGO*lzwwGJ`NT>2j^iBaRZ@Tv%|C>VZO90MK_FwEG;PZD7ZeIhIqVK zU%Z=&7T*8rrCdp7IGT%ZEY4H&k(u&{CkmK)G)4m&(cBGE(Mq^&QxI+tlsm4vR{JgD z#N;sY^{_eK=j2h`xQLcte0y0x&z%*N>@wskmh7JPGo6y?Wp3Qfo9kihAJ_WyvD24n zd0W`WyA_;sxXKqihF1ocF15L)IQYK*j4(J`HTJbF7B{8x$2_Ffi=rH|2<@zLw<_{i zd7q_uwL_()xVju&e5}+NnY{`Odn2@oN3{IvAY5<^cx7GoX(1-TeznER*D&r^rrxqNr6-{RYr)quLtRM)QhPk;Alw&F zE}_(VZ?X4RNplr~$(-tfkxt1GXHk=(+dP<~!xaJH-AG?j*Sikw*NK6i@4-8-)} zRnx5G4rF^)2PTZB$dlrF6@+85@>}?eDO4}w1rj0ohC#V5?VM!QDf#vdX!Wt(;+w2g z;lxRpWeEX_A&Dl*whLk9<~eR#hOB+)-OR~}=M9~H)lZC()VwpWq}5E=UQ}&CxZu6U zmBEj&CxhojK3|g|fd-}aLIt=qp<*%LyW4ydYSUS`QCOUlzY5TO=&;Ki;c7+-c>71 zKdeSiGtEFCdv`=ouzCDP!-Um+*^@E%*G*ffDs77w{?DI$cWyS~+kfVBuQXs>+!Q)~ zh=Ov#(QQxtpk^L=A~h)fvOT z`9J9|q1+vYvaQ+nEyr$Ki*L`H-px-IyFC2%;jxkH$#3L=a4qsC(#H?CG;0O4w~}1b zU$;JHCwY|V;lu0Bhk66%F#R4n{}2o1GT*j#Q{gGH3zmYtPmC_G!}*#UXC=$vqvsYG zGL*c(kY(kz<}(z$;^`tP-SuTcrON%ehmJkAhWflZHQvnXLx>J>P;To=xaI?aZy05F zO5N9eUbq+@FbvNbGZML;Vr`4)v7!*nB;0ZFm2NwC%7hC#4z6OsVjbQ07sP}y%DMQS zxx9jK}5%h7brr)@++2lwvvB%b2Fa{!cBm37b7;x595VotwS8-f$93~8xh`ay75MR!@G|^ zS4dmmZP_BT%S;dxwo~Jyi)_t)MT7qf9$W9n?OGgW_+<*S3Bm>YiYtRtB4b)_X;|Vh zbEpsNe|Be3Hr}v$x!8rI^2wd}0Tb7LPu*@`5Y=qTb0ha>?RVPu-(u4y7x4;cMy5E( zV|Orveb`m|O@eX{jl9q3oj)BPUGt{5nSJR~*gNF>uxJRC+k=q0O5siG!IT(wR^cVG6rRg7;H41z+=0>+LT9Jmgn?G;d%^pr#$K?xutD~n0;eyBT%HYeo z$FBSKwayszoDQfBFcB<)w^{corz01?;AtkD-xnkJ7mVl!xeGWCVl z|Dmv<4b#xLV&SWlS5@hEZ=UE5xAOL+87=+!aAV+eM+v$PmVf($BvJ4CQ&~eD{<{!v z8kD=7p-gOF-E&=^xvu#xYOQDNL#-YggqsfK)=u~i(bCb~Ezw9k7mT*nl=w+o@a3_rR3+Y#f2L>q zZ&Zg1p{JIGJzhRv6NhkLL%G;Tv4ax=tPCNE<7tL| z&pKP)37jFhi{DKpO1;*^>4bb?hpPI@nnvRown3lk6 zev%42Jy1K{oEclqwvKxVCkIWTAe3pz)aM{Gp z9^5w7bjGc1TdaugJo9PROS8XEHIv>domZ)tva$5AJqzbT9g=Srl)E1$&UIRF%P~Hw zBDO?OtKs2$uG{R*+r?#BS>f8jUi)Nu^CUxOgO1XZHlCC^dwT(7ri6_@npW1L3-?Xb zIprYSY$(^i`I;r>%mi$L#HbyA-Xp#)eK_;PrsNPOp85<2-b1*`Vp&*`;28GZXR>jC zp~dWaEBOrT?`ND(ABUQcb1!{@aC4ws#~$l>d#zGAs@g%DCog<${+Lv^#MyCW4+fe( z)o=V+YEO89Tm7uX>*$!}xMpx>?@%CHomucMzdcvfODqO;=y?o2tG_aMSHNi4k+!rL zPvw5sRw2?M0X)=BG(2(=BgtnPPoR7JTj=Z?^_yc+D<+skCfR8V;T{kF1T$HyR;QTG z>})E0gXEhFWA^ZG@ zOTf=PHb2x^mk>d`$Y$}IyU2Y^i4_oT9+bO2n$=ck!T43|!u$vvi(GNvLb;`ZSTroJKFo-6(k}*`)XwQEp3$X- zu?PCboBR83Bm~%TMBiW8IRD^gDJi$+%HP)q{dRkzYe`#Xwj31?-4+p_>~lmmiN8Q#dk zCxf3~ZaNQz(nOwO(Z`rQ+FU=tJS%)~-UO$A5@Uf=Br_Uqv{4 zF!39k@u&>WmEr5$vnw_Z5$4zPWtklHv3TQc?q9Fbo%Eg9qd?rJcc-nr7ahVafpVux zc3b`Ak@wG#Ok%=|f>)JmXet7PTiRnW>t2hLscO<8ulAh%$k=^ifpw0|Y|MP{!Qh1+ z(rypgW1avN+79Ua`a39B*fQu*$XsZ6)ZS`K70Sxg?(Y`GXxGGd>;!Trho!)sfx3AV z%r${U;Q42w=-7lnE=CMYHKc-fG7$#J9oLJY^Xu=S+?swADsEas`;Z@~#tmA&U(h{r zpKVHgd4!#5_BCX%iF0a6`Szo=gvC+oQEr}x!%|I6k%torI>QysO-G5jN8p&@s-1m+ za<6&Ld3$ymJ28upC%PZwrJ|Fv`VgyR8wXhpABjH+-!!LNzh{G0`&Fjm9ff-=)3qQS zmcqf!?FLo)J!cQ10I(0c;+8_WdUlC{zl6CanGdlY+0s8IE#P6AEn1RfGk%J93jKXV zENv*FJIj{dbQ+Fbn(um3yE^>m6id*E1R)craecwpN(i?M%DuOBJ-nF|$4XF8t$)su zSB=)f?l_UzXPg~1M=qQVJMB2Ut01JQ_{Wdv-OcrBBHEfkPM-+a2C0=gS~|7`A$aay zEm)Igh67uNTMSW^o%8qHD9BRF- zWUwqp8f{r_X|^fdmyO?r&yP5PoX zZhz?aG8YB+rwkrT9@6S2+YhB~8V0&vx04+(`ml5K=7kU>U$D=-GWe=ywQfv!Oj|=h z8ERlbe>qPMWpgXelL5uN!htH4t@KV{4z5eYc=j=Vilu{zYz1SZeBXEcPpkt6{azn$ z_k(lES2}=NUm09~P4#H@*Y_Ap5*4 z*IX!!KSQ~;I2);U9A5D}u0rN!viB+*hySyC9_DY2-zJGPXSoY~HE@OAw^T#9a(RLL z4`!H8@l|JrvWYxf2fxnC%5~Z0pnD&4TK*>TYQ~AcMa!W^C$uHH`4HzRjA^R8TI8BM zxAL%`ioEC@uur(kw+6~JC2^9<|3(m_QCe>(qSb{aKJs~Fin7QEue2!G5ndOr7Y4kZ zHl>kXGsg+IBmY6<bn`~S-9DAbD=Xkiq?yI zjb<5z3Y|dtSHy%mU2r;WzJzqoMg4=WJ2q~dvLU?I$K}b`5n3pj-^pF0d2tva+*&BN z#$TeNNZHifOMxhNH&oI$?=3C$Yp*@2#)Jtc?#&N<4h$BP?GJ}^hMiyd6=Kb;Cc9PrxallqK)r-$W)$U$2|0W7^=vsx_`_E z;Wj|IUgGKMnK_cQE(zHhJv0k%K6Twi+sh`KlN#wId|@@bUiNUcp4jUG1NV-ikVCc^ z>mF}^$=8>+IQZ&cNxTVkgZ7i)vy3Z)`^<&>)U;&D&uzU3SS_&SclzM(`NRLXaL_hq zrutf|(9h>6&FzFqg!4xAVp1EOuNZ|JNAGC;Vc}#~x~VkO_8gLL6O^lu*YF6Ibs_6M zITr3}f+u%8qMNn%*>RS}N!6E`rLUQ;{G>{6t^ekSug2-S5`RwnK9=9~4gC0A4JAMt z56J+$R$l4b4CT^oDK+xp=nxHvQ}Pc6z8@2fjWx`%xRVfncZ|&JS^m+hWQ{SSo~2$~ z6OHLplj4GHxTPAo2jM_%)7##{s;~~nsBR5ZMcroNZYvopAW3mb1wnDkWh6~?+ zVf}Ej*QXh~_CW15hj&{-U4z|8cyHrpAKj+5#11--dvrw4UvR{#Kd_g4WAO0)>$(Z7 zch3n0B)gkT!9L+ihtE)MK-wcd40^hW>GQn~26@8!%E5d-0yngmr5=mtI4dU|l&%US z521XzvBq-HUVNL(=;#j_QpTz-M)d28VYW~na4znO3-<9>245a$b~7ms3(8;qGHw}f zWUbPR^QMRtClLK)eOOVe)PjLxC+1_@({}#=tDekR;qZ}wzJd34?pi~H)2hCztwMh?&s%~WJ7L-R@-MX6=`f zy3<9Jg87HV*-k}lDYuCb@gA=M90Dej2xhKf(JjjY@^BNYA7AZct!LRU+vD5=UC#nzjE#i z(96g}xIIwrOo**;q$`7apH6ep1ju|!B}96Jf!mT1gw=IRmX~}} zyWH8o9`EBeM&<$zn|>`<|F1u=Cz)~IK3i*bJ{_jHxuBtbV#OBqC5* z(>lEO=?^MeAcWfo;OiCu;ST>BuI{`3om-Y&KMT-jX2WQvJ`X+Vnuv_8x_x|H{V`~M z8ChxU6pmKskeKjzcA&8_zj=x9*BAbVDV&UGBk!eC0|*y9r>_j2{j9hKNvY+c@4d*) zE#L9w0;g*z73?tYGfufo{MT{=GYc919~p?tFJ#1evM?|>jhK6({(DWRTB#TI!v{G0Y@2>!_f5F36z>LtK#J?xd zqu7_si$OQnw0IzrUhyqXfgi#hgK}XDbTY7vdI;kf=&M zM6)i>?kH%qH~I6MrntmZaftWaJT$%DT*u=GU`H_gwHuG~Kj%67hUmi+q%HDLA5?LO?TklF|R&Kn|5%B%!OAxk( zgp&J1nnZLXYY1uxwkvDbe$@x$UM^aQE{k zZOKz9QMMyOBc3Mq9~ob`-Aehur*`^npzD30kMbX@8|#bwgH(ios3FYQqDu+Ko@o%TqsF z_JYUiDqrv!)Rn8k72OzB*T-gK_xo7D z>VeY2XOCzxN~^l_Up$tTs%r-u*zg^&54+;dK)H?YYS7;8q{X(0+eNXk9e#1#j_TV| zXdw#5Ux|Aicx%$FHs{+B_vU+t-Us}5SnN9u-2}W+WzB`YcIR{6COrW)z2bg{a)0O( ze@XCJt$Lmys3lMH=4}(~x_sP#|KrOu=?~p%6|HE;nyqz;My}P}p zRC8Ql86TO&Jpk>0XQAA?KcZybXC~wVFUx%;!DAV(fZ5BlkAa&?L3#XcXLS0A4Q25z z7KN6$Bk~R{!+fkMDi`T8MJ^6O^JXKxmr(Z}B;PqG_o1|+@avE7vzkTm4kE`b`o{2( zbx!8pjTkZ}x1v2zQn?sQ?c>QfezpJkG!3isnH#>xDkOia-%V|VIko8E{t<*b59M-B z;_y(3CWTK^kOv#aL1G;mvY#(V?0*+C z5RPGi9MU7XU=EIXuk>AjaJ1LQt$CuZo9UNx9e1hAmo19JPJovpYHL(5WU)a?Lw@SEnXa^l+H9jdxJI~(QfjLm z_}tdD$tY2-_;rczIx#Ayqz4blpFs8f0p$+7Eb3(_keyJLLT`N< z$yLlT-6OhHrGDDJ!g%@|lQ=D)ck@;&?}X?o7s>IScK>E6{lIl==MO){_6xKrQCGk= zaHYcvlzW%;o=h*Z^GB?CDQZ1Tzt@y5uZD?Ws6TYWStA6=N9^o=V`knI`N$RMHy%AE z#5=kotcqKF6igw)%+HCFG6sDvy$a=0PbSHqYke<4QP*uPxww9_II?wZ4VMwGI=T25 zqvLBMeG%|zUR{K{_@zIGlMa5MqBc&a@^vn@ve%vBCcA1fkbKvmTv0N$sCJ5d`_WeN zS{wrVwItuMKhLuxtzB%ac2yS^)q#JKSz!CbX`9;pd!9lGn!m07=W{!Fb-~$rlHES3 zFgT}krNd7schr_IAouVY?I_9ds(O-0uf*Lq`%66i2gqNCl^mqkr!59zA2+?}czGlr zpQ7Wtr|_}KF^q$}A&)UO!Ec~w^F-AfWJ zPEqvsdK`&Btu$zM?JjnMfYA8}h; zFu%x}3^`EMiKP|PW}LG)`NU(pv}?gXvpq(h6Tt5pn{o5khiB+1$rLw>4(hhrB9RW= zQQw&dHNDbd1In$Hj!m5(iYQ2vpzS2aPi8G$=BNGBCgmk2HA(al6F)l&*&U5>FJ9(j zZn9pH+Ebfn$yRzjPD@)F!^jk#HGyalE_kneW$^dZ9kZ~$?Mz46dtHmZVM-;Pj1={J zZzBdr^wG_eU)dL4%s$x9!$rO=ly4ey$0()!^rmH|<<|x2)J3%$lMc2JE_m);8T^Sy z6lOVJLV?+0#KZSQ2CAO+1(J(rFXfnMo=+t8>fy(H!jIlqd76rjwb0<*v*17eXw!H} zTV3~>Eqbb^X`uszyA9=1j?ny}e(Tn8N~G2$OcIRdI>b;rd~LsX=vA;jTlS-+49~f* zw*#h;eMa;G)|Uwyxvx2r88C4-K071t zIEW_7{3ycMsk9H_g3pq#4BlAsG2pq9=Qf?k+eQ;Vfk?^kbf|p2m~m^`9JKknX{SGw zk(iBTrO8R6;Rp9eLz9g3F}oVJRD>b~~D$UY%`LBZXsI`&Lz8hNoSj$#30-^WU3dW5VOP5YxFwSJDb%FvzoNe&1X z%>ByXvV|$I!O_Pu1=f>kGJA?Y@No5BR*q}Z4M$$9sNIkZIXH;JRK z^7)sT?_WCn%>}RdR|fA`z46Gp<%#11l~%zQ?UV+=GK&p(6^5D1+P1AUYm;jfZwZ1G zylMN1k-R2JP;K+6zUt7~A*a!dA7oARHP-z7eF7MU9{@ata+Q8QAfD>uQp})#GWR$_ z+O4utVWNe@lFtx7UDY^=_tWM*xXb=0_G`~{yu@X&(yEqbB-G3UT3rwKN0d7G-B<*NY?jts4@suz403dcTG1b# zSeAc5O&gBte1L6eTC@+$u!Jk$`J0Oj>iY-E{U%o~=@WP_>Lu5rPT~3(T`)5Xt^msB z1D%^C0>7$kjonhO-}0@~P)YcW@pHC~$-}GN{fuzDdj;*uQ9|h!cl2LeKN!e8g>r*N zO!Y;$1*@im4 zoC!Oh+b@?XA8|ANo$p`VGbmSbE^?DAe|T)>{g)3d?5iD_k4PrM$(-2g;^lE~s*8{G zptOCW!p~LT@oUSxesn_@_?Ic;=ErIe!#6~k`tTS3?jJBHz1bX1iR<*PyhSmD*mFszeC)ewAS~XY4J?$Sy z4(aMvtu$zg)s!f>53wA$j&5zfOjeRB%l>=(`!^Scd_{%9;Mk{AQb8Idd>{HpJ%V$c zobFk?{Nsi;klVlct>XIoyT%`R`Q0r@<)%KfI(<3GpQ%5SO2FisJZ`}0)Vde=M&b5l zzF=cRf^rpa)^@Tfk4ZC_>1p(nzgWRKSk@qybw?Xal8!ozQItqTJBcFvxvN&FUtyyC zPP-%geJwt*D9JO=u;8Q5*|PqBap8aIfDGl@ZNBRCotg@9Y{x5ofI7yJT38%9OC$T|EJ*zziI+R8m(V8h~4}Kjr zF4Ck=48KEqJSi?V!0CkFq`8YKRWoffq4PNyeAbTy1I}V#i3;UfZ)ey@ii*fDD>Tm& zG4GsE7<}lHea2rZjGDbaGzhlTB^$^* zC!6~SNG8u36x*A7eK)L4+3EB*M zbh!J_Ya3gK6U3EmDm$MzbsO|7pC2$HER!@_@C2kMDLAV$fiPbuwEIy zUZ!Od4A%QzY)Z#mQ|hqx$WN)xCRn6>?k|1M@xZiw)&^ zTK`^p%cr}qh6bl=6Du6?_iJ7R%76`5;pniBVn zU^|iNI`=}N2YyRDbp8Pc%AJt(%Rl~AJ6T^FsRcKm((J{g4_Lr&efCg2YGa%C$>`aG zcvoqUMfrPOPK~u2J_V1H_|&OTZYWJ|nXZW3CewlBdkxBMvO*!)^iEtMs#OnUSX33^ z_|_AOO_q@|ys<^3b<6*${QEXx_9q?Gy1;En|403v;3_rp715g)q!v03F|iKNc^F(M zcW0^4ER_l=K@7G7ryDext`(xC&#BVV?d`22q|r1EUA(Qzyi5NTrz3$%KCJMhVo7BC z(^rhS*n~$nHbxKWyCM1FLAhwP4n70$nK7>7PiC{k_G$OKqY@reMA;IEV|{RAiR&d* z(SIO2)1FwYR=9GO|LJ}d{%){+k~vFKSzU{TCr`1P>snvP*!57aXnxBuJSR`9H<7Yb&HAyN!KK}R z5qKugwj9fPzPOzK3fj(ypF71_7&Mz>kwy%4kS?S53M9cDI&L#xEf+;O@A49mL zP_6?$+s;nk=f+fxeuqv%WX~3goqLlyXc&sP$y<@T2^%+EwhhC6mK}&H_fMbwjQ#FB z9*;bQ+}cK^$}q0&<*o+dl0ms{X|X@DoWC4ao5&C48-75+FIVvL{*bs`;c9Tv^LbB{ zc|I}CdY|-nfn4QNUZTDrb!xhX-Lkzq&J0Kl_@_);151)_MJKGY9xAhhTZZhjHNks7F4>u%07D7 z=|>2c8p_qrR4}c+Z1jBE}y=QRXhrg+fZ>waSAEry1-c>O+!5 z?0%?sue-;FeUV4{PLX$?*K-fNXS>Rm2FjH>W;*SDj??%>{a$sQ0cxAh8}TQ-kF0CO zb_s9OG}qHP9jZQzIhgz|n%Vn~c5Ot$0e3u+^dg;qWi#Ox2K&pa@dJ3>q=j;I-NUe7 z8aDQ0`>vt1m|JZz7lr#4d~8!qHhgA%@ICtbJM&_;25l0?Dpm37Yh^n@*9=u(3(D=d zpuF4&y{L4lnebHAOb7AJSoJKO&3 z{8ic-M+nCs#RTcEk|&|(-dxK}GV>O=$DO;8P7p2=lxs5i%_sI#RBJSmXhe7iWey)B zLphwN6pi<`h^#$Qxze@Zibh9XRzFf*ArZH%jJ}#oBg2OvCr|J^__wEOM-m;2IWP_4SPVHfEGcH1T{oQ+g*HLR;)z$1A&xOmn zNvmLyXew@0uhVlwxHqBP`U!qZIUZXZ?G~%@)6?yA!@fvXY%RKj*5XAnL%RDo$mF(h zM(*QpC|w5vOyvyfIU2q3P|~K+D&7qE<^41^f^eCkTo1kCHYWQ7@nq^d#<-i=y$`rE{aQE`%7EAU0?FI*<_$#7I)cgO&Mi#gS}2x&OzN1?l}PV_+BiA=%rR-p-_r~e?a=qXY1;w&?{l8PiAzU^n_fZ$N)Ls4V*Y~<@`!8}9lF#ZZ zEJBFxJB_}}-IDJnHx*@6)2rVhb+zAnl6*H8<@WRdKU2E1dHyQ@v0$@+o;OQUM85LWDKPtdea$gQE^ra%D7#0$@vQeEDeLFKh!?6=DBF6ad?Sv&r8(!^vf0YsG2p z^3U!X9H@BZ|Li*dUFw0lSlBukgZZB7A+9t3>Tv`0ySh#f11ci=mw#13M16z;2nGIk zC;;|Nb~dI~&WsRZ-~BxpQ}O`j;ema+iv6?O1=n>nGI4Mh6!`C{0N9@G ztn7eZ4F+on`@ef`{r6N5ku^ere_jFbd~3h9XcahD+_xEbKrf< zf7$uP-v)lz240c{z9$RyufP2L83ibVo4BgK3sisupTYRdW&J~73DyDQ-!Egwz!DAk z46+u1GH4forF)ARIco_rp1b2xV@RG}YW4w&f0I^IU25QR$#K2=q3pl6?sO_!GdUQb9 z8*tFrff(=uqX! r9nsd0CGE_#^~4cnrYy4%TA?T=+7^eOZqQ_?!-G13WG~m$4f_ z*%)xpcrRl&f$~?N4BDN`7&B1r1FWlZ2Q#tFoBKrmo}moYA&44!+S30=l+1Le)j znDAwc8;E5CG4L3OT*i2Sa=~Rx^fJZ^#QZPU6}ybx0b=2R(*Z6H#DE_dAK=k|gC==d z?=Db|0h|tSsmprzfU+{+41j~0f#~~yivbRr^kwV;PzLLQCUY6%2g)XZgU3o1hygz^ z0l*c&0$_5$5#=*1eC#?G%sTkKw0@Rrga&U1Y%%bU_I^2m=sV3a{yyH zm$8RH8O#BU=>kiznLGmg0&vg_F6&7HWv~vIyWwR_<}xMvOpQ!50Gno8G8(r z!F9pEnOw#+FE19WU?T+#OVEv%M;N#!{KKom-x`Cp!5qOp4HaSHON7?32O%7VJyGz7y;#!M>3aaIo(K`#P{6qXV1)a3;XPHVgJ0U|#|D z4J^PuQ36f{I5prjfL{lk7;t>R2>>Sqyd4m=1MmPKhd{uC0M7;1-vgHWfP>eSBfyUV zKLPv?;HQ9>0veS4f6e^|oD@a#_>bQqCQvenC<-D5a1ZXtB6t{3F(4v>0d((fFTmdI zvAai(DvANcEQp8!RLlWXP(Z;)1rv&(f{H#yKvc|%@OxKx&(7@Lgp<$n`u_ii<<3lX zRaaM6S67GW0iFh)k!zkMp}Usr8-bgF+kl;b^f|i#>35}3WdpT9!F?+yT@Z>k5>2O0p<=QRSPpObz~`mtt!^j*?dN#7)W(IJ5JE7Fe~4zvPV1JYke z{~-NATi|Hm7(n{Ac7XH=(g#StCVpQ`Rr)jW?c&SDcZ;tU-z>gZe6N_X_*U_y;ycAx zif@#7MBEPjfmfhk1v~;sANUwB54Z-n5|{?eC*K9YgTO<;!@xpdByc}478nmq044&`_gw%; zn{Nk5KO+5x^b^uANIxKc|5$9OHE;xQIM51+^L!U_%_iTwfH3z3fcTM7z!IL_3EU36 zj-K8CRs+%(iq94weI~F45I=GVybc8p0}cm{0+#apMc^f13is>zoy4#71Ecw!0E`5r zKP&(`0iA*E;CBFj06T$fp49^O2KEE?2c*CJ65O}IjlfLcCg2v}cAyH_3|tLN1SSFE zE5$cnO8I`GT+(k!KPmmM&f<4A za2GHKxEpu_KC6Wn@GNi@a5XRmkp6Tu@CdRz3Ooi}2V4)#0Hhzi2Dk#a5|{>D1xx|@ z1NlH7K>EjAK>F>r)ay||8z8~`T=*C9TMUc_q>tQ$-pTq2I>L_0%t+{DCr*qo+NEOp4A5$0B3{S#P27- zr$9aK8v)ILHo)^ddjXgM+yKl3BtE|dxD}9i@(+G5ChZn}w*ec-<2pdb3M;s`cu1`8 z#itka5|2q-eh07yz7l6|1O~$A0N@U;KL;iQ#el_kF1UMvJAuW}-V59Z+z-qL9t0i& z9tMQ>wd8XnFcml#7yz6D+zqaf-+AzU8yE)M2}nHK6y8yA*KqwC>3#)%02Ttma{@3A z{Jp?zU?y-oa2{|n&>gVyk{EFSATglCq7wH>oEIa_J^W6FS5K}5g#Kclb>dgz#|r@A z)dP^&^LWypz^{O$m*3{n+3~mH4E-Avze)cq{j&7W(ofd~#6IfqyFVc16<>)8vbYt~ zRsQo;F8%{ZTCsDneQ6g`j?I9yCn3909Ze+5*R#-=q0G251Mg2SoP1KrYY`I2Dlkl6sW;JfI8E z+1yK=b_GN(sav`41_+;%fP6se_e4PISlWuT8M&6z~#V1U_2nttAJ6!7+@SAx)=+{ zGttciUBm;y+;TY<-cM}bEGkz*n7Fks2&EZc)TUjRrM=K~J_vw{18>jBC4 zKHy&9c3>WGH*gm)3%C=w1Goi{^4ttanQj8E0jx623z5;~rm%x94?|@%`AA#+_RzPN@yYPDm@I9~%_yO1f`~>_A{0{sE z`~lR-)-jjFUc0%E0lR>5;BO!b`~~a<^aXN(j=(>_PC)c1dXi3Xe?Ve4xemz>zY@Pm z9Jdd!H&7dpI80)$0|1G~q~H9N^lt*Ma4mVh%COnzb60^hg=Vc z45j=^{PZG_14z7cBCr&YxcOe70+5*OIY8ntYwRO)5Hc6>Bv1~tgx?eVJ`OwvNF4bn zAmd|+)5Zc4v*nZSWPTR_(}AmjD}eKW^MO9V<-k-x#t|n0X9H&eX9A}K5(oDLBu;88 zzwkVg-!{MzfapNvKN>g+I0lgC?SS?`51<1eX{6jCpbtFcIv3~&bOH9|Stow?<2R4r z2K;t6ICKwqFAa0VcGNLelhgsIG#zKB0KoKw$kb7yP!}z@nxD+S= z!hk%t@YZjV=b}H6GX_Y$eMncquh^c*HXJAeL`K1n0HWqv^i|1q92jYyjp27RATo{u zL|0XS$S!p<0T5k`2P}INKQ)DWkwxrXbSZ6ZA|QE)oI*Ppki6gF*))CycO}0k1Hwz{ ztf#?U#kKH|vRx0%2PD1Jhva`B(1&}m)q4Rc^E}{gKC3$5IX{}A8tjJX$m zN}B0_@U@=H{WQSRw^Jr(dZ{_dJ(0zLsY0-FFy_bKoh@L%9t;0Hit-VS^ZYz4Leg8K&e8u-rK zZ{yd($#Y2~zd~bO%de!d_{zQTuLINqvH>fOC`V2yzsM`){sr8xz<#x;Utk|V=F)2e zGPf>s>@wG$1?(o>F5n+vC-67$7w{+W2k<*k7idP>N`5W<*W+I1;3ZF)kC%L9-d*P3 zS8^@=jLgSNEY*--nWL`{NGxW}&0G8|UJ_RcA1kj{$WQW+XD#`a`TO5^Cb1VkYJ7Sq z&kg~ktcL;pcrIyVuD(6c3Xpr5(?1fBIeu%te;=M7%l%QnF~HG4JM$~gLO>4iqnXd> zyqFvQ>YQ+KUe}>j-PyU&HMKUCo4#|QDQ>v_6}N55-S5gn_sbsEF{g7*XFcja7)+1t zdo>(z-NIU6a&vQXbGoW|2V%)7KYsbz&-Z`Vae|ab|A~g1fhqiP+G%sPufE^LTna|= zxjs>LL6g4o4zMvvgSqwfjZ22jy>6_Hxdu!-Xv$wW^Yd?$>uZ?Vi(7jVPPI?WZRj*8O7UbssC5 zjv!R*PAoFzw!KEKt9jNV{2tEM_o032*yg8Vc z?zrOjdN9VDc13EZ|sZj{a%z; zl*3@+F);gsS-Ah9_1onf_6`_qr&~^5Zh1I9B2t8BI`7E4PnnlJLTGd?NHY^|eIGsG zyL;}+|F50Tt6-!&TgzA7HRPKCYZSMRIXLfkz=#gc>)m+ghQ8xMV2sq2p?IXIsxVUU z@@?UJx*oWfjoD=KS+((&dXEmhup1at3tPb)0Jm*_6nt`NH-f)NHgRare*l@fg zL2ci-^^1kwrfmOPFx_&xfGUiZRbe1m&5yn^yl!6JxnO8;LK8_8hNI!4$j<1>f6l&m zjMy8R7L2Tyh~Azkd*p*R8!u}HMiff>krkANV7~lhz-wjSw;ZN0ie?ZPDd|7a>?bRa zIOSt7oso^!I5xL?1p;K9vLk!PSId$sz=#gu7B)1!O5QD+aP{ybg`0S>T(z1kI@nfp z(V$OGSaAv%ks7O#^+0K-kH&JJjczZT3&zw>J{YOfmPdTvX2t$TyZ}b*n(|y_Fc)Rd z+1U5@dn&<*1YkU*CLeEZ7ww}hanSqQjvNCCTxTYSZ_`+_lbdM-55>Yu-9!e=MV8$Hv;MB?#SJQNx8$#-8* z-;{G39+tMCJjHHk#9v=h*>_vN$4^|ZFiLL^!b)WOt>X5}o77p*1`KUpG#WuL!CWw4 z%{k!)&%Or6(oTp?hT&vnLz@%!oi_IBt8C2UhUT;hjqfcO_W6TgQl27OR$i9O`gQ2J z*Z-4P-42Y@4|=N%CF4@mse{`ev|!QDVuew5{TA6v4|v19PY?QM;yZ&3hF0QYRnng# zwKq*6O;PIS)Rl#WZyo(}9By6E4ic1x6QSa0xa5{irArPRvT-#SBejdY<>t@>x!Ic( zho+t7;l+^p39=UnRxWAqNxQ9E4zf!ckA#at7~Lx;eZS?k|2{tpjOpiIfsylXz)q~;!kM%qH+n$veZJO9WD(2$Gd^As5Vo3ec5(Hpl-?k7Hkd^+cJb8p-D zSmIg)6TN-=(6#l~uir=fn`w>S`5XYv5zu^n?zp|q9yCjObd!&FoepcH>-7HNqpHU4 zy0|4Yc{$ysJl;Ke6q-ZH=dK?cKeBSutOKDDd&5_K3r2d3ary1P=oC7?uEC&#U%)g4 zbNmx$4gKcqK1myMabw*x?R#SM+9j=)6)6c+{R{^qdOK>(kuNOY{H-*Yw-`u9862+*yb$wsY64)h$MrKQHBx zW|GHzPdr>mz?HS4$%p$aS^3T3MmDVXM@(1p8FbmU-G9vt|E9PpFIGY)Cl=D{=M_(_ zZqTI^43;ChehiGXtlC5J&VOs4g|C7!z1eOs!mVA0(|7)Hag!a2M#(lEhbX!}zIb8c zmZOIa2h$ZFg05Y94C5(og+|Kr>KmswxxMS=UeI9fIk~x6_kt1GJ}J8CnSx2RJAmn^ zBkxBHX5Xv7xOC$g-#0Kclyngos%T1!P|w@jKh#I!L{mSR@mquJ-o0{Losiqf%~u(_ zcJXhf4|efyhNeLaZ6PJc_daJ`V&~~d&>5-GK`Ss)o{`Pp{d&f2A4_j0t&y6Gl*fjb zG0ez+^O-XrUD5Y1RUY^;_JKxpJ#447`*WFNWhm;2!757p;4nqFk zJ8#{zpN3SOY)f4kjwd2`=eDmrGQU;pswfzOM6s&La624sw+~r!{KAT}KLca@s9S8Q zQ7*Bi)Pm^k|3z$xdj4H(DV=@ZzmNII$2;b8(Woc~jrKlF#}xB_8&fo}-hagN89O)w zjI^xtu37*1VMF@W0V8z^%>{;L@865Q-16URI@&bJ(ny7bNjrXi^t*>HeR)Q$?0ZNn z_3xdJOXR?qNwsN?i!B>wvKR=Nd0^7HTab^PFm*NxQBl;hjv?}2Am zeXwZ5jaZ^M3d|sDFS;1XXZrN6_wL$o@rf!@Rjs5U81eDb|6JJifTlf(ZYLS-Br2n2 zNr+c<&8hY4bq~^?T9~7tX#mYdx6XWaS;yJ0n79|cB}?P6s*=*Y@YMbLKC<}1Az)1W zmW+oPXC)%_4_)|q+a+hM1tY$yD>MJGN=A*$Se-cFjps}6kT^snfLjIm(1@qpbpHEe z`gB>RygwMc*mN)r!3=ryk{zw1=dDvTw4W|nUxJZ1BsS~HZyz{p#pi}wH)g@Wh^IJl z*p5+uoqUhP2a*q%Vu@hGg{4_Xd^hd7W$)e&hFl~cZ`t;0qkF)Ch4t3ApEFGQZQ+K# zhJcY6cHcQSz1il9ukSM$xUB^v_O|VbUiUUxH0T(GQ2|`5BeiU8&u%?$)rp%LD-3>_ zH7L*C>{vt7^3Z=K-?Z?XH%vb0&07NRUh`A9iPUvJ`sKDEi|dZGWec}eJGrJbzi0fc z84sPGXYxU|J(1c~2YF)G3G!)-1V27EedetFzQPjGsEV)Gfguo>^1-1iHVu4s)dNO0 zX#Tenc-Qu~NI=Iq<-U4%zECw|X}-|`we77P?~;1cc$ddpw$qQ+tz_w#EdxI6`wwkf zSt9l0ZSgIR(e3B-RlS;>6F+CLt?Tw+#15`|eNx>GZ+)s}Fmm$56uo8Z3QZ$u-rq9% z!~eGE|Aeib)4>o7O_}<@%0?g8o^gVW`G3_;Jz5E^aS^hKPh9xHkcI!b?nxQT;6WHj zczT!4J{tAbj(0w{9ILJBlvj85IC0I$YmIhj!H1`$Bi{eruG_NDS<|$ijoE52A7Alu^JZ`VG|^zNsy(q@nWARk zHYMkTm630+9e9Dwtv(pBoqGAVZ#iWAKza-{;-{po4bAsOUw-%7^zmmY8s!f+$#Z z?-zdj%~B&9B|Q?1w4aA|jJbNqtkyaT$jj-$ObHn2F)H&XJhEbPPZ^hpcDl0*0*pj8 z&5!z~^`|pu%j}oIc>B>2(1^u1>C@$;s#o6GXlST8Zx4GpG>xFyyHWD_d3W`-dLQt!CRqc4HxJGj+_TUL)>M%{PXb>nSr-WLB0G@`erH;ie$ z>!Y`1wn%gUw~LR{wzDPt_};ye3qG(*S_GycGLE3USzMyYKNZnUgD ziDA8cm3KSclW61rm2BSH@s4-AdnWH5-8CLnBNmT-0Soc=RsUP5_k^2w%;&8gk9_bh z-g@)yOFX#wwmA!pb`-MFk;SZz-#8Y_WLrJ}@%3BBP zXvN~io~-+E`Iu>oWlSsi@YL0+R8Q;8tpgg8kx_itBY&;`;`+AAi>cK<4@)GyM|miz zcX_<^_OI>QTLRzsz(Z=ddDJ!<_12sBICu%Y#sO&bz-E73^yBrLt+U2+J^WcWAJ!Hebx%&_a*S&4jHBwa;pGWJGnD5ZOQ%8OKVJ(@d zmmZxu_2{>e&AUJKt_2SoxOrFz5_o9G*YkNt-X7e1t?FNM+e6vB$EO}r`}z;(I5V+2dT|fAJxUufF&BQ)AN?fD!AZm6TPKM&f13M8nxz4r>0{x)R0BjI%QP zAB(0J+{DMP>DjmE9pmTsH#Be?07fG3)*CwvJ9=2_Z@`#UgiF9k`&s(l*98wHp1oR0 zphiZ+4d%X1>(9Ao{-50xMzt&tZsc=iPaTuTCv}+p&gT49&_E&OsjeN>o1trOjQ1RI z{a#vczYINi-`{sllaaV+6stM}jFj}D|E&1(pDB+HF#Z4uioi4gGkouF_G#R{@G09< zB*Dli^qK|RCfxDu0gJ(icA$9#cP?a=|BAqmDjUMtWxc zdgzIIMrKs&w8WRAURhROuu+%29$mErF8jF5+WQr7 zlacHE+vjfEyVYefw_#S)W`Yr`YJS*B7auY8*hNYLRh~Iuq@?T5>HFZ;8$a)CWW!g@ zH#DOL=e3+M;hfcA80(4-o-#D`j(+!{FDA#PfiV&+0VCyEc3#=aTD#t#Y3K7Qn8si# z2fZ`oo5AtPCLg$cXlU9`xbMD}7Zn^0#^_D0%Y@1k^A0_!#l%RnpKO}SaI!QsA~N>9 z#PVGqoOiFjVR&WPja{ArBl|5Wk9SF3xXCUmWb1sgZo&CYKfCkz<@-sG zZe$x>TE?V5dx*<#`2GI=JNg<7`IIHtw+H5-;|6~`u+f$cHqE}L==!Pi-Qbod&b$6E zFlPPKWv8Fn=g|06Ex}vK9v3$2dHphIkW5Cqv5H8ttUQu6`1nUIozXD+4lri5&!wa$ z9~aw^e307P620qmHnNFTt=aFpqxzk*`c1o}uC}8z>SCj^@{O)tEW}7%&Q6@dP;tc* zLr-Z__l`MR$j7vwJ5STrJNw+VJ1%uaB=gpr7hE*BKXLuqs zHe4Rfy5Xbi>i>EFqqP(_C7a7`6JzlksLwiZvv#94VST$Z8*93_?jfXdQvoJIkjLa+3W3V|;HHTXDw|{WKE1&*H(Wsi6 ze1^8FM_LcL@2_dQk5n{D0+*eOMsM}{X}xVdpw+>3FK;XBGDg>H!AJ|<@0yeM{p|kx zdVw)I*aSwr%X6W<=GT8`jrkY-^L6DBlFjFn{VsTzwY3-?0m);%nuDdx?|m#Dbf~<#k;O}D%o5t-uOh< zeX7d$-aq zst^3%?yoA2$I5XFS=o`UXFWLR@9U6&(VSTPQD^J6&~ojzPjJD&zkSp zv_AVvFl>jCd1Y^Iu6vl&Y^%40l$TZDD-y|BpWM25z*S%CwRg2|{az%Hes1W7k7ILA z$(EKSJIy89FtUBQZApt2t^QdHMpl>6!RT1LXmmVWIj~OSE@w3Uq!^6&UUcxR;daRS zaUBPb?R;Eq(Wn}ITr)uH`l~e)ZaqKz)FzdWqWJ)fNbtmSyShEF?)RNGx1v}wR2h#| zF70~h)}~jMtO8@A4Q4%J1;csE>!pWo?ET#tU)bEd_qu!UPIu{LOut>6U>hdO>_aCm z>~i6x1C9{cbWy!6{?+qzc^bUG>&?H%pMJevp0~k>r`TRQ>(SS?4}Khs>CGxC%f)oE z4mf-F=bgvh_mR*Lq@mJyp+aQ+uwv5{3tDcK$U#N`hMUX2 zLHZ@xyV7c)*3Nr_KltLa9W!sWX<}d`TL1mx_5Ds?mTU^f_-hw#Qa#w{+vn?e^p`=0 zU$A2NB;5n5-mJwS9SL=9^5N(n10FmKZqk=vy?J1ol25(dwO_uls`D^~QQS@ga{!o; zR~je%v1E- zPy11OAF*sF&qacUgEgjKapIthE)I(ikv0dWI=4x}4U9+I26HIf#18sDws!RQ8xHP* zY!aoRo$9+w%VP+3KBjB}9c?i{}DaaqqY65!to%ZlPzJKlJ(ZTy*M zqhO5ZliIEfClZN$W=<@;^LWvl**jSgD~g0l##TN)Y~N#Ff1pgQzMxP{1Rq++R_~Io z3vW2+rhylldehk;s9#s=mP-Uh9it1GaCk1S38X>uqi@ ze;$AA;K$coBHT>dZVg7-!i#4On~?SN1#c*f8X>ngn3J+=6_wn-pRAk7sI3$GuMOsa zV@yJ!LaLA`kxaGruNi!C%t%0Z5d687o*LcVK9fB@kF=C!!gN6?O^SaHPB$b zfAZVVzI6H0#|zH0j}55+_P|sGo&kq%YZV>6~+ad1>`FFcQ^J zKhGJ=upUnjjZOQ284Oi(%u%c|nCTnOAAkGuCnSCo9U%2uFfw{?f9=g5UUT^P`e3@l z4a{c-Gwp%7EgOuuMD`w=w&1c)wFe_gtjc4@GRPIy?u9x|96I97w%?B3_$L^No{;SjgBjQHxEb&6)&FELrk`sM zrXl%ET{GwFpO=659vD;76AWhYu|3ad{MQMq!Kf%LH|uPJ898;^FF)Nqu~0D792y;F zFl(=xShaWW*Q~aHuZkJW&Ffm6d_>pTH$fxX0W-y54s5&pgj3dbh=5Vn%bU_*8j)b@ z`@3Iid*p^6!HC79xBCrd*$EF${%!bg2{63#CzwSB^U>wMym(8q`uBk09Vp4@HG^4m z`HWAlc(~zlg3&Y|foV)WJ^Pldd;O_KuP6*8U9+|s%(?ewpEo->ygL}!(tYEpf4;Tf`Y8j%yNCePsmmU8(@KtkrWxECF8t@fo%{W_fyoE! z^?0)Zz4eCXU}$c=_s4ho-nj1wXpG(lgOQQZAunw>eA$!ADR%AAmq2p?+{E)uYj8<4dS&(;B>|CdZq|*M zhvbv~_JFk0>fGkRr3Gn^yVQ(-7T&zIS=0B#lZ$^NpB`Wukm8}SyJDT@9ny#K0v0L- za@kKVJs>oj49y>ZjW2y=$gT5hWxvXkgW!^1TlYVXx8!sq$;!S=YqaRXm9NaLKX30^ z+1sFzSkGnmvuTZOpb-z7Tj!^(?fN#MPV4hTT8GQtV>1WoDz)+l-mG=nbo1GoLo01w&?%jetdKt>&-R3;}4m7SYk{X}7Ms}+I@h+(=O(%&q zyz_BySyByq6Df-&zT4}<4I?f|-i~%mFVhH212BI)bnwsj-d*q$81WQn)T7U$q#m9e zjJMQY{dPz9K1=D%+b6o_qwK&H6Qs9V+K*O$N;W;|iPizp^zLX{T^I6O2SP$9Ero*ok{R zBQdO*8}zos>fX}oPX#lM_j2ZJ@4hs9&=%b{uavzzw@BdrmQdbd;WS~wSO;^Su> zbJ+V!4}UhtNPwsCiASN4TG-ur?EmBtlW~4%+d^2O8Iz zdNrO)@FAE)7N@r*-fGrj^uR4}W9PBd_EsZxgL>QVed_LpGMl5L7`w;IrAs2fyZNb}iJZ3#msmCk<81GhJ z5z~Fi&|&@RhF+U^Ewy0mdLozx)alo4x_x^8i|^bIM*I$T;O!~A%X3Jjj=0WWFz)fg zCLH6K7b`ND4)ymdIQ*t(#NNmyhwAg@=G||*%(|QMxU8rdKkBlgX8Hwh2?|Sjbve$g z(W@Iq=I<4{&u9l-myci~nldf_yLic{W%oP+M*1x5+S^mOlKZ6H0ZI0j7CQ#^~}QN zu9IJU?ye!9sC-oa;~uRO26Xh87h^8KJKAuaJyue?=)jcJ+Y&u|2>p+DEqKoad$$tr z(Ui}exBH1Xdc06RE*vlV;{Ko4EPB16^qOM5)Pi^X=3U#~5wZ7K2HsMy8l`pYo-Jpc+@uQr(SVlC-?TF z`o?=l>u9jq*PEJOS-;mZ$|HUU&*$#_eddv%*^_zlg-9U%cKPdhwVylVtwU7HQa*k$ z7>PPxp0G7sd;V#%^Ge3f=)gPn_ZT5#@m{u*m*wq4JVumwzJEERsdVi|jEXw0CZ;9SK<_5v+gxf)IE0}S4m-pTd?`OCnf%mLo<1u@s1o^Em5_s z?UYn3v83{e?=HS=g{)S{911PiW0o3?#>VNW=B4b*-x}Zd#0!1Km!4UDu{OEi`GvxV0nnpkFdaBLMdqwRTBf&El zA2xes)jzUcA}d393h#XWrPRvey}5Z~l2yf_;lzD+-FNQgb0a^Vr^=&b>oh_47;Rc^ zO|HIZ4ZWF!qX_LYp7V;uah&-LKoaWWP_rn6Zq<3L!M!zSm=S1`_y0T%NI| z_x=NKje18Yd(vxq$8X*eczD=uesPFLUxMDedJK;p9i3!v-k#{qyT|bIos zG;S|Xp6e6ydDFPof^UzJ>$Y+yt&#C|e)aV^v(gnNqXJgDR?Z#zL-eqm_h}{KtY`~9 zvU!ddyfx~*m%z1eR9<;cWb@9)do<-0SN*Ro(YpozZ}sLKE4#*Ts?RDaD`qW)^Yg}x zS~ugYYg%9jX5W*yc03{n`ky^1&uP>2tZvcui!UhKG*@=uncc#!QOzD($Vpe}+18Fj zUub#Wvfh%9nZqxW!+1Fz;)*%%FK>3?Z?g7o=AJy}P-rE?6C85N$HLEC`OpX5H;g9IzV z$nK1LW_A7bfe(&Rv&T9Z+YCk`hhy7!dj8gKAINODnL+;*Obaj#PkH2mLpS#53C8TZ z-IHCs-m}Nd|Ak~`DzWO-jcacEp_F{2N5@;r(*k1B8$P^ja?6U{vY#SXchCQN-X9&* zzeekNg3kA>Mm+UFN(+HgwSKT5a(io;q@61)FtTAGF)*?gdhDzxkL}PsTk2G# zCZ9=QWCr7kSEtl}_R@`FJF?duy}7&xM*f27c)gyH$vyFbwiB*sDE>g+R-xbKxM;>- zod18`f8O{ovS2tES#zb;_b@b%Z(6#ob>cYfLzImsqTxg-y8iy|{hJ&cnen!wQTceE zje6(|J^Hw==+>uR{P+zSJ4?@m1jiZ7^5gFMxaXke{lLhmfPU@-Ffz)Sd)3mDANr@B z>=HG5SIQ&tk_fcFENC>R*3HE;6%Dq?TRyB}*MTPE)NabdUQjiyI{M1uuXFlWv5VKz z=nv#0GQV<4VY98M%h@;mB_e(!fY zXNK%)q-Bc)@kmudPT{M6bmFFlOXa3r;;;#(-wL zJ=b934NtuLpr?l}0%JVV<6xRW6T5H!PRsj0A`%!6>n-&vXc|H@yy@hb@x_OUCl|ki z4n6=Q?Pvd$iyq&2$(L)u7*DbMW}VN?*&WUr{^NItf#D=x^qu9M&m*_!eExIH+ebGX ze8uz7n4P8nR%&Ss9o-cnFLe*Zi4{OHmq z%^D58an|Lvvk60Jshkl7hC^MaEZV(oN1Ya{+uE3XFo%N~Fnjy<{CR74*_cbg90q2e zVvgX#X``2O`8T)o!DECnO8t=HZ7&z$GlG~8li zJ^|AP%pb#MP3k;n^==y@A9554zV6d@zh6&2WtELN3`{F9-Hv{&d*0vg_O&s+!5j(Z z=BbBXIBC?c$Jm$x!|nV}m*0E!FH>9Cm?>ZmhUUb52NcJrzfojkW`U6?t>w7>*+T-sNMwicbR$6Uuz0w+dwSzsW zZP#~~mDJvKx+hY5zbWW#iQW<{yI;pHD>t07ZR$7UkHmV-IGC^7#G<1jS;yqgf9H^I zt9}PVT!p%;eSxS+_FA|cM4Qh1noyJMcB+@F{xuT&(MP_!5m}wR=FvVplvF zd8b{E{x2vA)S17X!HDnOxuV;jYa#<=7F3)(Evvfsw=ga}hS(eZkN5fUncrG!lHGH@ z&iQM8C9b;Uld|YlojOVUW}>u2xqe4&)vnmKH?|C$Zp+3HDZt(J2+Zvbru6AF^l{?RNpy|ivfctCBZ(__FPi&z^QINh z7>#r@{AA>Qg%Ok+ZTjzf^jEvaT`7N0ZXT>!qw{uqc!+Ko*`L4GfKc@46dJmu&VwM+8 zM^6blXPv%Kmvr5{6SwsFe5Z^)j1Q@PhKkh|rkHPyWj)8>iN880B;tiRWfh4e8%uNa z+;YyToGBCM(?SE2akeG)Zk20)j;y=mb7V=G^5<{G-%aMnZ=Knspsb>UJgfid{U>a1 zIdVyoRe?CCZ4Z6F)ztHf&-~+a(#o3MmEF6%nLVc7uFN-hmk!ornRz|8|JeWdJAbRF z9HaQxp8nmzPq(}6m^TbQe@(0LO$&}2)zOq;w|qE=KfVj!9bvsbotBlajk7~#w5p`6 zBBYHt^WoS^Rx;vcmC1aR5w1vbT0}e&jf4}C4tyJ-q9npgfz?ssW~)CfDRbdMsrkH) zgl}arbR0@?m9Ork9v8;S`D9E&OB^y1Csci8%9TustSdk(y)>DuOdQ{#1M_0+7mkn3 zA>ZMNoLIc1gH6yOe!DM7n@3@D_y7se8tEh3YOCF)B_a?U(eoG6JT`R*B$*yZFV@05nr=^OFV zSRxrI3duuxrM0ZQG8RvU-`AL_FXRD${q;mhTvV>B<)^I@XAZpFjm`SXmnXovQ*QW(s|pTGg&G}O%>7~{0cBa`OJe5@`KTGnr%ROr;K7} zsu^jKLu#Ex=+(D9^fWTlN5xgqs4A0s=OI+d)L~NSDzULdnU5arL?$Oq5mJ4sb*KkUj=PYW2MVCBW;XDs_6&C-dy~+Sk7hFL z5k47D;3AQLKNozw`q-Tm#ayBVnQil@Q=d42Nd2mkD|zK)k08THrGY$k9a2+RlBufX zL~?92;^#FTsbJ%{!;YmEVN@84@*N4)m&B7kicCu@X`Wj=~78p9dN`i^p0K60lggjf0llxq5)68U5k zJ|R?`P>xD**G;`3R_>ESDvgv+`47_9sKLos@T7G7)GWPx%SXv2RH+-g3`!vyK{rZ& zqPs0WbMqo02QL)0$514o_N?=);A34v#{`zp;ic3Wgk_0RAH_OS!NqYGlI#P$h+Ush zJ8D<;2fR(|Z++%Vh>)5qr|rq-0qKg9e$wlY2&z;taonkfr;J$59Yn?uy!q9TBNdDs zcQvya^EE>uHJ7#kKFTMte9=&o$(5?Tz&0yiJ5)6MDmw|HFeev6W3FuJ#BEnbB(P$L zqWjZIABo+P!`AJwk}RkLQQt{}f%-)%2_vwXL{+6kaU4Y=-qc5)^n@@m)^ErsB0B}hZFeat@>FjTMC%A%$ZN(V*p85{wL@5gD|FiX#cX zEqQu}3Y~h?0~OQ8*cPe_B<+ zP$*u(NkSzYZs|Q1NJ~fNY4^Bt=}r7wf!)1`8QT1Ua6u%BopRPsah$M(&)7!B zgpx6$`b3$G!hPhcmX=JaJyUW9nNBq^;AG20AShq;l(JT$In_iGhNQHtq%;cnRefes zn5W1byg9Hk85y>=g^SebbfOZMJahd@5L*m{7Q zeWToSPzRkVC#0!s+eP@~>Tb@kaYxwWIzkXEJ`yUA6;(weMx&7s(=Msz5-lr4^?rf0 z9~mpE{s%4drGKV zQ(8>QFM!ERYUSmi*4YFO@}<;~F_s7xANvWtba0=l#4)WIVz4 zO?(`US244~F=sy2kxB!j)OCoR3bMb!-!G=yBlPJH&<0BrLG4H%KQE%cm&&-f zs-jS;Iz(?<Lrzr;yxqUkyIC`{83CUDz@DmQ%nJf$G9#*7Fdr+eg)mi|B z+PYNs8e}MGvv-PEKK*EB^J>Jwo(Y89zENH-)sd)~LLU`~UsY*D1nZh=4mJ(_6=TTs zg%VjsZ%h+LLvfiZ_sQ2z1PA+u)?ucA=$_S<&OlD-3E`Ihz@9S0{wu1=3w(RFR2ulC zu2qu_s?UsRTIe&L*j!Y^tw$aSCXzy_j#_S#;eu$SlX{g|O|qrN1*HUl@n~7ah=c^V znz?^~tJBz$$dN38Zo?%%IJi8mk&UM4R6yfoqz3^q$sD9vYit9xby>rnoPba15K3F2 z;`sCsB|kxPmk>T)T9u%>8Oz1B(IrO6`VjjRd@MMXMoML+R}NAYNCmQ%01_0apej_r zLXrk+75jFFyDxXG9`f&5lLU`5!{v{KE{r=HT9tr0Ipr!36OAz6%RsVem9 z5-LrWN7ZFKkNJ>DBor7X(;On@<2&D*Q(7RVFwSl1339UK@b8>Kz?cG7eaYzrP#qL{JlLP^w>*Wsv7dr%-1b2iUx`V^&&XrOx!z z*k5EYzz5_|f zE89OR!=o$oDu_HB&6y!QR7>@YpUdeCBJn&Q)mk5uCqwH}^-jvS%A8q|TFA)3Z6srI zpTn++!iYLMC8Y2nmB1&9YH7)<+B3?>KUERoQQ@+vgpf}DvcehFBek$h0O?8dWSA)> z&rKo{YB^F$jif&zQ!APTkr-QE5R3YC!a{CR8D{2AF$$XZm2b{LCO;~@YEH89%ep)( z014ko#XaQpo4QtoR+N;cD8d+;31U7$5s4?qCeUxmib6rm&eZDh=4;`3U6DAxs>1Q0Ez^rMf?p zlh&nn66(3uc4qIX?0-~QWV9i3+)G_(`izIVf)K$9T$8bKsbg_1j!upzvMLmp745Q7 z?Dx=foDK=Z%BAk8RENKPU4 zEXKH}=ZV{IzzdL#n>-g zVQh^Dde+gt*T&-`1T;Dg8KKf3;I>}U5p{+iHp#!7128|WH z+Z~L(k&1WFGFpa{gki=L#ifR^iU|YhP~lUplZ8|8nA+jq;NiG~TK@URIIhaTb9w0r zRE@Oc%GZmjDreAqTZSJNUgD~4&#dGM>c43-iUM-$Qt2tkZ&d3Rto)r_h2j*{ddd=t zL(*3)Gj0uSFyIb|3s)XOcxy# z;JPIz1GmSBFDq>RrkmE8!zk@u&D8`c)if3pC!|;~WLANLRA=zSA!cO44j9-s5L=^v z?>PjLyF@gaj|)Qa3{nd`)1oHLE$tCgO)@I>S1){0k(lO@x>a%nc^2rkdFv=uvv599 z6IVg)E-h9R9?sZN$)Wn~1lw}di$WC<|P)XdQV&UT& zE-RivzQk%fj>uD5Axu3maFywF|C-N;^5K^81TLmu(ni7nPsP|it|dJoEYlyT(grnG zq{tYLXwfnr*2ubTTXqakVdwf>Nf+3o%M6PicG>ZKb&fi|Q5=I>kV6(V(n)7#k28b# zs7B{LR)nPwR!JG;C(@dYT5|=oG_r`n-k2f_{ zG^2j$6@r~|25<7uc!C(6^ea_rKU-C@EE+0H5Xu#@hYaUChIb-I;QY*BQfpoZM3)vt zWJ_aQ(G*44btaqPeZ)^sh%D(3lqQ3`M0(cnNPhs28pMebYo$E7iiY$Z+ed;&DsH+# z4!6guj)N?^dJHAKZBPrfKEO=Q)}_iV$a6bu8CvIUjarDdQG3Be*8pZ~KeFqfsEWPJ zWBiv@Qfc6nx>kE^gNl-mrb1;j%bXBaZ2dOt4CSP$M3y*Hn!k zdi$nEOXO-7oAvf7T?}_FI-PKbk;<%4nFKR)kkv>(sJeI$k9km6N6P zAIv0%%GEv#CRBMF%DjY&NpoKj8O?^c$k-UOpn6M>k99awA-&^Hl{&CKnMsq=1s&N+ zwN`(sYBxxSS^=s&!bPb=-KlY15Oqo|(59}{+ZutC>a{?%BCqz*pxPSA>#P(qb>7md zf)_5;duhy2i@?-th|3z~o3aWBQ`d?zxC^yyOB1I|twuUdGgKI)-P>C^eeBJ4+K}5f zj<;$OkwRV?PG-hmK0G=r*``$-tm7War9C~Vg#fL(D^%4s8TO+TOAt$sMPi!t0BCxkJMNj!~=Y5T~ zrC7F(G}a|_!O^IXFNIZ_Z>>=?id2l^+vt$$3&%TV!&&RK*3Frv^{JAKlrYP9QloA$ zz4MTh15`tH7Rg5j7k7&sE>CMzJ84ZCK56$g3KpDV!@zlKo10I0Y_9;dePf?1;6s~o zhmfYOYgBG?{2FxT$_@#A^0N*slX9{?S{N#>sFCeCPDO##ac6f}VwKW9wu*2v=0@_|9xz4B73II@3<*FSxoUMdZ2QrF6b2Sr*bn}#xV zjV^;1yfRJ|fEv-(6Pb8mLi+e}8Ct0UWA%f%2!N`h;kXBObQ~+nK-8#iCqz$(k{|vl+zLR+qQ_p3Pswm3X zAg*3Vv+sNgX(u92`v#}pP0u%1L}I*DF}9*G5vz(9@+x(>vZ^F12jK7tUV1)#<9n=J zDm_`GuEk?>IAy@P(CApaNDgL#(z;Z=MUY+E!a`}^sGZM|k-sunmd9q;$$^XRwdGIFl*vHkFD> zRK@7^8EnLdQBf?Z201XXE@?Nx(-t^Frlz3M7j~vTd8OomEOlKYJF4E5G0y@QnQc+8 zZYpEqm4sqeuH#Ajy=T5{!5CGY@)!zNF}ZC5ySO-tSb1<{;+7odZjWme2Flhebzq7V)Vii3f?sz`rB0(Ha+3p5 z{MVL6qjEN)P?}3yAwDW~sXth_JXQW)O;+Ytig}1}(Cqw9c1%wQTZ;yJHpd9)B*Lex zBuB#4c!cS)!iXV_Byor%atg!hJT;w6wj;32#}L^y^(EgkDg6g3K!sXoRMe&b@7)UbAWs$6DJC+H9ZE{;3Zg{o4i zxkfSdXc=*xzT=D&b?0LW(n5>_l~Y2MAiV#~W6r@<-KHF*$|?oZ1e}YK4?^O{VmaKF zYdJDiU&*|czG5|~NMFetYWhl!L(*4C);p4DRKLa#i8aZy91dytbfnF~>A9mLJIg zg*`;XlZX+HT4}@af>@l!C#l0#MI7Wt%V!P-QRy-bxmZPcc!YRUw8d_?%F0NERhm#` zStaGDh^P!@X8`lWWpYrJJgDMyTI+P7h>ACr3kh-75OYYD6tdo<-_j8pwx`Kq)Hs+( z;RvOzlpj>`QC2YSemz5mSiV7Hv`q=r|@GxEk=QXPwE<>o9_>X{zz z=tU~)A>n6893LxJRFLc0JF*=qpXK^+VM3WdGv#uaAFtn;;Kj!{eW!ddTVK5DRe~0+ zd?hmtwwlB>awVS1fU74?^<6>Ez+L&UO|K1Aezns}kBA+n8WMA`*r+X?>P+S0Sd^V9 zKK0>6Lf)BNY7_~&3kElLgk7W32<+(2GFP0QkMN!}BE1(sm1R(cNJFI)2c>Pj6D?4tq855v{EWJV--&r(W$2vgS>gb9x=*FhInRhG}R6tC?o zpGsfi7a3$GRfVN%6*AevBY~5D2%VNQTV?JI24g8impF5v#)G)u|d=x4i zF5QMorjKRL*Ppb|0g8Id-y}&L#GJ1`CYHJv9zI%6r9;5fH9`g#&z1pF`$pASkZP3V zm|+>27TdiJ* z2vKHWbwmQkw0o?@j6Wn4*6Z>9_FBpd@^H~h@Y2SEY%P5Wz9zazN$%*2MR3P0mS$?! zO3XnGKI)~=g#=V`Dyem;22Vl4JGetChe60eKlTF+X8$`2IrB0h`i^azL6L z-om)rGkbG~Poab=J;pVx(jQ{S!824!0;6DDtR<8EL@x^j1&Wz^fKTQl)s+Ut3Ko%4 zu7ioXx4U;AOfuVR!T zqr%ihh`~_Quj9{1Ne&rLFiqwJu{Lc(^>(6&&>PMD@WJ+^Ro> zSycqBD>VJ(sxkntTu)hfiuVTvR-Ah&8&AcTwYKV=ItupWS8@S zEyv1NW-5{?%@s$h^o9+6o++=fFbBX}0)7#c=rc1p^0+-lEx|o;N*HTC!dV;EGt?A3 z2bkidisfXZ9w^%g+P-V^kSUWi9%>FnbgX!!5+*oT+ix3hqyisD)C(``QsQYdjmpWL z%-XW~-EYa(P6QMCM$NJ*XUPd<5<2B^0+|TdQQm`yo7W3e5S@cLFHCdDe62XITeL`r>IEIwoJ(PqmnhKLq{@U=eVs=#ARm)wSA)kB4y~j zK*V-~VrJs}^pZ*>GNf*>0O?Qs$Bj%2%YHEVQR#zXM7wICvTxKtEXb2qt9)2gd!~Z) zphah)avF_cVovZ@zrvGHFV}Gq`vT~s>-Yi z$~3o3pQRc(yY8YKTjC>6W>VN^K2iZ*kaQUmz$W8~3hb1fSWB3E8PG>CM=G0H0vfyH zE-SB!^B-qU`0ZeHq=J#-PPz0Tn{liqK<&6wHc%;#?eM5Tw zU$a>Vdr=4~XGKc486=$5CLpyg)i77l$-F>ed6EAnsPYjkRZG3<3)SR zN>Uf9AqGimnVepkB0qgmqeh&fO|20Led@-ZbF&hvR}xCb`fZ}iObSPf%618&LF=i% z8m&}LHdu?PL8ivpXH7WF3rhtI%sGq7r`-@?RGaZov6vX17O65BBu=I53aW@zCh0nY z=fc7T1@XuzDD*{*YSg7rFmQQVBll8mVGv%`o+;Z7a``fPOj~mlMPxp!k=<2W0fkew zXKLm($l=PQ$T*G`g@JXcT3XP^U6zTUQL9C%FcSXn_z$Tw|-i)db^Q-v-bQO7s4fSg+KmveAORYfc02+czF z`Z|WXW>!wl4>QwhjJ0^{KFL9EYN6T%CI8gC@FYy65-Nr7xe@EtZfQkiNr zG|JHhH78407xlo+ zDfv`~BNdz+cdBUz4f9jmG>8$*jtweEA}Xxe-o&9*Zr{r*_Vyj7$svAK#Z2`_3KRBo zc`ekwW6OvCCdbUA6i3t0=7T!{n_7f}RviN%bs6L>Y)V~BX!V8CM39rSsngW}m-Gij z#^(EwKDB9cW8Vxc_`i}OD5#Ni>RfVt0gcgEJ`X_f`G$z^;l^p{$XFA=$4sRed?8;; z!$T1Xh}-fjh|V`XJ$%z2DE>9CA2BF+$dA%WP~CX=OjvkeRPPexQ{23>l+49x*NC4> z2LL;lr*I2C6xS-5-N87DYd`!~-c$8luu;D%AX0V4p|{CGc`=^P%gd*<@q8?l`|VKi zpb!b&dEvxN16JdK1ZST5eT)xjNB}#1p$ZvfUe2x>O6P5jl$6?h2XX4UMmbX?9W6ka zx>kci)!bFw#1M!h$jbbd1=7;ND(${zV@#6?#8!Sa4Gc?4)I0I<7#DVH*6&tlY}xY< z`ZE$EO2!kJSqNTBGY3kT@0Y?Q{Q=W?!P}>;ph53S6s4$r#q`YP&|e?FS6CKeEjA`Z z`l?11KwJ(5|0i|uL}A(%O6bz=YgBQXqEhe3OmQ&xYRWU{v`5WBZ}v8lB8ogsK8u)2i*(RK#RbRDGGf6?Ar6!i$#wlXC z(WBs?K2{UUrsWi|+^ETtpk80o$|84)R&G>RVvW+me7{-k8<$NleiQom<@B`htoBR= zBS9u-1X7};c98^D9NWB8rQlkbNl@Eh3cRqaNT#^V6*@P)s65xZo_un)6T!m1QNAgt z;#fJc_IFvA_E6g=PuVmsJ&F2NVN;Mk?G2{9J_Kdjz49SJvZmfd5^C$Mq#EVTOefY8 zm!@c4%03m-`k7^y{q1~`*7v0@XEGQ$Z&k|<5?>-jS)72@x>PnEq`2y@X{nEfz()Pn z$OBgAA^V`J)V5P~;h1gCm(z_#@Ue9@YP4=DS#==(spRv@74p4#mim&Zlm@Bx&}2T8 z!v>Q{W?6V)EWz7efk7wc5gHz2G87$~%Nm)aoR(zXU$rh(6A038`dUNUMmIVd|0sx) zDWpE-N7X}+hjyv}I_ItO@XChtmz;R}sj`gs5?H1;5A-|hs<~F0M;wzV2U_XlLQ~K5 z)=P6MYe)rt8-~)-AynEur4o(!FG6HwZ1bRedgrVJaY5}P#l=x1Co9KerRyNKYWO*| zX0vlXRaO$%OT~&rlqH;Ak!Dnm?dugq7-bgF-2k4LH! zbd(U8E7b!BO?bJ8rKLAAs`9H4RiDKdXWrcR=xJv%kwu;0r>PLg^z2N4(Br626|>FU zZ(24ZB^)!JD5gQ~J*|`&wJ9Q3DnbYf!I@2Td>__%x|1xOr`^L(mT3H|in_Oq)w-jVZ#wT&WRrjSFNOHyrF6)sY1;u^Sn*_Ki5=9(t>2O&Xsw@`sP}7IXd= znlO}U;|rT%+G82I1v|g$Lmrv&=go{TuOpA;`&V$!c%ru=2U%vE{baIKti72IaN*_l z*q&BS!^)ZCe(k7w;!OUE169>sdpo?BeArnc0pI!jv~=)HyJtE~+$UX%dUugo6H_K1 zJJ6Ea(h+^h8H?~R$G+$s@=ZEZFisO0%~&!VFYnqp14Y2Xhsoq)chzywbjs5h&XFBe zm_f=Avucb%msC{wV9To#(G0XB-%uo6P?mvC@eaXR@TJ9`DTKRg5-opOm_m4p&%5!3 zqq=sMY+N&_jE#=OD+{}J=8bQ!G>J|KCJxjdWsuP%ZT1<6Jz{P<4cjHyIk;i}kJ(__+VQyMl0xoLCYk=x z&LI=CN9YrGxW(0+i)#Fp9K>@t1+!R$6%KWo+DZzW-$V$(PLZvUHlG(Kzlw0~i+Oj|uL?MxJ&AwnDqro6` zr5%=y@M-roDz_{;>uI8-%$quI(LwP1v}|L-OyrvokUQ>FlwX6-ol20dmXVnQ)Yr%z zoG+WX z45yjeoEXb67&)m;W(zE1QTL3NJM)i`E=XRrV3{r?A1Khrin&WIcEU8G|DHRYfb8^z zthE?ln?F2WR#Xxh9f?LA$G%DjMKA!{)ETxRQ_((Js-Bn}t3RZa?0rz4-h7@+j<)jQ z>qrGd#~lV@re=MpQ)PiRbxoaN3aXWh0@=$Q_uI*;@X)JYnNADdpyjUEbcKinYQ@o+ zn3vM!MYT#6x1EpKX`l}i7KBf6J!!}~jR)00IHRH>CynJts^VqN$GRHj_%b=A!f*ej z4^^u4DL&L7p+evqhQml5^1;q=M~@k73{K%3iiVTpPHCo^V&n^!>MYzEDnFH8N^Q|n zUJ0lCmsk?zYAsNHNFRKzhMTicnO659s&iAmN}p&e--7g=HN*?V%2g`SwtZKle&6v4 TLXpS*JRuIqs3GhBPyhZu?;eO5 delta 833 zcmbP!ly{-f1U;>Vmd3({_j(sTU6-`vlr(C9S{TB%E<7K{~wTMW|(fJ!x%QX<1()U ze~)Xvh3>m~D{l17d-bO>dIoyN;MfJnI4nkiGNyXQAQ@PifWVU;Q6Jwo$o|&Ej1A`VjPznfu^mO}eO!nLnP=QVkhUwhfnUv)mN-7Idi}jQ9 z^Gb>mi%WEq^KK#>F{fz2c>22fa2Z{&tMp3JtBUhWi;`1ya}x`u rFFed7GyO~~Gw1Y)?94{f{lb{pHBn6l8G&MzF4*57bEYS5W}E>4@pbqz diff --git a/dist/SoM.js b/dist/SoM.js deleted file mode 100644 index bb90555..0000000 --- a/dist/SoM.js +++ /dev/null @@ -1,1050 +0,0 @@ -// src/constants.ts -var 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"]' -]; -var 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"]' -]; -var VISIBILITY_RATIO = 0.6; -var MAX_COVER_RATIO = 0.8; -var ELEMENT_BATCH_SIZE = 10; -var SURROUNDING_RADIUS = 200; -var MAX_LUMINANCE = 0.7; -var MIN_LUMINANCE = 0.25; -var MIN_SATURATION = 0.3; - -// src/domain/Filter.ts -class Filter { -} - -// src/filters/visibility/quad.ts -class Rectangle { - x; - y; - width; - height; - element; - constructor(x, y, width, height, element = null) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - this.element = element; - } - contains(rect) { - return rect.x >= this.x && rect.x + rect.width <= this.x + this.width && rect.y >= this.y && rect.y + rect.height <= this.y + this.height; - } - intersects(rect) { - return !(rect.x > this.x + this.width || rect.x + rect.width < this.x || rect.y > this.y + this.height || rect.y + rect.height < this.y); - } -} - -class QuadTree { - boundary; - capacity; - elements; - divided; - northeast; - northwest; - southeast; - southwest; - constructor(boundary, capacity) { - this.boundary = boundary; - this.capacity = capacity; - this.elements = []; - this.divided = false; - this.northeast = null; - this.northwest = null; - this.southeast = null; - this.southwest = null; - } - subdivide() { - const x = this.boundary.x; - const y = this.boundary.y; - const w = this.boundary.width / 2; - const h = this.boundary.height / 2; - const ne = new Rectangle(x + w, y, w, h); - const nw = new Rectangle(x, y, w, h); - const se = new Rectangle(x + w, y + h, w, h); - const sw = new Rectangle(x, y + h, w, h); - this.northeast = new QuadTree(ne, this.capacity); - this.northwest = new QuadTree(nw, this.capacity); - this.southeast = new QuadTree(se, this.capacity); - this.southwest = new QuadTree(sw, this.capacity); - this.divided = true; - } - insert(element) { - if (!this.boundary.intersects(element)) { - return false; - } - if (this.elements.length < this.capacity && !this.divided) { - this.elements.push(element); - return true; - } else { - if (!this.divided) { - this.subdivide(); - } - let inserted = false; - if (this.northeast.boundary.intersects(element)) { - inserted = this.northeast.insert(element) || inserted; - } - if (this.northwest.boundary.intersects(element)) { - inserted = this.northwest.insert(element) || inserted; - } - if (this.southeast.boundary.intersects(element)) { - inserted = this.southeast.insert(element) || inserted; - } - if (this.southwest.boundary.intersects(element)) { - inserted = this.southwest.insert(element) || inserted; - } - return inserted; - } - } - query(range, found = []) { - if (!this.boundary.intersects(range)) { - return found; - } - for (let element of this.elements) { - if (range.intersects(element)) { - found.push(element); - } - } - if (this.divided) { - this.northwest.query(range, found); - this.northeast.query(range, found); - this.southwest.query(range, found); - this.southeast.query(range, found); - } - return found.filter((el, i, arr) => arr.indexOf(el) === i); - } -} - -// src/filters/visibility/utils.ts -function isAbove(element, referenceElement) { - function getEffectiveZIndex(element2) { - while (element2) { - const zIndex = window.getComputedStyle(element2).zIndex; - if (zIndex !== "auto") { - const zIndexValue = parseInt(zIndex, 10); - return isNaN(zIndexValue) ? 0 : zIndexValue; - } - element2 = element2.parentElement; - } - return 0; - } - const elementZIndex = getEffectiveZIndex(element); - const referenceElementZIndex = getEffectiveZIndex(referenceElement); - const elementPosition = element.compareDocumentPosition(referenceElement); - if (elementPosition & Node.DOCUMENT_POSITION_CONTAINS || elementPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return false; - } - if (elementZIndex !== referenceElementZIndex) { - return elementZIndex < referenceElementZIndex; - } - return !!(elementPosition & Node.DOCUMENT_POSITION_PRECEDING); -} -function isVisible(element) { - if (element.offsetWidth === 0 && element.offsetHeight === 0) { - return false; - } - const rect = element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return false; - } - const style = window.getComputedStyle(element); - if (style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none") { - return false; - } - let parent = element.parentElement; - while (parent !== null) { - const parentStyle = window.getComputedStyle(parent); - if (parentStyle.display === "none" || parentStyle.visibility === "hidden" || parentStyle.pointerEvents === "none") { - return false; - } - parent = parent.parentElement; - } - return true; -} - -// src/filters/visibility/canvas.ts -class VisibilityCanvas { - element; - canvas; - ctx; - rect; - visibleRect; - constructor(element) { - this.element = element; - this.element = element; - this.rect = this.element.getBoundingClientRect(); - this.canvas = new OffscreenCanvas(this.rect.width, this.rect.height); - this.ctx = this.canvas.getContext("2d", { - willReadFrequently: true - }); - this.ctx.imageSmoothingEnabled = false; - this.visibleRect = { - top: Math.max(0, this.rect.top), - left: Math.max(0, this.rect.left), - bottom: Math.min(window.innerHeight, this.rect.bottom), - right: Math.min(window.innerWidth, this.rect.right), - width: this.rect.width, - height: this.rect.height - }; - this.visibleRect.width = this.visibleRect.right - this.visibleRect.left; - this.visibleRect.height = this.visibleRect.bottom - this.visibleRect.top; - } - async eval(qt) { - this.ctx.fillStyle = "black"; - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - this.drawElement(this.element, "white"); - const canvasVisRect = { - top: this.visibleRect.top - this.rect.top, - bottom: this.visibleRect.bottom - this.rect.top, - left: this.visibleRect.left - this.rect.left, - right: this.visibleRect.right - this.rect.left, - width: this.canvas.width, - height: this.canvas.height - }; - const totalPixels = await this.countVisiblePixels(canvasVisRect); - if (totalPixels === 0) - return 0; - const elements = this.getIntersectingElements(qt); - for (const el of elements) { - this.drawElement(el, "black"); - } - const visiblePixels = await this.countVisiblePixels(canvasVisRect); - return visiblePixels / totalPixels; - } - getIntersectingElements(qt) { - const range = new Rectangle(this.rect.left, this.rect.right, this.rect.width, this.rect.height, this.element); - const candidates = qt.query(range); - return candidates.map((candidate) => candidate.element).filter((el) => el != this.element && isAbove(this.element, el) && isVisible(el)); - } - async countVisiblePixels(visibleRect) { - const imageData = this.ctx.getImageData(visibleRect.left, visibleRect.top, visibleRect.width, visibleRect.height); - let visiblePixels = 0; - for (let i = 0;i < imageData.data.length; i += 4) { - const isWhite = imageData.data[i + 1] === 255; - if (isWhite) { - visiblePixels++; - } - } - return visiblePixels; - } - drawElement(element, color = "black") { - const rect = element.getBoundingClientRect(); - const styles = window.getComputedStyle(element); - const radius = styles.borderRadius?.split(" ").map((r) => parseFloat(r)); - const clipPath = styles.clipPath; - const offsetRect = { - top: rect.top - this.rect.top, - bottom: rect.bottom - this.rect.top, - left: rect.left - this.rect.left, - right: rect.right - this.rect.left, - width: rect.width, - height: rect.height - }; - offsetRect.width = offsetRect.right - offsetRect.left; - offsetRect.height = offsetRect.bottom - offsetRect.top; - this.ctx.fillStyle = color; - if (clipPath && clipPath !== "none") { - const clips = clipPath.split(/,| /); - clips.forEach((clip) => { - const kind = clip.trim().match(/^([a-z]+)\((.*)\)$/); - if (!kind) { - return; - } - switch (kind[0]) { - case "polygon": - const path = this.pathFromPolygon(clip, rect); - this.ctx.fill(path); - break; - default: - console.log("Unknown clip path kind: " + kind); - } - }); - } else if (radius) { - const path = new Path2D; - if (radius.length === 1) - radius[1] = radius[0]; - if (radius.length === 2) - radius[2] = radius[0]; - if (radius.length === 3) - radius[3] = radius[1]; - path.moveTo(offsetRect.left + radius[0], offsetRect.top); - path.arcTo(offsetRect.right, offsetRect.top, offsetRect.right, offsetRect.bottom, radius[1]); - path.arcTo(offsetRect.right, offsetRect.bottom, offsetRect.left, offsetRect.bottom, radius[2]); - path.arcTo(offsetRect.left, offsetRect.bottom, offsetRect.left, offsetRect.top, radius[3]); - path.arcTo(offsetRect.left, offsetRect.top, offsetRect.right, offsetRect.top, radius[0]); - path.closePath(); - this.ctx.fill(path); - } else { - this.ctx.fillRect(offsetRect.left, offsetRect.top, offsetRect.width, offsetRect.height); - } - } - pathFromPolygon(polygon, rect) { - if (!polygon || !polygon.match(/^polygon\((.*)\)$/)) { - throw new Error("Invalid polygon format: " + polygon); - } - const path = new Path2D; - const points = polygon.match(/\d+(\.\d+)?%/g); - if (points && points.length >= 2) { - const startX = parseFloat(points[0]); - const startY = parseFloat(points[1]); - path.moveTo(startX * rect.width / 100, startY * rect.height / 100); - for (let i = 2;i < points.length; i += 2) { - const x = parseFloat(points[i]); - const y = parseFloat(points[i + 1]); - path.lineTo(x * rect.width / 100, y * rect.height / 100); - } - path.closePath(); - } - return path; - } -} - -// src/filters/visibility/index.ts -class VisibilityFilter extends Filter { - constructor() { - super(...arguments); - } - qt; - async apply(elements) { - this.qt = this.mapQuadTree(); - const results = await Promise.all([ - this.applyScoped(elements.fixed), - this.applyScoped(elements.unknown) - ]); - return { - fixed: results[0], - unknown: results[1] - }; - } - async applyScoped(elements) { - const results = await Promise.all(Array.from({ - length: Math.ceil(elements.length / ELEMENT_BATCH_SIZE) - }).map(async (_, i) => { - const batch = elements.slice(i * ELEMENT_BATCH_SIZE, (i + 1) * ELEMENT_BATCH_SIZE).filter((el) => isVisible(el)); - const visibleElements = []; - for (const element of batch) { - const isVisible2 = await this.isDeepVisible(element); - if (isVisible2) { - visibleElements.push(element); - } - } - return visibleElements; - })); - return results.flat(); - } - mapQuadTree() { - const boundary = new Rectangle(0, 0, window.innerWidth, window.innerHeight); - const qt = new QuadTree(boundary, 4); - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { - acceptNode: (node) => { - const element = node; - if (isVisible(element)) { - return NodeFilter.FILTER_ACCEPT; - } - return NodeFilter.FILTER_REJECT; - } - }); - let currentNode = walker.currentNode; - while (currentNode) { - const element = currentNode; - const rect = element.getBoundingClientRect(); - qt.insert(new Rectangle(rect.left, rect.top, rect.width, rect.height, element)); - currentNode = walker.nextNode(); - } - return qt; - } - async isDeepVisible(element) { - return new Promise((resolve) => { - const observer = new IntersectionObserver(async (entries) => { - const entry = entries[0]; - observer.disconnect(); - if (entry.intersectionRatio < VISIBILITY_RATIO) { - resolve(false); - return; - } - const rect = element.getBoundingClientRect(); - if (rect.width >= window.innerWidth * MAX_COVER_RATIO || rect.height >= window.innerHeight * MAX_COVER_RATIO) { - resolve(false); - return; - } - const canvas2 = new VisibilityCanvas(element); - const visibleAreaRatio = await canvas2.eval(this.qt); - resolve(visibleAreaRatio >= VISIBILITY_RATIO); - }); - observer.observe(element); - }); - } -} -var visibility_default = VisibilityFilter; -// src/filters/nesting.ts -var SIZE_THRESHOLD = 0.9; -var QUANTITY_THRESHOLD = 3; -var PRIORITY_SELECTOR = ["a", "button", "input", "select", "textarea"]; - -class NestingFilter extends Filter { - constructor() { - super(...arguments); - } - async apply(elements) { - const fullElements = elements.fixed.concat(elements.unknown); - const { top, others } = this.getTopLevelElements(fullElements); - const results = await Promise.all(top.map(async (topElement) => this.compareTopWithChildren(topElement, others))); - return { - fixed: elements.fixed, - unknown: results.flat().filter((el) => elements.fixed.indexOf(el) === -1) - }; - } - async compareTopWithChildren(top, children) { - if (PRIORITY_SELECTOR.some((selector) => top.matches(selector))) { - return [top]; - } - const branches = this.getBranches(top, children); - const rect = top.getBoundingClientRect(); - if (branches.length <= 1) { - return [top]; - } - const results = await Promise.all(branches.map(async (branch) => { - const firstHitRect = branch.top.getBoundingClientRect(); - if (firstHitRect.width / rect.width < SIZE_THRESHOLD && firstHitRect.height / rect.height < SIZE_THRESHOLD) { - return []; - } - if (branch.children.length === 0) { - return [branch.top]; - } - return this.compareTopWithChildren(branch.top, branch.children); - })); - const total = results.flat(); - if (total.length > QUANTITY_THRESHOLD) { - return total; - } - return [top, ...total]; - } - getBranches(element, elements) { - const firstHits = this.getFirstHitChildren(element, elements); - return firstHits.map((firstHit) => { - const children = elements.filter((child) => !firstHits.includes(child) && firstHit.contains(child)); - return { top: firstHit, children }; - }); - } - getFirstHitChildren(element, elements) { - const directChildren = element.querySelectorAll(":scope > *"); - const clickableDirectChildren = Array.from(directChildren).filter((child) => elements.includes(child)); - if (clickableDirectChildren.length > 0) { - return clickableDirectChildren; - } - return Array.from(directChildren).flatMap((child) => this.getFirstHitChildren(child, elements)); - } - getTopLevelElements(elements) { - const topLevelElements = [], nonTopLevelElements = []; - for (const element of elements) { - if (!elements.some((otherElement) => otherElement !== element && otherElement.contains(element))) { - topLevelElements.push(element); - } else { - nonTopLevelElements.push(element); - } - } - return { top: topLevelElements, others: nonTopLevelElements }; - } -} -var nesting_default = NestingFilter; -// src/loader.ts -class Loader { - filters = { - visibility: new visibility_default, - nesting: new nesting_default - }; - async loadElements() { - const selector = SELECTORS.join(","); - let fixedElements = Array.from(document.querySelectorAll(selector)); - const shadowRoots = this.shadowRoots(); - for (let i = 0;i < shadowRoots.length; i++) { - fixedElements = fixedElements.concat(Array.from(shadowRoots[i].querySelectorAll(selector))); - } - let unknownElements = []; - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { - acceptNode() { - return NodeFilter.FILTER_ACCEPT; - } - }); - let node; - while (node = walker.nextNode()) { - const el = node; - if (!el.matches(selector) && window.getComputedStyle(el).cursor === "pointer") { - unknownElements.push(el); - } - } - unknownElements = Array.from(unknownElements).filter((element, index, self) => self.indexOf(element) === index).filter((element) => !element.closest("svg") && !fixedElements.some((el) => el.contains(element))); - let interactive = { - fixed: fixedElements, - unknown: unknownElements - }; - console.groupCollapsed("Elements"); - console.log("Before filters", interactive); - interactive = await this.filters.visibility.apply(interactive); - console.log("After visibility filter", interactive); - interactive = await this.filters.nesting.apply(interactive); - console.log("After nesting filter", interactive); - console.groupEnd(); - return interactive.fixed.concat(interactive.unknown); - } - shadowRoots() { - const shadowRoots = []; - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { - acceptNode(node2) { - return NodeFilter.FILTER_ACCEPT; - } - }); - let node; - while (node = walker.nextNode()) { - if (node && node.shadowRoot) { - shadowRoots.push(node.shadowRoot); - } - } - return shadowRoots; - } -} - -// src/ui/colors.ts -class UIColors { - contrastColor(element, surroundingColors) { - const style = window.getComputedStyle(element); - const bgColor = Color.fromCSS(style.backgroundColor); - return this.getBestContrastColor([bgColor, ...surroundingColors]); - } - getBestContrastColor(colors) { - const complimentaryColors = colors.filter((color2) => color2.a > 0).map((color2) => color2.complimentary()); - let color; - if (complimentaryColors.length === 0) { - color = new Color(Math.floor(Math.random() * 255), Math.floor(Math.random() * 255), Math.floor(Math.random() * 255)); - } else { - color = this.getAverageColor(complimentaryColors); - } - if (color.luminance() > MAX_LUMINANCE) { - color = color.withLuminance(MAX_LUMINANCE); - } else if (color.luminance() < MIN_LUMINANCE) { - color = color.withLuminance(MIN_LUMINANCE); - } - if (color.saturation() < MIN_SATURATION) { - color = color.withSaturation(MIN_SATURATION); - } - return color; - } - getAverageColor(colors) { - const r = colors.reduce((acc, color) => acc + color.r, 0) / colors.length; - const g = colors.reduce((acc, color) => acc + color.g, 0) / colors.length; - const b = colors.reduce((acc, color) => acc + color.b, 0) / colors.length; - return new Color(r, g, b); - } -} - -class Color { - r; - g; - b; - a; - constructor(r, g, b, a = 255) { - this.r = r; - this.g = g; - this.b = b; - this.a = a; - if (r < 0 || r > 255) { - throw new Error(`Invalid red value: ${r}`); - } - if (g < 0 || g > 255) { - throw new Error(`Invalid green value: ${g}`); - } - if (b < 0 || b > 255) { - throw new Error(`Invalid blue value: ${b}`); - } - if (a < 0 || a > 255) { - throw new Error(`Invalid alpha value: ${a}`); - } - this.r = Math.round(r); - this.g = Math.round(g); - this.b = Math.round(b); - this.a = Math.round(a); - } - static fromCSS(css) { - if (css.startsWith("#")) { - return Color.fromHex(css); - } - if (css.startsWith("rgb")) { - const rgb = css.replace(/rgba?\(/, "").replace(")", "").split(",").map((c) => parseInt(c.trim())); - return new Color(...rgb); - } - if (css.startsWith("hsl")) { - const hsl = css.replace(/hsla?\(/, "").replace(")", "").split(",").map((c) => parseFloat(c.trim())); - return Color.fromHSL({ h: hsl[0], s: hsl[1], l: hsl[2] }); - } - const hex = NamedColors[css.toLowerCase()]; - if (hex) { - return Color.fromHex(hex); - } - throw new Error(`Unknown color format: ${css}`); - } - static fromHex(hex) { - hex = hex.replace("#", ""); - if (hex.length === 3) { - hex = hex.split("").map((char) => char + char).join(""); - } - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - if (hex.length === 8) { - const a = parseInt(hex.substring(6, 8), 16); - return new Color(r, g, b, a); - } - return new Color(r, g, b); - } - static fromHSL(hsl) { - const h = hsl.h; - const s = hsl.s; - const l = hsl.l; - let r, g, b; - if (s === 0) { - r = g = b = l; - } else { - const hue2rgb = (p2, q2, t) => { - if (t < 0) - t += 1; - if (t > 1) - t -= 1; - if (t < 0.16666666666666666) - return p2 + (q2 - p2) * 6 * t; - if (t < 0.5) - return q2; - if (t < 0.6666666666666666) - return p2 + (q2 - p2) * (0.6666666666666666 - t) * 6; - return p2; - }; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - r = hue2rgb(p, q, h + 0.3333333333333333); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 0.3333333333333333); - } - return new Color(r * 255, g * 255, b * 255); - } - luminance() { - const r = this.r / 255; - const g = this.g / 255; - const b = this.b / 255; - const a = [r, g, b].map((c) => { - if (c <= 0.03928) { - return c / 12.92; - } - return Math.pow((c + 0.055) / 1.055, 2.4); - }); - return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; - } - withLuminance(luminance) { - const l = this.luminance(); - const ratio = luminance / l; - const r = Math.min(255, this.r * ratio); - const g = Math.min(255, this.g * ratio); - const b = Math.min(255, this.b * ratio); - return new Color(r, g, b, this.a); - } - saturation() { - return this.toHsl().s; - } - withSaturation(saturation) { - const hsl = this.toHsl(); - hsl.s = saturation; - return Color.fromHSL(hsl); - } - contrast(color) { - const l1 = this.luminance(); - const l2 = color.luminance(); - return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); - } - complimentary() { - const hsl = this.toHsl(); - hsl.h = (hsl.h + 0.5) % 1; - return Color.fromHSL(hsl); - } - toHex() { - const r = this.r.toString(16).padStart(2, "0"); - const g = this.g.toString(16).padStart(2, "0"); - const b = this.b.toString(16).padStart(2, "0"); - if (this.a < 255) { - const a = this.a.toString(16).padStart(2, "0"); - return `#${r}${g}${b}${a}`; - } - return `#${r}${g}${b}`; - } - toHsl() { - const r = this.r / 255; - const g = this.g / 255; - const b = this.b / 255; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - let h = (max + min) / 2; - let s = (max + min) / 2; - let l = (max + min) / 2; - if (max === min) { - h = s = 0; - } else { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: - h = (g - b) / d + (g < b ? 6 : 0); - break; - case g: - h = (b - r) / d + 2; - break; - case b: - h = (r - g) / d + 4; - break; - } - h /= 6; - } - return { h, s, l, a: this.a / 255 }; - } - toString() { - return this.toHex(); - } -} -var NamedColors = { - aliceblue: "#f0f8ff", - antiquewhite: "#faebd7", - aqua: "#00ffff", - aquamarine: "#7fffd4", - azure: "#f0ffff", - beige: "#f5f5dc", - bisque: "#ffe4c4", - black: "#000000", - blanchedalmond: "#ffebcd", - blue: "#0000ff", - blueviolet: "#8a2be2", - brown: "#a52a2a", - burlywood: "#deb887", - cadetblue: "#5f9ea0", - chartreuse: "#7fff00", - chocolate: "#d2691e", - coral: "#ff7f50", - cornflowerblue: "#6495ed", - cornsilk: "#fff8dc", - crimson: "#dc143c", - cyan: "#00ffff", - darkblue: "#00008b", - darkcyan: "#008b8b", - darkgoldenrod: "#b8860b", - darkgray: "#a9a9a9", - darkgreen: "#006400", - darkkhaki: "#bdb76b", - darkmagenta: "#8b008b", - darkolivegreen: "#556b2f", - darkorange: "#ff8c00", - darkorchid: "#9932cc", - darkred: "#8b0000", - darksalmon: "#e9967a", - darkseagreen: "#8fbc8f", - darkslateblue: "#483d8b", - darkslategray: "#2f4f4f", - darkturquoise: "#00ced1", - darkviolet: "#9400d3", - deeppink: "#ff1493", - deepskyblue: "#00bfff", - dimgray: "#696969", - dodgerblue: "#1e90ff", - firebrick: "#b22222", - floralwhite: "#fffaf0", - forestgreen: "#228b22", - fuchsia: "#ff00ff", - gainsboro: "#dcdcdc", - ghostwhite: "#f8f8ff", - gold: "#ffd700", - goldenrod: "#daa520", - gray: "#808080", - green: "#008000", - greenyellow: "#adff2f", - honeydew: "#f0fff0", - hotpink: "#ff69b4", - "indianred ": "#cd5c5c", - indigo: "#4b0082", - ivory: "#fffff0", - khaki: "#f0e68c", - lavender: "#e6e6fa", - lavenderblush: "#fff0f5", - lawngreen: "#7cfc00", - lemonchiffon: "#fffacd", - lightblue: "#add8e6", - lightcoral: "#f08080", - lightcyan: "#e0ffff", - lightgoldenrodyellow: "#fafad2", - lightgrey: "#d3d3d3", - lightgreen: "#90ee90", - lightpink: "#ffb6c1", - lightsalmon: "#ffa07a", - lightseagreen: "#20b2aa", - lightskyblue: "#87cefa", - lightslategray: "#778899", - lightsteelblue: "#b0c4de", - lightyellow: "#ffffe0", - lime: "#00ff00", - limegreen: "#32cd32", - linen: "#faf0e6", - magenta: "#ff00ff", - maroon: "#800000", - mediumaquamarine: "#66cdaa", - mediumblue: "#0000cd", - mediumorchid: "#ba55d3", - mediumpurple: "#9370d8", - mediumseagreen: "#3cb371", - mediumslateblue: "#7b68ee", - mediumspringgreen: "#00fa9a", - mediumturquoise: "#48d1cc", - mediumvioletred: "#c71585", - midnightblue: "#191970", - mintcream: "#f5fffa", - mistyrose: "#ffe4e1", - moccasin: "#ffe4b5", - navajowhite: "#ffdead", - navy: "#000080", - oldlace: "#fdf5e6", - olive: "#808000", - olivedrab: "#6b8e23", - orange: "#ffa500", - orangered: "#ff4500", - orchid: "#da70d6", - palegoldenrod: "#eee8aa", - palegreen: "#98fb98", - paleturquoise: "#afeeee", - palevioletred: "#d87093", - papayawhip: "#ffefd5", - peachpuff: "#ffdab9", - peru: "#cd853f", - pink: "#ffc0cb", - plum: "#dda0dd", - powderblue: "#b0e0e6", - purple: "#800080", - rebeccapurple: "#663399", - red: "#ff0000", - rosybrown: "#bc8f8f", - royalblue: "#4169e1", - saddlebrown: "#8b4513", - salmon: "#fa8072", - sandybrown: "#f4a460", - seagreen: "#2e8b57", - seashell: "#fff5ee", - sienna: "#a0522d", - silver: "#c0c0c0", - skyblue: "#87ceeb", - slateblue: "#6a5acd", - slategray: "#708090", - snow: "#fffafa", - springgreen: "#00ff7f", - steelblue: "#4682b4", - tan: "#d2b48c", - teal: "#008080", - thistle: "#d8bfd8", - tomato: "#ff6347", - turquoise: "#40e0d0", - violet: "#ee82ee", - wheat: "#f5deb3", - white: "#ffffff", - whitesmoke: "#f5f5f5", - yellow: "#ffff00", - yellowgreen: "#9acd32" -}; - -// src/ui.ts -class UI { - colors = new UIColors; - display(elements) { - const labels = []; - const boundingBoxes = []; - const rawBoxes = []; - for (let i = 0;i < elements.length; i++) { - const element = elements[i]; - const rect = element.getBoundingClientRect(); - const div = document.createElement("div"); - div.style.left = `${rect.left}px`; - div.style.top = `${rect.top}px`; - div.style.width = `${rect.width}px`; - div.style.height = `${rect.height}px`; - div.classList.add("SoM"); - if (element.isContentEditable || EDITABLE_SELECTORS.some((selector) => element.matches(selector))) { - div.classList.add("editable"); - } - const surroundingColors = boundingBoxes.filter((box) => { - const distances = [ - Math.sqrt(Math.pow(rect.left - box.left, 2) + Math.pow(rect.top - box.top, 2)), - Math.sqrt(Math.pow(rect.right - box.right, 2) + Math.pow(rect.top - box.top, 2)), - Math.sqrt(Math.pow(rect.left - box.left, 2) + Math.pow(rect.bottom - box.bottom, 2)), - Math.sqrt(Math.pow(rect.right - box.right, 2) + Math.pow(rect.bottom - box.bottom, 2)) - ]; - return distances.some((distance) => distance < SURROUNDING_RADIUS); - }).map((box) => box.color); - const color = this.colors.contrastColor(element, surroundingColors); - div.style.setProperty("--SoM-color", `${color.r}, ${color.g}, ${color.b}`); - document.body.appendChild(div); - boundingBoxes.push({ - top: rect.top, - bottom: rect.bottom, - left: rect.left, - right: rect.right, - width: rect.width, - height: rect.height, - color - }); - rawBoxes.push(div); - } - for (let i = 0;i < elements.length; i++) { - const element = elements[i]; - const box = boundingBoxes[i]; - const label = document.createElement("label"); - label.textContent = `${i}`; - label.style.color = this.getColorByLuminance(box.color); - rawBoxes[i].appendChild(label); - const labelRect = label.getBoundingClientRect(); - const gridSize = 10; - const positions = []; - for (let i2 = 0;i2 <= gridSize; i2++) { - positions.push({ - top: box.top - labelRect.height, - left: box.left + box.width / gridSize * i2 - labelRect.width / 2 - }); - positions.push({ - top: box.bottom, - left: box.left + box.width / gridSize * i2 - labelRect.width / 2 - }); - positions.push({ - top: box.top + box.height / gridSize * i2 - labelRect.height / 2, - left: box.left - labelRect.width - }); - positions.push({ - top: box.top + box.height / gridSize * i2 - labelRect.height / 2, - left: box.right - }); - } - const scores = positions.map((position) => { - let score = 0; - if (position.top < 0 || position.top + labelRect.height > window.innerHeight || position.left < 0 || position.left + labelRect.width > window.innerWidth) { - score += Infinity; - } else { - labels.concat(boundingBoxes).forEach((existing) => { - if (existing.top <= box.top && existing.bottom >= box.bottom && existing.left <= box.left && existing.right >= box.right) { - return; - } - const overlapWidth = Math.max(0, Math.min(position.left + labelRect.width, existing.left + existing.width) - Math.max(position.left, existing.left)); - const overlapHeight = Math.max(0, Math.min(position.top + labelRect.height, existing.top + existing.height) - Math.max(position.top, existing.top)); - score += overlapWidth * overlapHeight; - }); - } - return score; - }); - const bestPosition = positions[scores.indexOf(Math.min(...scores))]; - label.style.top = `${bestPosition.top - box.top}px`; - label.style.left = `${bestPosition.left - box.left}px`; - labels.push({ - top: bestPosition.top, - left: bestPosition.left, - right: bestPosition.left + labelRect.width, - bottom: bestPosition.top + labelRect.height, - width: labelRect.width, - height: labelRect.height - }); - element.setAttribute("data-SoM", `${i}`); - } - } - getColorByLuminance(color) { - return color.luminance() > 0.5 ? "black" : "white"; - } -} - -// src/style.css -var style_default = ".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.35)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.35) 10px,rgba(var(--SoM-color),.35) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:'Courier New',Courier,monospace;background-color:rgba(var(--SoM-color),.7)}"; - -// src/main.ts -class SoM { - loader = new Loader; - ui = new UI; - async display() { - this.log("Displaying..."); - const startTime = performance.now(); - const elements = await this.loader.loadElements(); - this.clear(); - this.ui.display(elements); - this.log("Done!", `Took ${performance.now() - startTime}ms to display ${elements.length} elements.`); - } - clear() { - document.querySelectorAll(".SoM").forEach((element) => { - element.remove(); - }); - document.querySelectorAll("[data-som]").forEach((element) => { - element.removeAttribute("data-som"); - }); - } - hide() { - document.querySelectorAll(".SoM").forEach((element) => element.style.display = "none"); - } - show() { - document.querySelectorAll(".SoM").forEach((element) => element.style.display = "block"); - } - resolve(id) { - return document.querySelector(`[data-som="${id}"]`); - } - log(...args) { - console.log("%cSoM", "color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;", ...args); - } -} -if (!document.getElementById("SoM-styles")) { - const styleElement = document.createElement("style"); - styleElement.id = "SoM-styles"; - styleElement.innerHTML = style_default; - const interval = setInterval(() => { - if (document.head) { - clearInterval(interval); - document.head.appendChild(styleElement); - } - }, 100); -} -window.SoM = new SoM; -window.SoM.log("Ready!"); diff --git a/dist/SoM.min.js b/dist/SoM.min.js deleted file mode 100644 index 28bd9f9..0000000 --- a/dist/SoM.min.js +++ /dev/null @@ -1 +0,0 @@ -var O=["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"]'],U=['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"]'],Y=0.6,V=0.8,W=10;var H=200,z=0.7,q=0.25,B=0.3;class J{}class ${x;y;width;height;element;constructor(h,i,t,u,f=null){this.x=h,this.y=i,this.width=t,this.height=u,this.element=f}contains(h){return h.x>=this.x&&h.x+h.width<=this.x+this.width&&h.y>=this.y&&h.y+h.height<=this.y+this.height}intersects(h){return!(h.x>this.x+this.width||h.x+h.widththis.y+this.height||h.y+h.heightf.indexOf(t)===u)}}function S(h,i){function t(w){while(w){const y=window.getComputedStyle(w).zIndex;if(y!=="auto"){const p=parseInt(y,10);return isNaN(p)?0:p}w=w.parentElement}return 0}const u=t(h),f=t(i),a=h.compareDocumentPosition(i);if(a&Node.DOCUMENT_POSITION_CONTAINS||a&Node.DOCUMENT_POSITION_CONTAINED_BY)return!1;if(u!==f)return uu.element).filter((u)=>u!=this.element&&S(this.element,u)&&K(u))}async countVisiblePixels(h){const i=this.ctx.getImageData(h.left,h.top,h.width,h.height);let t=0;for(let u=0;uparseFloat(y)),a=u.clipPath,w={top:t.top-this.rect.top,bottom:t.bottom-this.rect.top,left:t.left-this.rect.left,right:t.right-this.rect.left,width:t.width,height:t.height};if(w.width=w.right-w.left,w.height=w.bottom-w.top,this.ctx.fillStyle=i,a&&a!=="none")a.split(/,| /).forEach((p)=>{const v=p.trim().match(/^([a-z]+)\((.*)\)$/);if(!v)return;switch(v[0]){case"polygon":const s=this.pathFromPolygon(p,t);this.ctx.fill(s);break;default:console.log("Unknown clip path kind: "+v)}});else if(f){const y=new Path2D;if(f.length===1)f[1]=f[0];if(f.length===2)f[2]=f[0];if(f.length===3)f[3]=f[1];y.moveTo(w.left+f[0],w.top),y.arcTo(w.right,w.top,w.right,w.bottom,f[1]),y.arcTo(w.right,w.bottom,w.left,w.bottom,f[2]),y.arcTo(w.left,w.bottom,w.left,w.top,f[3]),y.arcTo(w.left,w.top,w.right,w.top,f[0]),y.closePath(),this.ctx.fill(y)}else this.ctx.fillRect(w.left,w.top,w.width,w.height)}pathFromPolygon(h,i){if(!h||!h.match(/^polygon\((.*)\)$/))throw new Error("Invalid polygon format: "+h);const t=new Path2D,u=h.match(/\d+(\.\d+)?%/g);if(u&&u.length>=2){const f=parseFloat(u[0]),a=parseFloat(u[1]);t.moveTo(f*i.width/100,a*i.height/100);for(let w=2;w{const f=h.slice(u*W,(u+1)*W).filter((w)=>K(w)),a=[];for(let w of f)if(await this.isDeepVisible(w))a.push(w);return a}))).flat()}mapQuadTree(){const h=new $(0,0,window.innerWidth,window.innerHeight),i=new M(h,4),t=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode:(f)=>{if(K(f))return NodeFilter.FILTER_ACCEPT;return NodeFilter.FILTER_REJECT}});let u=t.currentNode;while(u){const f=u,a=f.getBoundingClientRect();i.insert(new $(a.left,a.top,a.width,a.height,f)),u=t.nextNode()}return i}async isDeepVisible(h){return new Promise((i)=>{const t=new IntersectionObserver(async(u)=>{const f=u[0];if(t.disconnect(),f.intersectionRatio=window.innerWidth*V||a.height>=window.innerHeight*V){i(!1);return}const y=await new j(h).eval(this.qt);i(y>=Y)});t.observe(h)})}}var R=T;var L=0.9,I=3,c=["a","button","input","select","textarea"];class A extends J{constructor(){super(...arguments)}async apply(h){const i=h.fixed.concat(h.unknown),{top:t,others:u}=this.getTopLevelElements(i),f=await Promise.all(t.map(async(a)=>this.compareTopWithChildren(a,u)));return{fixed:h.fixed,unknown:f.flat().filter((a)=>h.fixed.indexOf(a)===-1)}}async compareTopWithChildren(h,i){if(c.some((w)=>h.matches(w)))return[h];const t=this.getBranches(h,i),u=h.getBoundingClientRect();if(t.length<=1)return[h];const a=(await Promise.all(t.map(async(w)=>{const y=w.top.getBoundingClientRect();if(y.width/u.widthI)return a;return[h,...a]}getBranches(h,i){const t=this.getFirstHitChildren(h,i);return t.map((u)=>{const f=i.filter((a)=>!t.includes(a)&&u.contains(a));return{top:u,children:f}})}getFirstHitChildren(h,i){const t=h.querySelectorAll(":scope > *"),u=Array.from(t).filter((f)=>i.includes(f));if(u.length>0)return u;return Array.from(t).flatMap((f)=>this.getFirstHitChildren(f,i))}getTopLevelElements(h){const i=[],t=[];for(let u of h)if(!h.some((f)=>f!==u&&f.contains(u)))i.push(u);else t.push(u);return{top:i,others:t}}}var Z=A;class G{filters={visibility:new R,nesting:new Z};async loadElements(){const h=O.join(",");let i=Array.from(document.querySelectorAll(h));const t=this.shadowRoots();for(let y=0;yv.indexOf(y)===p).filter((y)=>!y.closest("svg")&&!i.some((p)=>p.contains(y)));let w={fixed:i,unknown:u};return console.groupCollapsed("Elements"),console.log("Before filters",w),w=await this.filters.visibility.apply(w),console.log("After visibility filter",w),w=await this.filters.nesting.apply(w),console.log("After nesting filter",w),console.groupEnd(),w.fixed.concat(w.unknown)}shadowRoots(){const h=[],i=document.createTreeWalker(document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(u){return NodeFilter.FILTER_ACCEPT}});let t;while(t=i.nextNode())if(t&&t.shadowRoot)h.push(t.shadowRoot);return h}}class k{contrastColor(h,i){const t=window.getComputedStyle(h),u=d.fromCSS(t.backgroundColor);return this.getBestContrastColor([u,...i])}getBestContrastColor(h){const i=h.filter((u)=>u.a>0).map((u)=>u.complimentary());let t;if(i.length===0)t=new d(Math.floor(Math.random()*255),Math.floor(Math.random()*255),Math.floor(Math.random()*255));else t=this.getAverageColor(i);if(t.luminance()>z)t=t.withLuminance(z);else if(t.luminance()f+a.r,0)/h.length,t=h.reduce((f,a)=>f+a.g,0)/h.length,u=h.reduce((f,a)=>f+a.b,0)/h.length;return new d(i,t,u)}}class d{h;i;t;u;constructor(h,i,t,u=255){this.r=h;this.g=i;this.b=t;this.a=u;if(h<0||h>255)throw new Error(`Invalid red value: ${h}`);if(i<0||i>255)throw new Error(`Invalid green value: ${i}`);if(t<0||t>255)throw new Error(`Invalid blue value: ${t}`);if(u<0||u>255)throw new Error(`Invalid alpha value: ${u}`);this.r=Math.round(h),this.g=Math.round(i),this.b=Math.round(t),this.a=Math.round(u)}static fromCSS(h){if(h.startsWith("#"))return d.fromHex(h);if(h.startsWith("rgb")){const t=h.replace(/rgba?\(/,"").replace(")","").split(",").map((u)=>parseInt(u.trim()));return new d(...t)}if(h.startsWith("hsl")){const t=h.replace(/hsla?\(/,"").replace(")","").split(",").map((u)=>parseFloat(u.trim()));return d.fromHSL({h:t[0],s:t[1],l:t[2]})}const i=E[h.toLowerCase()];if(i)return d.fromHex(i);throw new Error(`Unknown color format: ${h}`)}static fromHex(h){if(h=h.replace("#",""),h.length===3)h=h.split("").map((f)=>f+f).join("");const i=parseInt(h.substring(0,2),16),t=parseInt(h.substring(2,4),16),u=parseInt(h.substring(4,6),16);if(h.length===8){const f=parseInt(h.substring(6,8),16);return new d(i,t,u,f)}return new d(i,t,u)}static fromHSL(h){const{h:i,s:t,l:u}=h;let f,a,w;if(t===0)f=a=w=u;else{const y=(s,F,g)=>{if(g<0)g+=1;if(g>1)g-=1;if(g<0.16666666666666666)return s+(F-s)*6*g;if(g<0.5)return F;if(g<0.6666666666666666)return s+(F-s)*(0.6666666666666666-g)*6;return s},p=u<0.5?u*(1+t):u+t-u*t,v=2*u-p;f=y(v,p,i+0.3333333333333333),a=y(v,p,i),w=y(v,p,i-0.3333333333333333)}return new d(f*255,a*255,w*255)}luminance(){const h=this.r/255,i=this.g/255,t=this.b/255,u=[h,i,t].map((f)=>{if(f<=0.03928)return f/12.92;return Math.pow((f+0.055)/1.055,2.4)});return 0.2126*u[0]+0.7152*u[1]+0.0722*u[2]}withLuminance(h){const i=this.luminance(),t=h/i,u=Math.min(255,this.r*t),f=Math.min(255,this.g*t),a=Math.min(255,this.b*t);return new d(u,f,a,this.a)}saturation(){return this.toHsl().s}withSaturation(h){const i=this.toHsl();return i.s=h,d.fromHSL(i)}contrast(h){const i=this.luminance(),t=h.luminance();return(Math.max(i,t)+0.05)/(Math.min(i,t)+0.05)}complimentary(){const h=this.toHsl();return h.h=(h.h+0.5)%1,d.fromHSL(h)}toHex(){const h=this.r.toString(16).padStart(2,"0"),i=this.g.toString(16).padStart(2,"0"),t=this.b.toString(16).padStart(2,"0");if(this.a<255){const u=this.a.toString(16).padStart(2,"0");return`#${h}${i}${t}${u}`}return`#${h}${i}${t}`}toHsl(){const h=this.r/255,i=this.g/255,t=this.b/255,u=Math.max(h,i,t),f=Math.min(h,i,t);let a=(u+f)/2,w=(u+f)/2,y=(u+f)/2;if(u===f)a=w=0;else{const p=u-f;switch(w=y>0.5?p/(2-u-f):p/(u+f),u){case h:a=(i-t)/p+(ia.matches(s)))y.classList.add("editable");const p=t.filter((s)=>{return[Math.sqrt(Math.pow(w.left-s.left,2)+Math.pow(w.top-s.top,2)),Math.sqrt(Math.pow(w.right-s.right,2)+Math.pow(w.top-s.top,2)),Math.sqrt(Math.pow(w.left-s.left,2)+Math.pow(w.bottom-s.bottom,2)),Math.sqrt(Math.pow(w.right-s.right,2)+Math.pow(w.bottom-s.bottom,2))].some((g)=>gs.color),v=this.colors.contrastColor(a,p);y.style.setProperty("--SoM-color",`${v.r}, ${v.g}, ${v.b}`),document.body.appendChild(y),t.push({top:w.top,bottom:w.bottom,left:w.left,right:w.right,width:w.width,height:w.height,color:v}),u.push(y)}for(let f=0;f{let X=0;if(P.top<0||P.top+p.height>window.innerHeight||P.left<0||P.left+p.width>window.innerWidth)X+=Infinity;else i.concat(t).forEach((Q)=>{if(Q.top<=w.top&&Q.bottom>=w.bottom&&Q.left<=w.left&&Q.right>=w.right)return;const b=Math.max(0,Math.min(P.left+p.width,Q.left+Q.width)-Math.max(P.left,Q.left)),C=Math.max(0,Math.min(P.top+p.height,Q.top+Q.height)-Math.max(P.top,Q.top));X+=b*C});return X}),g=s[F.indexOf(Math.min(...F))];y.style.top=`${g.top-w.top}px`,y.style.left=`${g.left-w.left}px`,i.push({top:g.top,left:g.left,right:g.left+p.width,bottom:g.top+p.height,width:p.width,height:p.height}),a.setAttribute("data-SoM",`${f}`)}}getColorByLuminance(h){return h.luminance()>0.5?"black":"white"}}var _=".SoM{position:fixed;z-index:2147483646;pointer-events:none;background-color:rgba(var(--SoM-color),.35)}.SoM.editable{background:repeating-linear-gradient(45deg,rgba(var(--SoM-color),.15),rgba(var(--SoM-color),.15) 10px,rgba(var(--SoM-color),.35) 10px,rgba(var(--SoM-color),.35) 20px);outline:2px solid rgba(var(--SoM-color),.7)}.SoM>label{position:absolute;padding:0 3px;font-size:1rem;font-weight:700;line-height:1.2rem;white-space:nowrap;font-family:'Courier New',Courier,monospace;background-color:rgba(var(--SoM-color),.7)}";class N{loader=new G;ui=new D;async display(){this.log("Displaying...");const h=performance.now(),i=await this.loader.loadElements();this.clear(),this.ui.display(i),this.log("Done!",`Took ${performance.now()-h}ms to display ${i.length} elements.`)}clear(){document.querySelectorAll(".SoM").forEach((h)=>{h.remove()}),document.querySelectorAll("[data-som]").forEach((h)=>{h.removeAttribute("data-som")})}hide(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="none")}show(){document.querySelectorAll(".SoM").forEach((h)=>h.style.display="block")}resolve(h){return document.querySelector(`[data-som="${h}"]`)}log(...h){console.log("%cSoM","color: white; background: #007bff; padding: 2px 5px; border-radius: 5px;",...h)}}if(!document.getElementById("SoM-styles")){const h=document.createElement("style");h.id="SoM-styles",h.innerHTML=_;const i=setInterval(()=>{if(document.head)clearInterval(i),document.head.appendChild(h)},100)}window.SoM=new N;window.SoM.log("Ready!"); diff --git a/package.json b/package.json index 6967f77..21cb2be 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,27 @@ "type": "git", "url": "git@github.com:brewcoua/web-som.git" }, - "main": "dist/SoM.js", + "main": "./SoM.min.js", + "files": [ + "SoM.min.js", + "SoM.min.js.map", + "SoM.js", + "SoM.js.map" + ], "devDependencies": { - "@types/contrast-color": "^1.0.3", - "clean-css": "^5.3.3", - "prettier": "^3.2.5" + "@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", + "conventional-changelog-conventionalcommits": "^8.0.0", + "prettier": "^3.2.5", + "rollup": "^4.18.0", + "rollup-plugin-string": "^3.0.0", + "semantic-release": "^24.0.0", + "tslib": "^2.6.3", + "typescript": "^5.4.5" }, "bugs": { "url": "https://github.com/brewcoua/web-som/issues", @@ -24,6 +40,6 @@ "homepage": "https://github.com/brewcoua/web-som#readme", "license": "(MIT OR Apache-2.0)", "scripts": { - "build": "bun ./build.ts" + "build": "rollup -c" } } diff --git a/release.config.ts b/release.config.ts new file mode 100644 index 0000000..b93da5d --- /dev/null +++ b/release.config.ts @@ -0,0 +1,33 @@ +export default { + branches: ['master'], + plugins: [ + [ + '@semantic-release/commit-analyzer', + { + preset: 'conventionalcommits', + }, + ], + '@semantic-release/release-notes-generator', + '@semantic-release/changelog', + [ + '@semantic-release/exec', + { + prepareCmd: 'cp package.json README.md LICENSE* dist', + }, + ], + '@semantic-release/git', + [ + '@semantic-release/github', + { + assets: 'dist/*.js', + }, + ], + [ + '@semantic-release/npm', + { + npmPublish: true, + pkgRoot: 'dist', + }, + ], + ], +}; diff --git a/rollup.config.ts b/rollup.config.ts new file mode 100644 index 0000000..ea31297 --- /dev/null +++ b/rollup.config.ts @@ -0,0 +1,27 @@ +import typescript from '@rollup/plugin-typescript'; +import terser from '@rollup/plugin-terser'; +import { string } from 'rollup-plugin-string'; + +export default { + input: 'src/main.ts', + output: [ + { + file: 'dist/SoM.js', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/SoM.min.js', + format: 'cjs', + sourcemap: true, + plugins: [terser()], + }, + ], + plugins: [ + typescript(), + string({ + include: '**/*.css', + exclude: ['node_modules/**'], + }), + ], +}; From a67b248fc51d50e46da87f0c9460f1db24c0a06c Mon Sep 17 00:00:00 2001 From: Brewen Couaran <45310490+brewcoua@users.noreply.github.com> Date: Sat, 8 Jun 2024 11:57:11 +0200 Subject: [PATCH 5/5] docs: update readme description and fix repo url in pkg --- README.md | 4 +-- package.json | 86 ++++++++++++++++++++++++++-------------------------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e23ef37..9afbdff 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ You can then use the `SoM` object in the `window` object to interact with the sc ### Example ```js -window.SoM.display().then(() => console.log("Set-of-Marks displayed")); +window.SoM.display().then(() => console.log('Set-of-Marks displayed')); ``` ### How it works @@ -25,7 +25,7 @@ The script will first query all elements on the page (and inside shadow roots) t #### 2. Elements filtering -The script will then first proceed to filter out, in both lists, the elements that are not visible enough (see [src/constants.ts](src/constants.ts) for the threshold values, e.g. `0.7`). To do that, we first use an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to check if the element is visible enough in the viewport, and if it is, we check the actual pixel-by-pixel visibility ratio of the element by first drawing it on a canvas, then drawing the overlapping elements on the same canvas, and finally counting the number of pixels that were not overlapped by other elements. +The script will then first proceed to filter out, in both lists, the elements that are not visible enough (see [src/constants.ts](src/constants.ts) for the threshold values, e.g. `0.7`). To do that, we first use an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to check if the element is visible enough in the viewport, and if it is, we find the elements that are possibly intersecting with the element, using a QuadTree that we previously built with all elements on the page by their bounding boxes. We then query the QuadTree for elements that are possibly intersecting with the element, and we draw them on a canvas, after drawing the original element. We then calculate the pixel-by-pixel visibility ratio by counting the number of pixels that were not overlapped by other elements. If the ratio is above the threshold, we consider the element visible enough. After that, we take the elements in the second list (the ones that display a pointer cursor) and apply a nesting filter. This filter will remove all elements that are either inside a prioritized element (e.g. a button) or that have too many clickable children. Additionally, we consider elements disjoint if their size is different enough (see [src/constants.ts](src/constants.ts) for the threshold value, e.g. `0.7`). When applying this filter, we also consider the first list for reference, while not removing any element from that first list afterwards. diff --git a/package.json b/package.json index 21cb2be..6d189cc 100644 --- a/package.json +++ b/package.json @@ -1,45 +1,45 @@ { - "name": "web-som", - "version": "1.0.0", - "author": { - "name": "Brewen Couaran", - "email": "contact@brewen.dev", - "url": "https://brewen.dev" - }, - "repository": { - "type": "git", - "url": "git@github.com:brewcoua/web-som.git" - }, - "main": "./SoM.min.js", - "files": [ - "SoM.min.js", - "SoM.min.js.map", - "SoM.js", - "SoM.js.map" - ], - "devDependencies": { - "@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", - "conventional-changelog-conventionalcommits": "^8.0.0", - "prettier": "^3.2.5", - "rollup": "^4.18.0", - "rollup-plugin-string": "^3.0.0", - "semantic-release": "^24.0.0", - "tslib": "^2.6.3", - "typescript": "^5.4.5" - }, - "bugs": { - "url": "https://github.com/brewcoua/web-som/issues", - "email": "contact@brewen.dev" - }, - "description": "Set-of-Marks script for web grounding, used for web agent automation", - "homepage": "https://github.com/brewcoua/web-som#readme", - "license": "(MIT OR Apache-2.0)", - "scripts": { - "build": "rollup -c" - } + "name": "@brewcoua/web-som", + "version": "1.0.0", + "author": { + "name": "Brewen Couaran", + "email": "contact@brewen.dev", + "url": "https://brewen.dev" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/brewcoua/web-som.git" + }, + "main": "./SoM.min.js", + "files": [ + "SoM.min.js", + "SoM.min.js.map", + "SoM.js", + "SoM.js.map" + ], + "devDependencies": { + "@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", + "conventional-changelog-conventionalcommits": "^8.0.0", + "prettier": "^3.2.5", + "rollup": "^4.18.0", + "rollup-plugin-string": "^3.0.0", + "semantic-release": "^24.0.0", + "tslib": "^2.6.3", + "typescript": "^5.4.5" + }, + "bugs": { + "url": "https://github.com/brewcoua/web-som/issues", + "email": "contact@brewen.dev" + }, + "description": "Set-of-Marks script for web grounding, used for web agent automation", + "homepage": "https://github.com/brewcoua/web-som#readme", + "license": "(MIT OR Apache-2.0)", + "scripts": { + "build": "rollup -c" + } }