|
| 1 | +const IGNORE_PERCENT_THRESHOLD = 0.8; // Ignore elements that cover at least this % of the region |
| 2 | +const SPLIT_SIZE = 10; // Split into 2 sub regions (vertically, horizontal shouldn't be common) when one contains this many elements |
| 3 | + |
| 4 | +/** |
| 5 | + * Gets the top of the element, in pixels relative to the page. |
| 6 | + * @param {Element} elem |
| 7 | + * @returns {number} |
| 8 | + */ |
| 9 | +function elemTop(elem) { |
| 10 | + return elem.getBoundingClientRect().top + document.documentElement.scrollTop; |
| 11 | +} |
| 12 | + |
| 13 | +/** |
| 14 | + * Calculates how much a given element overlaps the height band given |
| 15 | + * @param {number} top |
| 16 | + * @param {number} height |
| 17 | + * @param {Element} elem |
| 18 | + * @returns {number} |
| 19 | + */ |
| 20 | +function calculateOverlap(top, height, elem) { |
| 21 | + // Calculates vertical overlap of a height band + element |
| 22 | + // Start by finding elem top/bottom, removing any overflow of the band |
| 23 | + const eTop = Math.max(top, elemTop(elem)); |
| 24 | + const eBottom = Math.min( |
| 25 | + top + height, |
| 26 | + elem.getBoundingClientRect().bottom + document.documentElement.scrollTop |
| 27 | + ); |
| 28 | + |
| 29 | + // Return ratios of heights |
| 30 | + return (eBottom - eTop) / height; |
| 31 | +} |
| 32 | + |
| 33 | +class PointResolver { |
| 34 | + /** |
| 35 | + * |
| 36 | + * @param {Element[]} elements |
| 37 | + * @param {number} top |
| 38 | + * @param {number} bottom |
| 39 | + */ |
| 40 | + constructor(elements, top, bottom) { |
| 41 | + this.top = top; |
| 42 | + this.bottom = bottom; |
| 43 | + /** @type {PointResolver[]} */ |
| 44 | + this.subResolvers = []; |
| 45 | + /** @type {Element[]} */ |
| 46 | + this.elements = elements.filter( |
| 47 | + elem => |
| 48 | + calculateOverlap(top, bottom - top, elem) >= IGNORE_PERCENT_THRESHOLD |
| 49 | + ); |
| 50 | + |
| 51 | + const minorElems = elements.filter( |
| 52 | + elem => |
| 53 | + calculateOverlap(top, bottom - top, elem) < IGNORE_PERCENT_THRESHOLD |
| 54 | + ); |
| 55 | + if (minorElems.length < SPLIT_SIZE) { |
| 56 | + this.elements = this.elements.concat(minorElems); |
| 57 | + } else { |
| 58 | + // Need to go to sub resolvers for minor elements. Just sort + take half in each |
| 59 | + const sorted = minorElems.sort( |
| 60 | + (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top |
| 61 | + ); |
| 62 | + const midway = Math.floor(sorted.length / 2); |
| 63 | + this.subResolvers = [ |
| 64 | + new PointResolver( |
| 65 | + sorted.slice(0, midway), |
| 66 | + top, |
| 67 | + elemTop(sorted[midway]) |
| 68 | + ), |
| 69 | + new PointResolver( |
| 70 | + sorted.slice(midway, sorted.length), |
| 71 | + elemTop(sorted[midway]) + 1, |
| 72 | + bottom |
| 73 | + ), |
| 74 | + ]; |
| 75 | + } |
| 76 | + } |
| 77 | + /** |
| 78 | + * Returns all elements that the point lies inside of |
| 79 | + * @param {number} x x-coord of the point |
| 80 | + * @param {number} y y-coord of the point |
| 81 | + * @returns {Element[]} |
| 82 | + */ |
| 83 | + resolvePoint(x, y) { |
| 84 | + // If out of our range, then just return nothing. |
| 85 | + if (Math.ceil(y) < this.top || Math.floor(y) > this.bottom) { |
| 86 | + return []; |
| 87 | + } |
| 88 | + // Any sub-band should also be allowed to resolve |
| 89 | + /** @type {Element[]} */ |
| 90 | + const subResolved = this.subResolvers.reduce( |
| 91 | + // @ts-ignore For some reason, it's trying to give arr the type never[] |
| 92 | + (arr, resolver) => arr.concat(resolver.resolvePoint(x, y)), |
| 93 | + [] |
| 94 | + ); |
| 95 | + const containingElems = this.elements.filter(elem => { |
| 96 | + return ( |
| 97 | + y > elemTop(elem) && // point lies below the top of elem |
| 98 | + y < |
| 99 | + elem.getBoundingClientRect().bottom + |
| 100 | + document.documentElement.scrollTop && // and above bottom |
| 101 | + x > |
| 102 | + elem.getBoundingClientRect().left + |
| 103 | + document.documentElement.scrollLeft && // to the right of left side |
| 104 | + x < |
| 105 | + elem.getBoundingClientRect().right + |
| 106 | + document.documentElement.scrollLeft |
| 107 | + ); // to the left of right side |
| 108 | + }); |
| 109 | + return subResolved.concat(containingElems); |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +// Hmm, not really a great class name, sadly. |
| 114 | +export class DoodleElementFinder { |
| 115 | + /** |
| 116 | + * @param {HTMLElement | null} root - Element containing the doodle. Defaults to the <body> element of the page. |
| 117 | + */ |
| 118 | + constructor(root) { |
| 119 | + if (root === null) { |
| 120 | + root = document.querySelector('body'); |
| 121 | + } |
| 122 | + if (root === null) { |
| 123 | + throw Error("Couldn't find body element"); |
| 124 | + } |
| 125 | + const allElements = root.querySelectorAll('*'); |
| 126 | + this.resolver = new PointResolver( |
| 127 | + Array.from(allElements).filter( |
| 128 | + elem => |
| 129 | + elem.getBoundingClientRect().width > 0 && |
| 130 | + elem.getBoundingClientRect().height > 0 |
| 131 | + ), |
| 132 | + root.getBoundingClientRect().top + document.documentElement.scrollTop, |
| 133 | + root.getBoundingClientRect().bottom + document.documentElement.scrollTop |
| 134 | + ); |
| 135 | + } |
| 136 | + |
| 137 | + /** |
| 138 | + * Returns all Elements that the given point overlaps |
| 139 | + * @param {number} x |
| 140 | + * @param {number} y |
| 141 | + * @returns {Element[]} |
| 142 | + */ |
| 143 | + resolvePoint(x, y) { |
| 144 | + return this.resolver.resolvePoint(x, y); |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Returns all Elements that the line overlaps |
| 149 | + * @param {import("../types/api").DoodleLine} line |
| 150 | + * @returns {Element[]} |
| 151 | + */ |
| 152 | + resolveLine(line) { |
| 153 | + // Resolve for each point in the line, removing duplicates. |
| 154 | + // In a later sprint we can revisit this and potentially ignore some elements that aren't common |
| 155 | + // but for now, this is good enough. |
| 156 | + let elems = new Set(); |
| 157 | + line.points.forEach(point => { |
| 158 | + this.resolvePoint(point[0], point[1]).forEach(elems.add); |
| 159 | + }); |
| 160 | + return Array.from(elems); |
| 161 | + } |
| 162 | +} |
0 commit comments