diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index b35132c..3b0f24b 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -583,7 +583,11 @@ async function captureHighlightedPageState( : ''; const detectedViewport = detectionResult.result.value.viewport || {}; const layoutStability = detectionResult.result.value.layoutStability; + const inPagePerf = detectionResult.result.value._perf || {}; const highlightTraceStart = Date.now(); + let paginationMs = 0; + let screenshotMs = 0; + let consistencyMs = 0; const detectedViewportWidth = typeof detectedViewport.width === 'number' ? detectedViewport.width : 0; const detectedViewportHeight = @@ -656,8 +660,9 @@ async function captureHighlightedPageState( console.log( `📄 [${logLabel}] Page ${page}/${totalPages}, showing ${paginatedElements.length} of ${filteredElements.length} elements`, ); + paginationMs = Date.now() - paginationBuildStart; console.log( - `⏱️ [HighlightTrace] background pagination build-pages=${Date.now() - paginationBuildStart}ms (page=${page}, viewport=${detectedViewportWidth}x${detectedViewportHeight})`, + `⏱️ [HighlightTrace] background pagination build-pages=${paginationMs}ms (page=${page}, viewport=${detectedViewportWidth}x${detectedViewportHeight})`, ); } @@ -702,9 +707,8 @@ async function captureHighlightedPageState( console.log( `📸 [${logLabel}] Screenshot captured (with in-page highlights), size: ${screenshotResult.imageData.length} bytes`, ); - console.log( - `⏱️ [HighlightTrace] background screenshot ${Date.now() - screenshotStart}ms`, - ); + screenshotMs = Date.now() - screenshotStart; + console.log(`⏱️ [HighlightTrace] background screenshot ${screenshotMs}ms`); // Apply bboxes returned from the highlight injection script const preCaptureData = screenshotResult.preCaptureResult; @@ -766,8 +770,9 @@ async function captureHighlightedPageState( })), currentConsistencySamples, ); + consistencyMs = Date.now() - consistencyCheckStart; console.log( - `⏱️ [HighlightTrace] background consistency-check ${Date.now() - consistencyCheckStart}ms (checked=${highlightConsistency.checkedCount}, matched=${highlightConsistency.matchedCount}, missing=${highlightConsistency.missingCount}, shifted=${highlightConsistency.shiftedCount}, maxCenterShift=${highlightConsistency.maxCenterShift}, maxSizeDelta=${highlightConsistency.maxSizeDelta}, retry=${highlightConsistency.shouldRetry})`, + `⏱️ [HighlightTrace] background consistency-check ${consistencyMs}ms (checked=${highlightConsistency.checkedCount}, matched=${highlightConsistency.matchedCount}, missing=${highlightConsistency.missingCount}, shifted=${highlightConsistency.shiftedCount}, maxCenterShift=${highlightConsistency.maxCenterShift}, maxSizeDelta=${highlightConsistency.maxSizeDelta}, retry=${highlightConsistency.shouldRetry})`, ); const repeatedDrift = isRepeatedHighlightDrift( highlightConsistency, @@ -841,6 +846,15 @@ async function captureHighlightedPageState( page: currentPage, pageState, readinessReasons, + _perf: { + scan_ms: + typeof inPagePerf.scan_ms === 'number' ? inPagePerf.scan_ms : 0, + scan_stats: inPagePerf.scan_stats || {}, + scan_times: inPagePerf.scan_times || {}, + pagination_ms: paginationMs, + screenshot_ms: screenshotMs, + consistency_ms: consistencyMs, + }, ...buildScreenshotPayload(compressedScreenshotResult), }; } diff --git a/extension/src/commands/highlight-detection.injected.js b/extension/src/commands/highlight-detection.injected.js index 72f3e8e..4140016 100644 --- a/extension/src/commands/highlight-detection.injected.js +++ b/extension/src/commands/highlight-detection.injected.js @@ -77,6 +77,130 @@ function hasCallableMethod(value, methodNames) { ); } +// Layout reads (getBoundingClientRect, getComputedStyle) and elementsFromPoint +// are the single biggest cost in collectHighlightCandidates: every visibility +// predicate re-reads them for the same element. Within one synchronous +// Runtime.evaluate task no page JS runs concurrently, so the values cannot +// change mid-scan. We monkey-patch the prototypes for the duration of one +// scan, populate a per-element WeakMap, and restore originals at the end. +const SCAN_NON_INTERACTIVE_TAGS = new Set([ + 'script', + 'style', + 'link', + 'meta', + 'head', + 'title', + 'noscript', + 'br', + 'hr', + 'source', + 'track', + 'template', + 'param', + 'col', + 'colgroup', +]); + +function isScanSkippableTag(el) { + if (!el || !el.tagName) return false; + return SCAN_NON_INTERACTIVE_TAGS.has(el.tagName.toLowerCase()); +} + +// Per-scan memoization caches for pure-function classifiers that get hit many +// times for the same element during the resolve phase (each candidate walks +// up to 5 ancestors, each ancestor calls hasExplicitClickableAncestor which +// walks ALL ancestors, etc.). Reset at the start of each scan, leak nothing +// outside it. WeakMap so any GC'd nodes drop out automatically. +let _scanSemanticSignalCache = null; +let _scanClickableCandidateCache = null; +let _scanBaseClickableSignalCache = null; +let _scanTextContentCache = null; +let _scanSearchTextCache = null; +let _scanExplicitAncestorCache = null; + +function withScanLayoutCache(fn) { + const rectCache = new WeakMap(); + const styleCache = new WeakMap(); + // elementsFromPoint dedup keyed by rounded "x:y" + const efpCache = new Map(); + _scanSemanticSignalCache = new WeakMap(); + _scanClickableCandidateCache = new WeakMap(); + _scanBaseClickableSignalCache = new WeakMap(); + _scanTextContentCache = new WeakMap(); + _scanSearchTextCache = new WeakMap(); + _scanExplicitAncestorCache = new WeakMap(); + + const origElementRect = Element.prototype.getBoundingClientRect; + const SVGGraphicsProto = + typeof SVGGraphicsElement !== 'undefined' + ? SVGGraphicsElement.prototype + : null; + const origSVGRect = + SVGGraphicsProto && SVGGraphicsProto.getBoundingClientRect; + const origGetComputedStyle = window.getComputedStyle; + // Patch Document.prototype rather than the document instance so we don't + // leave an own-property shadowing the prototype after the scan finishes. + const DocumentProto = + typeof Document !== 'undefined' ? Document.prototype : null; + const origElementsFromPoint = + DocumentProto && DocumentProto.elementsFromPoint; + + function patchedRect() { + let r = rectCache.get(this); + if (r === undefined) { + r = origElementRect.call(this); + rectCache.set(this, r); + } + return r; + } + + Element.prototype.getBoundingClientRect = patchedRect; + if (SVGGraphicsProto && origSVGRect) { + SVGGraphicsProto.getBoundingClientRect = patchedRect; + } + + window.getComputedStyle = function (el, pseudo) { + if (pseudo) return origGetComputedStyle.call(window, el, pseudo); + let s = styleCache.get(el); + if (s === undefined) { + s = origGetComputedStyle.call(window, el); + styleCache.set(el, s); + } + return s; + }; + + if (DocumentProto && origElementsFromPoint) { + DocumentProto.elementsFromPoint = function (x, y) { + const key = Math.round(x) + ':' + Math.round(y); + let stack = efpCache.get(key); + if (stack === undefined) { + stack = origElementsFromPoint.call(this, x, y); + efpCache.set(key, stack); + } + return stack; + }; + } + + try { + return fn(); + } finally { + Element.prototype.getBoundingClientRect = origElementRect; + if (SVGGraphicsProto && origSVGRect) { + SVGGraphicsProto.getBoundingClientRect = origSVGRect; + } + window.getComputedStyle = origGetComputedStyle; + if (DocumentProto && origElementsFromPoint) { + DocumentProto.elementsFromPoint = origElementsFromPoint; + } + _scanSemanticSignalCache = null; + _scanClickableCandidateCache = null; + _scanBaseClickableSignalCache = null; + _scanTextContentCache = null; + _scanSearchTextCache = null; + _scanExplicitAncestorCache = null; + } +} + function createHighlightTrace() { const traceStart = performance.now(); @@ -305,6 +429,15 @@ function getSwipeMarkerText(el) { } function getElementTextForDetection(el) { + if (_scanTextContentCache && _scanTextContentCache.has(el)) { + return _scanTextContentCache.get(el); + } + const r = getElementTextForDetectionImpl(el); + if (_scanTextContentCache) _scanTextContentCache.set(el, r); + return r; +} + +function getElementTextForDetectionImpl(el) { if (el instanceof HTMLInputElement) { const inputType = (el.type || '').toLowerCase(); if ( @@ -316,10 +449,22 @@ function getElementTextForDetection(el) { } } + // textContent on a deep node walks the entire subtree of text nodes — for + // a table row with hundreds of descendants this is expensive enough to + // dominate the resolve phase. Cache so each candidate pays at most once. return normalizeWhitespace(el.textContent || '', 240); } function getElementSearchText(el) { + if (_scanSearchTextCache && _scanSearchTextCache.has(el)) { + return _scanSearchTextCache.get(el); + } + const r = getElementSearchTextImpl(el); + if (_scanSearchTextCache) _scanSearchTextCache.set(el, r); + return r; +} + +function getElementSearchTextImpl(el) { const tokens = [ el.tagName.toLowerCase(), ...getAttributeTextTokens(el, [ @@ -480,6 +625,15 @@ function hasPointerCursor(el) { } function getBaseClickableSignal(el) { + if (_scanBaseClickableSignalCache && _scanBaseClickableSignalCache.has(el)) { + return _scanBaseClickableSignalCache.get(el); + } + const r = getBaseClickableSignalImpl(el); + if (_scanBaseClickableSignalCache) _scanBaseClickableSignalCache.set(el, r); + return r; +} + +function getBaseClickableSignalImpl(el) { const semanticSignal = getSemanticClickableSignal(el); if (semanticSignal) { return semanticSignal; @@ -573,6 +727,15 @@ function getControlAffinityScore(el) { } function getSemanticClickableSignal(el) { + if (_scanSemanticSignalCache && _scanSemanticSignalCache.has(el)) { + return _scanSemanticSignalCache.get(el); + } + const r = getSemanticClickableSignalImpl(el); + if (_scanSemanticSignalCache) _scanSemanticSignalCache.set(el, r); + return r; +} + +function getSemanticClickableSignalImpl(el) { const tag = el.tagName.toLowerCase(); const role = (el.getAttribute('role') || '').toLowerCase(); @@ -769,18 +932,30 @@ function countDirectClickableChildren(el) { } function hasExplicitClickableAncestor(el) { + if (_scanExplicitAncestorCache && _scanExplicitAncestorCache.has(el)) { + return _scanExplicitAncestorCache.get(el); + } + // Per-call top-level memoization only. A previous version tried to + // walk-and-memoize each visited ancestor too, but that's incorrect — + // a node's own `hasExplicitClickableAncestor` is about ITS ancestors, + // not about its own signal, and it's also influenced by its own signal + // when answering the same question for *its* descendants. Doing the full + // walk per unique element (with getSemanticClickableSignal cached) is + // already cheap enough thanks to the upstream caches. let current = el.parentElement; - + let answer = false; while (current && current !== document.body) { const signal = getSemanticClickableSignal(current); if (signal === 'semantic' || signal === 'attribute') { - return true; + answer = true; + break; } - current = current.parentElement; } - - return false; + if (_scanExplicitAncestorCache) { + _scanExplicitAncestorCache.set(el, answer); + } + return answer; } function isInputableCandidate(el) { @@ -911,6 +1086,15 @@ function hasStructuredInteractiveDescendant(el) { } function isClickableCandidate(el) { + if (_scanClickableCandidateCache && _scanClickableCandidateCache.has(el)) { + return _scanClickableCandidateCache.get(el); + } + const r = isClickableCandidateImpl(el); + if (_scanClickableCandidateCache) _scanClickableCandidateCache.set(el, r); + return r; +} + +function isClickableCandidateImpl(el) { if (isDisabledForDetection(el)) { return null; } @@ -2473,6 +2657,12 @@ function collectUploadableCandidates(trace) { } function collectHighlightCandidates(config, trace, layoutStability) { + return withScanLayoutCache(() => + collectHighlightCandidatesImpl(config, trace, layoutStability), + ); +} + +function collectHighlightCandidatesImpl(config, trace, layoutStability) { const activeTopLayerRoot = getActiveTopLayerRoot(); const registry = new Map(); @@ -2519,6 +2709,27 @@ function collectHighlightCandidates(config, trace, layoutStability) { ); let scannedCount = 0; + // Per-phase reject counters and timings — gated behind the trace, helps + // identify where the scan budget is spent without per-element console spam. + const phaseStats = { + tagSkip: 0, + notInViewport: 0, + notVisible: 0, + scrollParentClipped: 0, + notInActiveTopLayer: 0, + hitTestOccluded: 0, + notResolvable: 0, + matched: 0, + }; + const phaseTimes = { + tag: 0, + viewport: 0, + visible: 0, + scrollParent: 0, + topLayer: 0, + hitTest: 0, + resolve: 0, + }; for (const element of allElements) { scannedCount += 1; @@ -2529,34 +2740,65 @@ function collectHighlightCandidates(config, trace, layoutStability) { ); } - if (!isElementInViewportForDetection(element)) { + let t = performance.now(); + if (isScanSkippableTag(element)) { + phaseStats.tagSkip += 1; + phaseTimes.tag += performance.now() - t; continue; } + phaseTimes.tag += performance.now() - t; - if (!isElementVisibleForDetection(element)) { + t = performance.now(); + const inViewport = isElementInViewportForDetection(element); + phaseTimes.viewport += performance.now() - t; + if (!inViewport) { + phaseStats.notInViewport += 1; continue; } - if (!isElementVisibleInScrollParent(element)) { + t = performance.now(); + const visible = isElementVisibleForDetection(element); + phaseTimes.visible += performance.now() - t; + if (!visible) { + phaseStats.notVisible += 1; + continue; + } + + t = performance.now(); + const scrollOk = isElementVisibleInScrollParent(element); + phaseTimes.scrollParent += performance.now() - t; + if (!scrollOk) { + phaseStats.scrollParentClipped += 1; continue; } - if (!isElementInActiveTopLayer(element, activeTopLayerRoot)) { + t = performance.now(); + const topLayerOk = isElementInActiveTopLayer(element, activeTopLayerRoot); + phaseTimes.topLayer += performance.now() - t; + if (!topLayerOk) { + phaseStats.notInActiveTopLayer += 1; continue; } + t = performance.now(); const hitTestVisibility = getElementHitTestVisibility(element); + phaseTimes.hitTest += performance.now() - t; if (!hitTestVisibility.visible) { + phaseStats.hitTestOccluded += 1; continue; } + t = performance.now(); const resolvedCandidate = resolveElementCandidate( element, config.elementType, ); + phaseTimes.resolve += performance.now() - t; if (!resolvedCandidate) { + phaseStats.notResolvable += 1; continue; } + phaseStats.matched += 1; const candidate = { element: resolvedCandidate.element, @@ -2605,14 +2847,20 @@ function collectHighlightCandidates(config, trace, layoutStability) { return element; }); + const roundedTimes = {}; + for (const k of Object.keys(phaseTimes)) { + roundedTimes[k] = Math.round(phaseTimes[k]); + } trace( 'scan:done', - `processed=${scannedCount} matched=${elements.length} counts=${JSON.stringify(counts)}`, + `processed=${scannedCount} matched=${elements.length} counts=${JSON.stringify(counts)} reject=${JSON.stringify(phaseStats)} ms=${JSON.stringify(roundedTimes)}`, ); return { elements, counts, + _scan_stats: phaseStats, + _scan_times: roundedTimes, }; } @@ -2625,11 +2873,10 @@ async function runOpenBrowserHighlightDetection(config) { const layoutStability = evaluateReadinessSnapshot(trace); - const { elements, counts } = collectHighlightCandidates( - config, - trace, - layoutStability, - ); + const scanStart = performance.now(); + const scanResult = collectHighlightCandidates(config, trace, layoutStability); + const { elements, counts } = scanResult; + const scanMs = Math.round(performance.now() - scanStart); trace('return', `elements=${elements.length}`); return { @@ -2641,5 +2888,10 @@ async function runOpenBrowserHighlightDetection(config) { width: window.innerWidth, height: window.innerHeight, }, + _perf: { + scan_ms: scanMs, + scan_stats: scanResult._scan_stats || {}, + scan_times: scanResult._scan_times || {}, + }, }; } diff --git a/extension/src/utils/collision-detection.ts b/extension/src/utils/collision-detection.ts index 054abc2..a409c64 100644 --- a/extension/src/utils/collision-detection.ts +++ b/extension/src/utils/collision-detection.ts @@ -36,6 +36,108 @@ interface RemainingCandidate { element: InteractiveElement; } +// Coarse spatial grid used to skip O(N) scans of `selected` and `remaining` +// when checking collisions. Cell size is a heuristic — large enough that most +// label rects touch only a couple of cells, small enough that a typical +// query returns far fewer than the full set. +const SPATIAL_INDEX_CELL_PX = 96; + +class SelectedSpatialIndex { + private cells = new Map(); + + add(element: InteractiveElement): void { + const labelBBox = getLabelBBox( + element.bbox, + element.labelPosition ?? 'above', + element.id, + ); + const union = unionBBox(element.bbox, labelBBox); + this.forEachCell(union, (key) => { + let bucket = this.cells.get(key); + if (!bucket) { + bucket = []; + this.cells.set(key, bucket); + } + // Avoid duplicate registration when a single element straddles cells we + // visit out of order — the per-call dedup Set in queryNear handles dup + // results across cells. + if (bucket[bucket.length - 1] !== element) { + bucket.push(element); + } + }); + } + + // Returns elements whose registered union-rect lies in any cell touched by + // the query rect (inflated by clearance on each side). Includes elements + // whose registration cells are *adjacent* to the query rect — see + // `queryNear` callers, which already inflate the query rect with clearance. + queryNear(query: BBox): InteractiveElement[] { + const seen = new Set(); + const out: InteractiveElement[] = []; + this.forEachCell(query, (key) => { + const bucket = this.cells.get(key); + if (!bucket) return; + for (const el of bucket) { + if (!seen.has(el)) { + seen.add(el); + out.push(el); + } + } + }); + return out; + } + + private forEachCell(rect: BBox, fn: (key: number) => void): void { + // Real bboxes from getBoundingClientRect are always finite, but synthetic + // test inputs or future callers might pass NaN/Infinity. Without this + // guard Math.floor would yield NaN, the loop would skip, and we'd + // silently drop a registration — masking real collisions. + if ( + !Number.isFinite(rect.x) || + !Number.isFinite(rect.y) || + !Number.isFinite(rect.width) || + !Number.isFinite(rect.height) + ) { + // Single sentinel cell so the registration is still discoverable. + fn(Number.MIN_SAFE_INTEGER); + return; + } + const minCx = Math.floor(rect.x / SPATIAL_INDEX_CELL_PX); + const maxCx = Math.floor( + (rect.x + Math.max(0, rect.width)) / SPATIAL_INDEX_CELL_PX, + ); + const minCy = Math.floor(rect.y / SPATIAL_INDEX_CELL_PX); + const maxCy = Math.floor( + (rect.y + Math.max(0, rect.height)) / SPATIAL_INDEX_CELL_PX, + ); + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cx = minCx; cx <= maxCx; cx++) { + // Cantor-pair-ish key: cy gets the high bits, cx the low bits. + // Negative coords are uncommon for label rects but still encode safely + // because Math.floor preserves order under shift. + fn(cy * 100000 + cx); + } + } + } +} + +function unionBBox(a: BBox, b: BBox): BBox { + const x = Math.min(a.x, b.x); + const y = Math.min(a.y, b.y); + const xMax = Math.max(a.x + a.width, b.x + b.width); + const yMax = Math.max(a.y + a.height, b.y + b.height); + return { x, y, width: xMax - x, height: yMax - y }; +} + +function inflateBBox(rect: BBox, padding: number): BBox { + return { + x: rect.x - padding, + y: rect.y - padding, + width: rect.width + 2 * padding, + height: rect.height + 2 * padding, + }; +} + interface PlacementEvaluation { position: LabelPosition; blockedCandidateCount: number; @@ -302,12 +404,14 @@ function buildCollisionFreePages( while (remaining.length > 0) { const selected: InteractiveElement[] = []; + const selectedIndex = new SelectedSpatialIndex(); let pageRemaining = remaining; while (pageRemaining.length > 0) { const nextSelection = chooseNextCandidate( pageRemaining, selected, + selectedIndex, viewportWidth, viewportHeight, ); @@ -316,10 +420,12 @@ function buildCollisionFreePages( break; } - selected.push({ + const placed: InteractiveElement = { ...nextSelection.candidate.element, labelPosition: nextSelection.position, - }); + }; + selected.push(placed); + selectedIndex.add(placed); pageRemaining = pageRemaining.filter( (candidate) => candidate.sourceIndex !== nextSelection.candidate.sourceIndex, @@ -347,14 +453,16 @@ function tryBuildUniformPositionPage( viewportHeight?: number, ): InteractiveElement[] | null { const selected: InteractiveElement[] = []; + const index = new SelectedSpatialIndex(); for (const element of elements) { + const nearby = nearbySelectedFor(element, position, element.id, index); if ( !isPlacementFeasible( element, element.id, position, - selected, + nearby, viewportWidth, viewportHeight, ) @@ -362,10 +470,12 @@ function tryBuildUniformPositionPage( return null; } - selected.push({ + const placed: InteractiveElement = { ...element, labelPosition: position, - }); + }; + selected.push(placed); + index.add(placed); } return selected; @@ -374,6 +484,7 @@ function tryBuildUniformPositionPage( function chooseNextCandidate( remaining: RemainingCandidate[], selected: InteractiveElement[], + selectedIndex: SelectedSpatialIndex, viewportWidth?: number, viewportHeight?: number, ): (PlacementEvaluation & { candidate: RemainingCandidate }) | null { @@ -388,6 +499,7 @@ function chooseNextCandidate( candidate.element, candidate.element.id, selected, + selectedIndex, viewportWidth, viewportHeight, ); @@ -415,6 +527,7 @@ function chooseNextCandidate( constrainedCandidate.feasiblePositions, remaining, selected, + selectedIndex, viewportWidth, viewportHeight, ), @@ -426,6 +539,7 @@ function chooseLeastBlockingPlacement( feasiblePositions: LabelPosition[], remaining: RemainingCandidate[], selected: InteractiveElement[], + selectedIndex: SelectedSpatialIndex, viewportWidth?: number, viewportHeight?: number, ): PlacementEvaluation { @@ -435,31 +549,109 @@ function chooseLeastBlockingPlacement( ); let bestPlacement: PlacementEvaluation | null = null; - for (const position of feasiblePositions) { - const hypotheticalSelected = [ - ...selected, - { - ...candidate.element, - labelPosition: position, - }, - ]; - let blockedCandidateCount = 0; - let totalFutureOptions = 0; - - futureCandidates.forEach((candidate) => { - const futureOptions = getFeasiblePositions( - candidate.element, - candidate.element.id, - hypotheticalSelected, - viewportWidth, - viewportHeight, + // Pre-compute each future candidate's baseline feasible positions against + // the current `selected` set. When we test a hypothetical placement of + // `candidate@position`, only future candidates whose bbox/label is + // geometrically near that placement can have their feasibility change. The + // rest keep their baseline feasibility — saving the O(|future|×4×|selected|) + // recomputation per position. + interface FutureBaseline { + candidate: RemainingCandidate; + elementUnion: BBox; // bbox ∪ all four label rects + feasibleCount: number; + totalLength: number; + } + const futureBaselines: FutureBaseline[] = futureCandidates.map((fc) => { + const baseline = getFeasiblePositions( + fc.element, + fc.element.id, + selected, + selectedIndex, + viewportWidth, + viewportHeight, + ); + let union = fc.element.bbox; + for (const pos of POSITION_PRIORITY) { + union = unionBBox( + union, + getLabelBBox(fc.element.bbox, pos, fc.element.id), ); + } + return { + candidate: fc, + elementUnion: union, + feasibleCount: baseline.length, + totalLength: baseline.length, + }; + }); + + const baselineBlockedCount = futureBaselines.reduce( + (acc, fb) => (fb.feasibleCount === 0 ? acc + 1 : acc), + 0, + ); + const baselineTotalOptions = futureBaselines.reduce( + (acc, fb) => acc + fb.totalLength, + 0, + ); + + for (const position of feasiblePositions) { + const hypotheticalElement: InteractiveElement = { + ...candidate.element, + labelPosition: position, + }; + const hypotheticalLabelBBox = getLabelBBox( + candidate.element.bbox, + position, + candidate.element.id, + ); + // Influence rect: anything whose elementUnion does NOT intersect this + // (inflated by clearance) cannot be affected by adding the hypothetical + // candidate. We only need to recompute for future candidates inside it. + const influenceRect = inflateBBox( + unionBBox(candidate.element.bbox, hypotheticalLabelBBox), + VISUAL_LABEL_CLEARANCE_PX, + ); - if (futureOptions.length === 0) { + let blockedCandidateCount = baselineBlockedCount; + let totalFutureOptions = baselineTotalOptions; + + for (const fb of futureBaselines) { + if (!bboxesIntersect(fb.elementUnion, influenceRect)) { + continue; + } + // Feasibility can change for this future candidate. Re-test against + // the spatially-near selected set plus the hypothetical candidate. + let updatedFeasibleLen = 0; + for (const pos of POSITION_PRIORITY) { + const nearby = nearbySelectedFor( + fb.candidate.element, + pos, + fb.candidate.element.id, + selectedIndex, + [hypotheticalElement], + ); + if ( + isPlacementFeasible( + fb.candidate.element, + fb.candidate.element.id, + pos, + nearby, + viewportWidth, + viewportHeight, + ) + ) { + updatedFeasibleLen++; + } + } + + // Adjust baseline aggregates for the delta on this single future. + if (fb.feasibleCount === 0 && updatedFeasibleLen > 0) { + blockedCandidateCount--; + } else if (fb.feasibleCount > 0 && updatedFeasibleLen === 0) { blockedCandidateCount++; } - totalFutureOptions += futureOptions.length; - }); + totalFutureOptions += updatedFeasibleLen - fb.totalLength; + } if ( !bestPlacement || @@ -492,18 +684,22 @@ function getFeasiblePositions( element: InteractiveElement, labelText: string, selected: InteractiveElement[], + selectedIndex: SelectedSpatialIndex | null, viewportWidth?: number, viewportHeight?: number, ): LabelPosition[] { const feasiblePositions: LabelPosition[] = []; for (const position of POSITION_PRIORITY) { + const nearby = selectedIndex + ? nearbySelectedFor(element, position, labelText, selectedIndex) + : selected; if ( isPlacementFeasible( element, labelText, position, - selected, + nearby, viewportWidth, viewportHeight, ) @@ -515,6 +711,28 @@ function getFeasiblePositions( return feasiblePositions; } +// Returns the subset of `selected` that could plausibly collide with the +// candidate placement. The query rect is the union of the candidate's bbox +// and its label rect for the requested position, inflated by the visible +// clearance threshold. Optional `extras` are appended (e.g. a hypothetical +// candidate not yet inserted into the index). +function nearbySelectedFor( + element: InteractiveElement, + position: LabelPosition, + labelText: string, + index: SelectedSpatialIndex, + extras: InteractiveElement[] = [], +): InteractiveElement[] { + const labelBBox = getLabelBBox(element.bbox, position, labelText); + const query = inflateBBox( + unionBBox(element.bbox, labelBBox), + VISUAL_LABEL_CLEARANCE_PX, + ); + const near = index.queryNear(query); + if (extras.length === 0) return near; + return near.concat(extras); +} + function isPlacementFeasible( element: InteractiveElement, labelText: string, diff --git a/extension/vite.config.ts b/extension/vite.config.ts index bf660fc..3ebf0bd 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -122,16 +122,18 @@ const devReloadPlugin = () => { return; } - // Otherwise wait for the extension to connect (up to 10s) + // Otherwise wait for the extension to connect (up to 40s — covers a + // full chrome.alarms keepalive cycle when the MV3 service worker has + // been terminated by Chrome). console.log( '🔄 [DevReload] Build complete — waiting for extension to connect...', ); const timeout = setTimeout(() => { console.warn( - '🔄 [DevReload] No extension connected within 10s. Reload the extension manually once, then future `npm run dev` runs will auto-reload.', + '🔄 [DevReload] No extension connected within 40s. Reload the extension manually once, then future `npm run dev` runs will auto-reload.', ); process.exit(0); - }, 10_000); + }, 40_000); // Check periodically if a client has connected const poll = setInterval(() => {