import { ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED, normalizeAllocationStrategy, } from './detectionPolicy.js'; const CELL_SIZE_PX = 32; const EMPTY_CANDIDATES = Object.freeze([]); const FADE_IN_MS = 150; const FADE_OUT_MS = 300; const MIN_LIFETIME_MS = 2500; const COOLDOWN_MS = 1200; function zeroCount(_value, key, map) { map.set(key, 0); } function clampInt(value, min, max) { return Math.max(min, Math.min(max, Math.floor(Number(value) || 0))); } function demandEntries(demandByLayer) { const entries = demandByLayer instanceof Map ? Array.from(demandByLayer.entries()) : Object.entries(demandByLayer || {}); return entries .map(([layerId, demand]) => [ String(layerId), Math.max(0, Math.floor(Number(demand) || 0)), ]) .filter(([, demand]) => demand > 0) .sort(([a], [b]) => a.localeCompare(b)); } function redistributeUnused(quotas, demand, capacity, order) { let used = Array.from(quotas.values()).reduce((sum, value) => sum + value, 0); while (used < capacity) { let changed = false; for (const layerId of order) { const current = quotas.get(layerId) || 0; if (current >= (demand.get(layerId) || 0)) continue; quotas.set(layerId, current + 1); used++; changed = true; if (used >= capacity) break; } if (!changed) break; } return quotas; } function allocateLayerQuotasInto( demand, capacity, strategy, layerWeights, ids, idCount, quotas, weighted, priorityOrder, remainders, ) { quotas.forEach((value, layerId) => { if (!demand.has(layerId)) quotas.delete(layerId); }); for (let i = 0; i < idCount; i++) quotas.set(ids[i], 0); if (capacity === 0 || idCount === 0) return quotas; if (strategy === ALLOCATION_ELASTIC) { const base = Math.floor(capacity / idCount); let remainder = capacity % idCount; let used = 0; for (let i = 0; i < idCount; i++) { const layerId = ids[i]; const entitlement = base + (remainder > 0 ? 1 : 0); if (remainder > 0) remainder--; const quota = Math.min(demand.get(layerId) || 0, entitlement); quotas.set(layerId, quota); used += quota; } while (used < capacity) { let changed = false; for (let i = 0; i < idCount && used < capacity; i++) { const layerId = ids[i]; const current = quotas.get(layerId) || 0; if (current >= (demand.get(layerId) || 0)) continue; quotas.set(layerId, current + 1); used++; changed = true; } if (!changed) break; } return quotas; } let totalWeight = 0; for (let i = 0; i < idCount; i++) { const layerId = ids[i]; const count = demand.get(layerId) || 0; const semanticWeight = Math.max(0.05, Number(layerWeights?.[layerId]) || 1); const entry = weighted[i] || (weighted[i] = {}); entry.layerId = layerId; entry.count = count; entry.weight = Math.sqrt(count) * semanticWeight; entry.fraction = 0; priorityOrder[i] = entry; remainders[i] = entry; totalWeight += entry.weight; } weighted.length = idCount; priorityOrder.length = idCount; remainders.length = idCount; priorityOrder.sort( (a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId), ); let remaining = capacity; if (capacity >= idCount) { for (let i = 0; i < idCount; i++) quotas.set(ids[i], 1); remaining -= idCount; } if (remaining > 0 && totalWeight > 0) { let apportioned = 0; for (let i = 0; i < idCount; i++) { const entry = weighted[i]; const exact = (remaining * entry.weight) / totalWeight; const room = entry.count - (quotas.get(entry.layerId) || 0); const whole = Math.min(room, Math.floor(exact)); quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole); apportioned += whole; entry.fraction = exact - Math.floor(exact); } remaining -= apportioned; remainders.sort( (a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId), ); for (let i = 0; i < idCount && remaining > 0; i++) { const entry = remainders[i]; const current = quotas.get(entry.layerId) || 0; if (current >= (demand.get(entry.layerId) || 0)) continue; quotas.set(entry.layerId, current + 1); remaining--; } } let used = 0; for (let i = 0; i < idCount; i++) used += quotas.get(ids[i]) || 0; while (used < capacity) { let changed = false; for (let i = 0; i < idCount && used < capacity; i++) { const layerId = priorityOrder[i].layerId; const current = quotas.get(layerId) || 0; if (current >= (demand.get(layerId) || 0)) continue; quotas.set(layerId, current + 1); used++; changed = true; } if (!changed) break; } return quotas; } /** * Allocate a collective capacity across non-empty layers. Both strategies are * work-conserving and deterministic; unused entitlement is always borrowed. */ export function allocateLayerQuotas( demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}, ) { const entries = demandEntries(demandByLayer); const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0); const cap = clampInt(capacity, 0, totalDemand); const quotas = new Map(entries.map(([layerId]) => [layerId, 0])); if (cap === 0 || entries.length === 0) return quotas; const demand = new Map(entries); const ids = entries.map(([layerId]) => layerId); const normalizedStrategy = normalizeAllocationStrategy(strategy); if (normalizedStrategy === ALLOCATION_ELASTIC) { const base = Math.floor(cap / ids.length); let remainder = cap % ids.length; for (const layerId of ids) { const entitlement = base + (remainder > 0 ? 1 : 0); remainder = Math.max(0, remainder - 1); quotas.set(layerId, Math.min(demand.get(layerId), entitlement)); } return redistributeUnused(quotas, demand, cap, ids); } const weighted = entries.map(([layerId, count]) => { const semanticWeight = Math.max(0.05, Number(layerWeights[layerId]) || 1); return { layerId, count, weight: Math.sqrt(count) * semanticWeight }; }); const priorityOrder = weighted .slice() .sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId)) .map((entry) => entry.layerId); let remaining = cap; if (cap >= ids.length) { for (const layerId of ids) quotas.set(layerId, 1); remaining -= ids.length; } const totalWeight = weighted.reduce((sum, entry) => sum + entry.weight, 0); const remainders = []; if (remaining > 0 && totalWeight > 0) { let apportioned = 0; for (const entry of weighted) { const exact = (remaining * entry.weight) / totalWeight; const room = entry.count - (quotas.get(entry.layerId) || 0); const whole = Math.min(room, Math.floor(exact)); quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole); apportioned += whole; remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight, }); } remaining -= apportioned; remainders.sort( (a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId), ); for (const entry of remainders) { if (remaining <= 0) break; const current = quotas.get(entry.layerId) || 0; if (current >= demand.get(entry.layerId)) continue; quotas.set(entry.layerId, current + 1); remaining--; } } return redistributeUnused(quotas, demand, cap, priorityOrder); } function rectIsFinite(rect) { return ( rect && Number.isFinite(rect.x) && Number.isFinite(rect.y) && Number.isFinite(rect.w) && Number.isFinite(rect.h) && rect.w > 0 && rect.h > 0 ); } function overlaps(a, b, padding = 4) { return ( a.x < b.x + b.w + padding && a.x + a.w + padding > b.x && a.y < b.y + b.h + padding && a.y + a.h + padding > b.y ); } const CELL_ORIGIN = 8192; /** * Reusable uniform grid over placement rectangles. Buckets and their numeric * cell keys survive across solves; the previous contents are invalidated by a * generation stamp instead of rebuilding a Map of string-keyed arrays. The * duplicate-visit set the original kept is unnecessary — `overlaps` is pure, so * re-testing the same rectangle in a second cell returns the same answer. */ class SpatialHash { constructor(cellSize = CELL_SIZE_PX) { this.cellSize = cellSize; this.cells = new Map(); this.generation = 0; } reset() { this.generation++; } clear() { this.cells.clear(); this.generation = 0; } /** * Pack a cell coordinate pair into one Smi. Coordinates are clamped to * +/-8192 cells (+/-262,144 px at the 32 px cell size) so the key stays a * Smi; anything beyond that folds into the edge cells. The clamp is * conservative, never lossy: two overlapping rectangles always share at * least one unclamped cell, which maps to the same clamped key, so folding * can only add candidate comparisons — `overlaps` still decides — and can * never hide a real collision. */ _cellKey(x, y) { const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN; const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN; // Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a // Smi; arithmetic multiplication materializes a boxed double at each // spatial-hash get/add on this hot solve path. return (cx << 14) | cy; } collides(rect) { const size = this.cellSize; const x1 = Math.floor((rect.x + rect.w) / size); const y1 = Math.floor((rect.y + rect.h) / size); for (let y = Math.floor(rect.y / size); y <= y1; y++) { for (let x = Math.floor(rect.x / size); x <= x1; x++) { const bucket = this.cells.get(this._cellKey(x, y)); if (!bucket || bucket.generation !== this.generation) continue; for (let i = 0; i < bucket.count; i++) { if (overlaps(rect, bucket.rects[i])) return true; } } } return false; } add(rect) { const size = this.cellSize; const x1 = Math.floor((rect.x + rect.w) / size); const y1 = Math.floor((rect.y + rect.h) / size); for (let y = Math.floor(rect.y / size); y <= y1; y++) { for (let x = Math.floor(rect.x / size); x <= x1; x++) { const key = this._cellKey(x, y); let bucket = this.cells.get(key); if (!bucket) { bucket = { generation: this.generation, count: 0, rects: [] }; this.cells.set(key, bucket); } else if (bucket.generation !== this.generation) { bucket.generation = this.generation; bucket.count = 0; } bucket.rects[bucket.count++] = rect; } } } } // Only the sign of a sort comparator is observed, so every branch returns a // small integer. A fractional (double) result would be boxed on the way out of // the comparator on every comparison of every solve. function candidateCompare(a, b, states, now) { const aState = states.get(a.key); const bState = states.get(b.key); // A stateless candidate is never lifetime-pinned: its source shipped as a // per-frame rebuild, so it must be free to lose its slot the moment something // better wants it. Plain incumbency ordering below still applies. const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS; const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS; if (aPinned !== bPinned) return aPinned ? -1 : 1; const aIncumbent = !!aState?.selected; const bIncumbent = !!bState?.selected; if (aIncumbent !== bIncumbent) return aIncumbent ? -1 : 1; const aPriority = Number(a.priority) || 0; const bPriority = Number(b.priority) || 0; if (aPriority !== bPriority) return bPriority > aPriority ? 1 : -1; const aAlpha = Number(a.keyholeAlpha) || 0; const bAlpha = Number(b.keyholeAlpha) || 0; if (aAlpha !== bAlpha) return bAlpha > aAlpha ? 1 : -1; const aDistance = Number(a.centerDistance) || 0; const bDistance = Number(b.centerDistance) || 0; if (aDistance !== bDistance) return aDistance > bDistance ? 1 : -1; return String(a.key).localeCompare(String(b.key)); } function cacheCandidateAnchorScalars(candidate) { let anchorX = Number.NaN; let anchorY = Number.NaN; if ( Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY) ) { anchorX = candidate.screenX; anchorY = candidate.screenY; } else { const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null; if ( Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY) ) { anchorX = placement.leadFromX; anchorY = placement.leadFromY; } else if (rectIsFinite(placement?.rect)) { anchorX = placement.rect.x + placement.rect.w * 0.5; anchorY = placement.rect.y + placement.rect.h * 0.5; } } candidate._anchorX = anchorX; candidate._anchorY = anchorY; } function visibilityBand(candidate) { const alpha = Math.max(0, Math.min(1, Number(candidate?.keyholeAlpha) || 0)); return alpha >= 0.999 ? 8 : Math.floor(alpha * 8); } /** * Incremental farthest-point selector for new labels. Semantic priority and * keyhole visibility remain primary; spatial separation only breaks ties. * This prevents the first solve from walking inward-to-outward by * centerDistance and packing an equal-priority satellite cohort around Earth. */ class SpatialCandidateQueue { constructor() { this.candidates = null; this.count = 0; this.anchorCount = 0; this.distances = new Float64Array(0); this.anchorXs = new Float64Array(0); this.anchorYs = new Float64Array(0); this.placementMasks = new Uint8Array(0); this.placementXs = new Float64Array(0); this.placementYs = new Float64Array(0); this.placementWs = new Float64Array(0); this.placementHs = new Float64Array(0); this.seedAnchorXs = new Float64Array(0); this.seedAnchorYs = new Float64Array(0); this.attemptStamps = null; this.stamp = 0; this.spatial = null; this.states = null; } /** * Re-target the queue at a caller-owned candidate slice. Per-candidate * spread state lives in a pooled numeric buffer, so computed squared doubles * stay unboxed. Few-placement candidates already blocked by the * monotonically growing collision field are dismissed before anchor scans. */ reset( candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states, ) { this.candidates = candidates; this.count = count; this.anchorCount = anchorCount; this.attemptStamps = attemptStamps; this.stamp = stamp; this.spatial = spatial; this.states = states; if (this.distances.length < count) { let capacity = Math.max(16, this.distances.length); while (capacity < count) capacity *= 2; this.distances = new Float64Array(capacity); this.anchorXs = new Float64Array(capacity); this.anchorYs = new Float64Array(capacity); this.placementMasks = new Uint8Array(capacity); this.placementXs = new Float64Array(capacity * 4); this.placementYs = new Float64Array(capacity * 4); this.placementWs = new Float64Array(capacity * 4); this.placementHs = new Float64Array(capacity * 4); } if (this.seedAnchorXs.length < anchorCount) { let anchorCapacity = Math.max(16, this.seedAnchorXs.length); while (anchorCapacity < anchorCount) anchorCapacity *= 2; this.seedAnchorXs = new Float64Array(anchorCapacity); this.seedAnchorYs = new Float64Array(anchorCapacity); } for (let i = 0; i < anchorCount; i++) { this.seedAnchorXs[i] = anchors[i]._anchorX; this.seedAnchorYs[i] = anchors[i]._anchorY; } for (let i = 0; i < count; i++) { const candidate = candidates[i]; this.distances[i] = Number.POSITIVE_INFINITY; this.anchorXs[i] = candidate._anchorX; this.anchorYs[i] = candidate._anchorY; this.placementMasks[i] = 255; if (attemptStamps.get(candidate.key) === stamp) continue; const placements = candidate.placements; if (!Array.isArray(placements) || placements.length > 4) continue; let freeMask = 0; for (let p = 0; p < placements.length; p++) { const rect = placements[p]?.rect; if (!rectIsFinite(rect)) continue; const rectIndex = i * 4 + p; this.placementXs[rectIndex] = rect.x; this.placementYs[rectIndex] = rect.y; this.placementWs[rectIndex] = rect.w; this.placementHs[rectIndex] = rect.h; if (!spatial.collides(rect)) freeMask |= 1 << p; } this.placementMasks[i] = freeMask; if (freeMask === 0) this._dismissBlockedKey(i); } for (let i = 0; i < count; i++) { const candidate = candidates[i]; if (attemptStamps.get(candidate.key) === stamp) continue; const pointX = this.anchorXs[i]; const pointY = this.anchorYs[i]; if (!Number.isFinite(pointX) || !Number.isFinite(pointY)) continue; let minimumDistanceSq = Number.POSITIVE_INFINITY; for (let a = 0; a < anchorCount; a++) { const dx = pointX - this.seedAnchorXs[a]; const dy = pointY - this.seedAnchorYs[a]; const distanceSq = dx * dx + dy * dy; if (distanceSq < minimumDistanceSq) minimumDistanceSq = distanceSq; } this.distances[i] = minimumDistanceSq; } } /** * Dismiss a blocked few-placement identity once. Collision occupancy only * grows during a solve, so it can never become placeable later. Duplicate * keys retain their historical single-attempt semantics: a free duplicate * prevents the shared key from being dismissed. */ _dismissBlockedKey(index) { const candidate = this.candidates[index]; const sticky = this.states.get(candidate.key)?.corner; if (firstFreePlacement(candidate, sticky, this.spatial)) { this.placementMasks[index] = 255; return false; } for (let i = 0; i < this.count; i++) { if (i === index) continue; const duplicate = this.candidates[i]; if (duplicate.key !== candidate.key) continue; const duplicateSticky = this.states.get(duplicate.key)?.corner; if (firstFreePlacement(duplicate, duplicateSticky, this.spatial)) { this.placementMasks[i] = 255; return false; } } this.attemptStamps.set(candidate.key, this.stamp); return true; } addAnchor(candidate, placement) { const anchorX = candidate?._anchorX; const anchorY = candidate?._anchorY; if (!Number.isFinite(anchorX) || !Number.isFinite(anchorY)) return; this.anchorCount++; const distances = this.distances; const anchorXs = this.anchorXs; const anchorYs = this.anchorYs; const placementMasks = this.placementMasks; const placementXs = this.placementXs; const placementYs = this.placementYs; const placementWs = this.placementWs; const placementHs = this.placementHs; const anchorRect = placement?.rect; const anchorRectX = anchorRect?.x; const anchorRectY = anchorRect?.y; const anchorRectW = anchorRect?.w; const anchorRectH = anchorRect?.h; for (let i = 0; i < this.count; i++) { const other = this.candidates[i]; if (this.attemptStamps.get(other.key) === this.stamp) continue; if (placementMasks[i] !== 255) { let freeMask = placementMasks[i]; for (let p = 0; freeMask !== 0 && p < other.placements.length; p++) { const bit = 1 << p; if ((freeMask & bit) === 0) continue; const rectIndex = i * 4 + p; const rectX = placementXs[rectIndex]; const rectY = placementYs[rectIndex]; const rectW = placementWs[rectIndex]; const rectH = placementHs[rectIndex]; if ( rectX < anchorRectX + anchorRectW + 4 && rectX + rectW + 4 > anchorRectX && rectY < anchorRectY + anchorRectH + 4 && rectY + rectH + 4 > anchorRectY ) freeMask &= ~bit; } placementMasks[i] = freeMask; if (freeMask === 0 && this._dismissBlockedKey(i)) continue; } const pointX = anchorXs[i]; const pointY = anchorYs[i]; if (!Number.isFinite(pointX) || !Number.isFinite(pointY)) continue; const dx = pointX - anchorX; const dy = pointY - anchorY; const distanceSq = dx * dx + dy * dy; if (distanceSq < distances[i]) distances[i] = distanceSq; } } next(attemptStamps, stamp) { let best = null; let bestIndex = -1; for (let i = 0; i < this.count; i++) { const candidate = this.candidates[i]; if (attemptStamps.get(candidate.key) === stamp) continue; if (!best) { best = candidate; bestIndex = i; continue; } const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0); if (priorityDelta !== 0) { if (priorityDelta > 0) { best = candidate; bestIndex = i; } continue; } const visibilityDelta = visibilityBand(candidate) - visibilityBand(best); if (visibilityDelta !== 0) { if (visibilityDelta > 0) { best = candidate; bestIndex = i; } continue; } if (this.anchorCount > 0) { const spreadDelta = this.distances[i] - this.distances[bestIndex]; if (spreadDelta !== 0) { if (spreadDelta > 0) { best = candidate; bestIndex = i; } continue; } } if (String(candidate.key).localeCompare(String(best.key)) < 0) { best = candidate; bestIndex = i; } } return best; } } /** * First placement in the canonical attempt order — finite rectangles only, * sticky corner first. Equivalent to the head of the filtered/stable-sorted * list the original built, without materializing it. */ function firstOrderedPlacement(candidate, stickyCorner) { const placements = candidate?.placements; if (!Array.isArray(placements)) return null; let first = null; for (let i = 0; i < placements.length; i++) { const placement = placements[i]; if (!rectIsFinite(placement?.rect)) continue; if (stickyCorner && placement.corner === stickyCorner) return placement; first ||= placement; } return first; } /** * Walk the canonical attempt order and return the first placement the spatial * hash accepts. The collision probes happen in exactly the original order. */ function firstFreePlacement(candidate, stickyCorner, spatial) { const placements = candidate?.placements; if (!Array.isArray(placements)) return null; if (stickyCorner) { for (let i = 0; i < placements.length; i++) { const placement = placements[i]; if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue; if (!spatial.collides(placement.rect)) return placement; } } for (let i = 0; i < placements.length; i++) { const placement = placements[i]; if (!rectIsFinite(placement?.rect)) continue; if (stickyCorner && placement.corner === stickyCorner) continue; if (!spatial.collides(placement.rect)) return placement; } return null; } /** * Per-solve identity used by the key-stamp maps. A module-level counter keeps * stamps unique across every arbiter instance sharing a candidate object. */ let _globalSolveStamp = 0; /** Drop a stamp index once it holds far more keys than the live cohort. */ function pruneStampMap(map, liveCount) { if (map.size > liveCount * 4 + 64) map.clear(); } function resetLayerBucket(bucket) { bucket.count = 0; } /** * Stable in-place insertion sort over a pooled array prefix. Frame-to-frame the * cohort order barely changes, so this beats `Array#sort`, whose TimSort work * buffers are re-allocated on every solve. */ function sortCandidateRange(items, count, states, now) { for (let i = 1; i < count; i++) { const item = items[i]; let j = i - 1; while (j >= 0 && candidateCompare(items[j], item, states, now) > 0) { items[j + 1] = items[j]; j--; } items[j + 1] = item; } } /** * Resolve the current render placement without materializing or sorting an * intermediate list. This is equivalent to `orderedPlacements(...).find(...)` * for every finite-placement/sticky-corner combination used by renderEntries. */ function renderPlacement(candidate, stickyCorner, fallback) { const placements = candidate?.placements; if (!Array.isArray(placements)) return fallback; let first = null; for (let i = 0; i < placements.length; i++) { const placement = placements[i]; if (!rectIsFinite(placement?.rect)) continue; first ||= placement; if (placement.corner === stickyCorner) return placement; } return first || fallback; } /** Stateful stable label selector shared by Sparse, Balanced, and Dense. */ export class LabelArbiter { constructor() { this.states = new Map(); this.selectedKeys = new Set(); this.solveRevision = 0; this.lastDiagnostics = null; this.lastActiveLayers = []; // Pooled solve working set. Everything here is candidate-scale and is // reused across solves so a moving cohort re-solves without churn. this._stateList = []; this._stateListDirty = true; this._candidates = []; this._layerBuckets = new Map(); this._demand = new Map(); this._quotas = new Map(); this._acceptedByLayer = new Map(); this._paintedByLayer = new Map(); this._weightedQuotaScratch = []; this._quotaPriorityScratch = []; this._quotaRemainderScratch = []; this._activeLayersScratch = []; this._selectedCandidates = []; this._selectedPlacements = []; this._droppedKeys = []; this._attemptStamps = new Map(); this._selectStamps = new Map(); this._spatial = new SpatialHash(); this._queue = new SpatialCandidateQueue(); } /** Release an identity when its source changes between ambient and protected paint. */ remove(key) { if (this.states.delete(key)) this._stateListDirty = true; this.selectedKeys.delete(key); } clear() { this.states.clear(); this.selectedKeys.clear(); this.solveRevision = 0; this.lastDiagnostics = null; this.lastActiveLayers = []; this._stateList.length = 0; this._stateListDirty = true; this._candidates.length = 0; this._layerBuckets.clear(); this._quotas.clear(); this._acceptedByLayer.clear(); this._paintedByLayer.clear(); this._selectedCandidates.length = 0; this._selectedPlacements.length = 0; this._droppedKeys.length = 0; this._attemptStamps.clear(); this._selectStamps.clear(); this._spatial.clear(); } /** Refresh the pooled state list when solve membership has moved on. */ _refreshStateList() { if (!this._stateListDirty) return this._stateList; const list = this._stateList; let index = 0; for (const state of this.states.values()) list[index++] = state; list.length = index; this._stateListDirty = false; return list; } /** * Number of live states, for indexed per-frame iteration. Callers must not * receive the pooled backing array — a caller that mutated it would desync * the arbiter permanently — and must not iterate `states` directly either, * because a Map iterator allocates a result object per step. * @returns {number} */ activeStateCount() { return this._refreshStateList().length; } /** * Live state at `index`, valid for the current solve generation. * @param {number} index * @returns {object|undefined} */ activeStateAt(index) { return this._refreshStateList()[index]; } /** * Select a bounded, stable label cohort. The selection is byte-for-byte the * same as the original list-building implementation; only the working set is * pooled, so a moving cohort re-solves without per-candidate allocation. * * Duplicate keys: attempt and selection membership are tracked per KEY (the * stamp maps), exactly as the original `Set`/`Map` did, so two candidate * objects sharing one key still get a single attempt and a single slot. The * queue spread distances and few-placement masks live in candidate-indexed * pooled storage, so duplicates keep independent spatial state — harmless, * because only the one that wins the key is ever selected, but callers * should keep keys unique per solve. */ solve(candidates, options = {}) { const now = Number.isFinite(options.now) ? options.now : Date.now(); const collectDiagnostics = options.collectDiagnostics !== false; const stamp = ++_globalSolveStamp; const attemptStamps = this._attemptStamps; const selectStamps = this._selectStamps; const candidateList = this._candidates; const source = Array.isArray(candidates) ? candidates : EMPTY_CANDIDATES; const byLayer = this._layerBuckets; byLayer.forEach(resetLayerBucket); let candidateCount = 0; for (let i = 0; i < source.length; i++) { const candidate = source[i]; if ( !candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0) ) continue; cacheCandidateAnchorScalars(candidate); candidateList[candidateCount++] = candidate; let bucket = byLayer.get(candidate.layerId); if (!bucket) { bucket = { list: [], count: 0 }; byLayer.set(candidate.layerId, bucket); } bucket.list[bucket.count++] = candidate; } candidateList.length = candidateCount; byLayer.forEach((bucket) => { bucket.list.length = bucket.count; sortCandidateRange(bucket.list, bucket.count, this.states, now); }); const demand = this._demand; demand.clear(); if (options.demandByLayer instanceof Map) { options.demandByLayer.forEach((count, layerId) => { const normalized = Math.max(0, Math.floor(Number(count) || 0)); if (normalized > 0) demand.set(String(layerId), normalized); }); } else if (options.demandByLayer != null) { const supplied = demandEntries(options.demandByLayer); for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]); } // A supplied demand map describes the full lightweight field while the // candidate list may be only a bounded materialized cohort. Keep defensive // support for candidate layers omitted by an incomplete caller map. byLayer.forEach((bucket, layerId) => { if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count); }); const limits = options.limitsByLayer; demand.forEach((count, layerId) => { demand.set(layerId, Math.min(count, limits?.get(layerId) ?? Infinity)); }); const acceptedByLayer = this._acceptedByLayer; acceptedByLayer.forEach(zeroCount); const activeLayers = this._activeLayersScratch; let activeCount = 0; let totalDemand = 0; demand.forEach((count, layerId) => { totalDemand += count; activeLayers[activeCount++] = layerId; }); activeLayers.length = activeCount; activeLayers.sort(); const capacity = clampInt(options.capacity, 0, totalDemand); const strategy = normalizeAllocationStrategy(options.strategy); const quotas = allocateLayerQuotasInto( demand, capacity, strategy, options.layerWeights, activeLayers, activeCount, this._quotas, this._weightedQuotaScratch, this._quotaPriorityScratch, this._quotaRemainderScratch, ); const spatial = this._spatial; spatial.reset(); const queue = this._queue; const selectedCandidates = this._selectedCandidates; const selectedPlacements = this._selectedPlacements; let selectedCount = 0; let sameActiveLayers = activeCount === this.lastActiveLayers.length; for (let i = 0; sameActiveLayers && i < activeCount; i++) { if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false; } const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers; let spatialQueueBuildCount = 0; let spatialQueueNextCount = 0; const attempt = (candidate, allowOverlapFallback = false) => { if ( selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp ) return false; attemptStamps.set(candidate.key, stamp); const layerCount = acceptedByLayer.get(candidate.layerId) || 0; if (layerCount >= (limits?.get(candidate.layerId) ?? Infinity)) return false; const previous = this.states.get(candidate.key); const stateless = candidate.stateless === true; // Stateless candidates ignore the re-entry cooldown (they return on the // very next solve, as their stateless originals did) and carry no sticky // corner, so above/below is re-decided from geometry every solve instead // of being pinned by whichever side happened to be free once. if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false; const sticky = stateless ? undefined : previous?.corner; let placement = firstFreePlacement(candidate, sticky, spatial); if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky); if (!placement) return false; spatial.add(placement.rect); selectStamps.set(candidate.key, stamp); selectedCandidates[selectedCount] = candidate; selectedPlacements[selectedCount] = placement; selectedCount++; acceptedByLayer.set(candidate.layerId, layerCount + 1); return true; }; for (let l = 0; l < activeCount; l++) { const layerId = activeLayers[l]; const target = quotas.get(layerId) || 0; const bucket = byLayer.get(layerId); const layerList = bucket ? bucket.list : EMPTY_CANDIDATES; const layerCount = bucket ? bucket.count : 0; let accepted = 0; if (preserveIncumbents) { for (let i = 0; i < layerCount && accepted < target; i++) { const candidate = layerList[i]; if (candidate.stateless || !this.states.get(candidate.key)?.selected) continue; if (attempt(candidate, true)) accepted++; } } if (accepted < target) { spatialQueueBuildCount++; queue.reset( layerList, layerCount, selectedCandidates, selectedCount, attemptStamps, stamp, spatial, this.states, ); while (accepted < target) { spatialQueueNextCount++; const candidate = queue.next(attemptStamps, stamp); if (!candidate) break; if (attempt(candidate)) { accepted++; queue.addAnchor(candidate, selectedPlacements[selectedCount - 1]); } } } } if (selectedCount < capacity) { spatialQueueBuildCount++; queue.reset( candidateList, candidateCount, selectedCandidates, selectedCount, attemptStamps, stamp, spatial, this.states, ); while (selectedCount < capacity) { spatialQueueNextCount++; const candidate = queue.next(attemptStamps, stamp); if (!candidate) break; if (attempt(candidate)) { queue.addAnchor(candidate, selectedPlacements[selectedCount - 1]); } } } // `selectedKeys` still holds the previous solve's winners here, so both // incumbent tallies are read before it is updated in place. let eligibleIncumbents = 0; for (let i = 0; i < candidateCount; i++) { if (this.selectedKeys.has(candidateList[i].key)) eligibleIncumbents++; } let retainedIncumbents = 0; for (let i = 0; i < selectedCount; i++) { if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++; } const exitingStates = this._refreshStateList(); for (let i = 0; i < exitingStates.length; i++) { const state = exitingStates[i]; if (state.selected && selectStamps.get(state.key) !== stamp) { state.selected = false; state.exitStartedAt = now; // Stateless entries may be re-selected immediately; the cooldown exists // to damp thrash for sources that shipped with hysteresis. state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS; } } const labelsByLayer = collectDiagnostics ? {} : null; for (let i = 0; i < selectedCount; i++) { const candidate = selectedCandidates[i]; const placement = selectedPlacements[i]; const key = candidate.key; const previous = this.states.get(key); const state = previous || { key, selected: false, selectedAt: now, enterStartedAt: now, exitStartedAt: null, cooldownUntil: 0, corner: undefined, stateless: false, lastCandidate: null, lastPlacement: null, }; if (!state.selected) { state.selectedAt = now; state.enterStartedAt = now; } state.selected = true; state.exitStartedAt = null; state.cooldownUntil = 0; state.corner = placement.corner; state.stateless = candidate.stateless === true; state.lastCandidate = candidate; state.lastPlacement = placement; if (!previous) { this.states.set(key, state); this._stateListDirty = true; } if (labelsByLayer) { labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1; } } for (let i = 0; i < candidateCount; i++) { const state = this.states.get(candidateList[i].key); if (state) state.lastCandidate = candidateList[i]; } const livingStates = this._refreshStateList(); for (let i = 0; i < livingStates.length; i++) { const state = livingStates[i]; if ( !state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS ) { this.states.delete(state.key); this._stateListDirty = true; } } const dropped = this._droppedKeys; let droppedCount = 0; this.selectedKeys.forEach((key) => { if (selectStamps.get(key) !== stamp) dropped[droppedCount++] = key; }); for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]); dropped.length = droppedCount; for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key); const previousActiveLayers = this.lastActiveLayers; this.lastActiveLayers = activeLayers; this._activeLayersScratch = previousActiveLayers; this.solveRevision++; this.lastDiagnostics = collectDiagnostics ? { strategy, capacity, selectedCount, quotas: Object.fromEntries(quotas), demand: Object.fromEntries(demand), labelsByLayer, solveRevision: this.solveRevision, spatialQueueBuildCount, spatialQueueNextCount, eligibleIncumbents, retainedIncumbents, } : null; selectedCandidates.length = selectedCount; selectedPlacements.length = selectedCount; pruneStampMap(attemptStamps, candidateCount); pruneStampMap(selectStamps, candidateCount); return this.lastDiagnostics; } /** * Reproject accepted/fading identities from the current frame candidate map. * A caller-owned output array enables allocation-free per-frame rendering. */ renderEntries(currentCandidates, now = Date.now(), out = [], limits = null) { const current = currentCandidates instanceof Map ? currentCandidates : new Map( (currentCandidates || []).map((candidate) => [ candidate.key, candidate, ]), ); const states = this._refreshStateList(); let outIndex = 0; const painted = this._paintedByLayer; painted.forEach(zeroCount); // Reserve the live winners before spending any remaining slots on exit fades. if (limits) for (let i = 0; i < states.length; i++) { const state = states[i]; if (!state.selected) continue; const candidate = current.get(state.key) || state.lastCandidate; if (candidate) painted.set( candidate.layerId, (painted.get(candidate.layerId) || 0) + 1, ); } for (let i = 0; i < states.length; i++) { const state = states[i]; let temporalAlpha; if (state.stateless) { // Hard cliffs, matching a per-frame rebuild: a stateless entry is either // fully painted this solve or gone. No enter ramp, no exit tail. temporalAlpha = state.selected ? 1 : 0; } else if (state.selected) { temporalAlpha = Math.min( 1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS), ); } else { temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS)); } if (temporalAlpha <= 0) continue; const candidate = current.get(state.key) || state.lastCandidate; if (!candidate) continue; if (limits && !state.selected) { const count = painted.get(candidate.layerId) || 0; if (count >= (limits.get(candidate.layerId) ?? Infinity)) continue; painted.set(candidate.layerId, count + 1); } const placement = renderPlacement( candidate, state.corner, state.lastPlacement, ); if (!placement) continue; const entry = out[outIndex] || (out[outIndex] = {}); entry.candidate = candidate; entry.placement = placement; entry.temporalAlpha = temporalAlpha; entry.selected = state.selected; outIndex++; } out.length = outIndex; return out; } /** * Return the bounded identities that still need per-frame placement data. * The grouped layer/source representation lets the render lane test live * membership without allocating composite keys for every losing source. */ liveIdentities({ includeFading = true, now = Date.now() } = {}) { const grouped = new Map(); for (const state of this.states.values()) { if (!state.selected) { if (!includeFading) continue; if ( !Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS ) continue; } const candidate = state.lastCandidate; if (!candidate?.layerId || candidate.sourceId == null) continue; if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set()); grouped.get(candidate.layerId).add(candidate.sourceId); } return grouped; } diagnostics() { return this.lastDiagnostics ? { ...this.lastDiagnostics } : null; } } export const LABEL_ARBITER_TIMING = Object.freeze({ fadeInMs: FADE_IN_MS, fadeOutMs: FADE_OUT_MS, minimumLifetimeMs: MIN_LIFETIME_MS, cooldownMs: COOLDOWN_MS, }); export { ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED };