import { compareVisualSurfacePriority, type Rect } from "@browser-skill/vom"; import type { ClusteredRenderedSurfaces, RenderedSurface, RenderedSurfaceGroup } from "./types"; const STACK_IOU_RATIO = 0.9; const STACK_SIZE_RATIO = 0.9; const SIZE_BAND_BASE = 1 / STACK_SIZE_RATIO; const POSITION_CELL_RATIO = 0.1; const MAX_CLUSTER_COMPARISONS = 1_000_000; const MAX_CLUSTER_SURFACES = 50_000; export interface ClusterRenderedSurfaceOptions { maxComparisons?: number; maxSurfaces?: number; } interface IndexedGroup extends RenderedSurfaceGroup { creationIndex: number; indexKey: string; } function area(rect: Rect): number { return Math.max(0, rect.w) * Math.max(0, rect.h); } function intersectionArea(a: Rect, b: Rect): number { const width = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)); const height = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)); return width * height; } function stackScore(a: RenderedSurface, b: RenderedSurface): number | null { const aArea = area(a.visibleRect); const bArea = area(b.visibleRect); const intersection = intersectionArea(a.visibleRect, b.visibleRect); const union = aArea + bArea - intersection; const widthRatio = Math.min(a.visibleRect.w, b.visibleRect.w) / Math.max(a.visibleRect.w, b.visibleRect.w); const heightRatio = Math.min(a.visibleRect.h, b.visibleRect.h) / Math.max(a.visibleRect.h, b.visibleRect.h); const iou = union > 0 ? intersection / union : 0; return iou >= STACK_IOU_RATIO && widthRatio >= STACK_SIZE_RATIO && heightRatio >= STACK_SIZE_RATIO ? iou : null; } function sizeBand(value: number): number { return Math.floor(Math.log(Math.max(Number.EPSILON, value)) / Math.log(SIZE_BAND_BASE)); } function bandSize(band: number): number { return SIZE_BAND_BASE ** band; } function spatialKey(rect: Rect): string { const widthBand = sizeBand(rect.w); const heightBand = sizeBand(rect.h); const cellWidth = bandSize(widthBand) * POSITION_CELL_RATIO; const cellHeight = bandSize(heightBand) * POSITION_CELL_RATIO; const centerX = rect.x + rect.w / 2; const centerY = rect.y + rect.h / 2; return `${widthBand}:${heightBand}:${Math.floor(centerX / cellWidth)}:${Math.floor(centerY / cellHeight)}`; } function candidateKeys(rect: Rect): string[] { const keys: string[] = []; const centerX = rect.x + rect.w / 2; const centerY = rect.y + rect.h / 2; const widthBand = sizeBand(rect.w); const heightBand = sizeBand(rect.h); // A >=0.9 size ratio crosses at most one logarithmic band. IoU >=0.9 // likewise keeps centers within one 10%-of-size position cell. for (let dw = -1; dw <= 1; dw += 1) { for (let dh = -1; dh <= 1; dh += 1) { const candidateWidthBand = widthBand + dw; const candidateHeightBand = heightBand + dh; const cellWidth = bandSize(candidateWidthBand) * POSITION_CELL_RATIO; const cellHeight = bandSize(candidateHeightBand) * POSITION_CELL_RATIO; const cellX = Math.floor(centerX / cellWidth); const cellY = Math.floor(centerY / cellHeight); for (let dx = -1; dx <= 1; dx += 1) { for (let dy = -1; dy <= 1; dy += 1) { keys.push(`${candidateWidthBand}:${candidateHeightBand}:${cellX + dx}:${cellY + dy}`); } } } } return keys; } function preferredRepresentative(a: RenderedSurface, b: RenderedSurface): RenderedSurface { const aArea = area(a.visibleRect); const bArea = area(b.visibleRect); return bArea > aArea || (bArea === aArea && b.paintOrder > a.paintOrder) ? b : a; } function partitionKey(surface: RenderedSurface): string { return `${surface.frameId}\u0000${surface.parentBackendNodeId ?? "root"}`; } function siftWorstUp(heap: RenderedSurface[], start: number): void { let index = start; while (index > 0) { const parent = Math.floor((index - 1) / 2); if (compareVisualSurfacePriority(heap[index], heap[parent]) <= 0) break; [heap[index], heap[parent]] = [heap[parent], heap[index]]; index = parent; } } function siftWorstDown(heap: RenderedSurface[], start: number): void { let index = start; while (true) { const left = index * 2 + 1; if (left >= heap.length) return; const right = left + 1; let worst = left; if (right < heap.length && compareVisualSurfacePriority(heap[right], heap[left]) > 0) { worst = right; } if (compareVisualSurfacePriority(heap[worst], heap[index]) <= 0) return; [heap[index], heap[worst]] = [heap[worst], heap[index]]; index = worst; } } function highestPrioritySurfaces( surfaces: readonly RenderedSurface[], limit: number, ): RenderedSurface[] { if (limit <= 0) return []; if (surfaces.length <= limit) return [...surfaces].sort(compareVisualSurfacePriority); const heap: RenderedSurface[] = []; for (const surface of surfaces) { if (heap.length < limit) { heap.push(surface); siftWorstUp(heap, heap.length - 1); } else if (compareVisualSurfacePriority(surface, heap[0]) < 0) { heap[0] = surface; siftWorstDown(heap, 0); } } return heap.sort(compareVisualSurfacePriority); } function normalizedLimit(value: number | undefined, fallback: number): number { const resolved = value ?? fallback; return Number.isFinite(resolved) ? Math.max(0, Math.floor(resolved)) : fallback; } export function clusterRenderedSurfaces( surfaces: RenderedSurface[], options: ClusterRenderedSurfaceOptions = {}, ): ClusteredRenderedSurfaces { const maxComparisons = normalizedLimit(options.maxComparisons, MAX_CLUSTER_COMPARISONS); const maxSurfaces = normalizedLimit(options.maxSurfaces, MAX_CLUSTER_SURFACES); const prioritized = highestPrioritySurfaces(surfaces, maxSurfaces); const partitions = new Map>>(); const groups: IndexedGroup[] = []; let comparisons = 0; let processed = 0; let truncated = prioritized.length < surfaces.length; let comparisonLimitReached = false; for (const surface of prioritized) { if (comparisons >= maxComparisons) { truncated = true; break; } const key = partitionKey(surface); let index = partitions.get(key); if (!index) { index = new Map(); partitions.set(key, index); } const candidates = new Set(); for (const candidateKey of candidateKeys(surface.visibleRect)) { for (const candidate of index.get(candidateKey) ?? []) candidates.add(candidate); } let best: { group: IndexedGroup; score: number; areaDelta: number } | null = null; for (const group of candidates) { if (comparisons >= maxComparisons) { truncated = true; comparisonLimitReached = true; break; } comparisons += 1; const score = stackScore(group.representative, surface); if (score === null) continue; const areaDelta = Math.abs( area(group.representative.visibleRect) - area(surface.visibleRect), ); if ( !best || score > best.score || (score === best.score && areaDelta < best.areaDelta) || (score === best.score && areaDelta === best.areaDelta && group.creationIndex < best.group.creationIndex) ) { best = { group, score, areaDelta }; } } if (comparisonLimitReached) break; if (best) { const group = best.group; const representative = preferredRepresentative(group.representative, surface); group.memberCount += 1; group.label ??= surface.label; if (representative !== group.representative) { index.get(group.indexKey)?.delete(group); group.representative = representative; group.indexKey = spatialKey(representative.visibleRect); const indexed = index.get(group.indexKey) ?? new Set(); indexed.add(group); index.set(group.indexKey, indexed); } processed += 1; continue; } const indexKey = spatialKey(surface.visibleRect); const group: IndexedGroup = { frameId: surface.frameId, parentBackendNodeId: surface.parentBackendNodeId, representative: surface, ...(surface.label ? { label: surface.label } : {}), memberCount: 1, creationIndex: groups.length, indexKey, }; groups.push(group); const indexed = index.get(indexKey) ?? new Set(); indexed.add(group); index.set(indexKey, indexed); processed += 1; } return { groups: groups .map(({ creationIndex: _creationIndex, indexKey: _indexKey, ...group }) => group) .sort( (a, b) => a.frameId.localeCompare(b.frameId) || a.representative.visibleRect.y - b.representative.visibleRect.y || a.representative.visibleRect.x - b.representative.visibleRect.x, ), truncated, ...(truncated ? { omittedCount: surfaces.length - processed } : {}), }; }