mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
1229 lines
41 KiB
JavaScript
1229 lines
41 KiB
JavaScript
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 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._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();
|
|
}
|
|
|
|
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._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 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 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++;
|
|
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 (!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 = []) {
|
|
const current =
|
|
currentCandidates instanceof Map
|
|
? currentCandidates
|
|
: new Map(
|
|
(currentCandidates || []).map((candidate) => [
|
|
candidate.key,
|
|
candidate,
|
|
]),
|
|
);
|
|
const states = this._refreshStateList();
|
|
let outIndex = 0;
|
|
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;
|
|
const placement = renderPlacement(
|
|
candidate,
|
|
state.stateless ? undefined : 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 };
|