* Show labels for the given nodes, culling any that overlap a previously * placed label. Nodes are processed largest-first so important nodes win. * * This method ONLY controls visibility. Scale and position are handled by * the update paths (update3D, updatePositionsFromBuffer, applyCou
(candidates: PixiNode[], invScale: number)
| 1279 | * to avoid jitter from competing writes. |
| 1280 | */ |
| 1281 | private applyLabelCulling(candidates: PixiNode[], invScale: number): void { |
| 1282 | // Sort by size descending — largest (highest degree) nodes get labels first |
| 1283 | candidates.sort((a, b) => b.size - a.size); |
| 1284 | |
| 1285 | const boxes: { x: number; y: number; w: number; h: number }[] = []; |
| 1286 | const lm = this.labelScaleMultiplier; |
| 1287 | const screenLabelScale = invScale * lm * this.vp.scale; |
| 1288 | const labelH = (LABEL_SIZE + 4) * screenLabelScale; |
| 1289 | |
| 1290 | // Viewport bounds in screen pixels with a small margin so a label |
| 1291 | // partially clipped at the edge still gets a slot — avoids |
| 1292 | // labels popping in/out as you pan across the boundary (Fix #49). |
| 1293 | const VIEWPORT_MARGIN = 80; |
| 1294 | const vpLeft = -VIEWPORT_MARGIN; |
| 1295 | const vpRight = this.width + VIEWPORT_MARGIN; |
| 1296 | const vpTop = -VIEWPORT_MARGIN; |
| 1297 | const vpBottom = this.height + VIEWPORT_MARGIN; |
| 1298 | |
| 1299 | // Skip labels for nodes whose sprite is too small on screen |
| 1300 | // (Fix #49). At zoom-out the entire graph compresses into a tiny |
| 1301 | // area; without this gate, every node still tries for a label |
| 1302 | // slot and the screen fills with text. Sprite on-screen radius = |
| 1303 | // node.size × vp.scale^(1-zoomSizeExponent). 2.5 px is the |
| 1304 | // current threshold — was 4, lowered so larger anchor nodes get |
| 1305 | // labels at lower zoom levels (overlap cull still keeps the |
| 1306 | // screen from filling with text at extreme zoom-out). |
| 1307 | const MIN_SPRITE_SCREEN_RADIUS = 2.5; |
| 1308 | const spriteRadiusFactor = Math.pow( |
| 1309 | this.vp.scale, |
| 1310 | Math.max(0, 1 - this.zoomSizeExponent), |
| 1311 | ); |
| 1312 | |
| 1313 | for (const node of candidates) { |
| 1314 | // Screen position of the sprite — used for the viewport |
| 1315 | // pre-filter (skip cull work for off-screen labels). |
| 1316 | const cx = node.sprite.position.x * this.vp.scale + this.vp.x; |
| 1317 | const cy = node.sprite.position.y * this.vp.scale + this.vp.y; |
| 1318 | if (cx < vpLeft || cx > vpRight || cy < vpTop || cy > vpBottom) { |
| 1319 | if (node.label) node.label.visible = false; |
| 1320 | continue; |
| 1321 | } |
| 1322 | |
| 1323 | // Size gate — sprites too small to read don't get labels. |
| 1324 | const spriteScreenRadius = node.size * spriteRadiusFactor; |
| 1325 | if (spriteScreenRadius < MIN_SPRITE_SCREEN_RADIUS) { |
| 1326 | if (node.label) node.label.visible = false; |
| 1327 | continue; |
| 1328 | } |
| 1329 | |
| 1330 | // World-space gap that lands the label just outside the sprite |
| 1331 | // edge (Fix #27 — the only piece of my label rework that |
| 1332 | // survived the revert; without it the label slides inside the |
| 1333 | // sprite at deep zoom because the sprite is in world coords |
| 1334 | // while the label is screen-pixel-constant). |
| 1335 | const gap = this.labelGap(node); |
| 1336 | const nx = node.sprite.position.x; |
| 1337 | const ny = node.sprite.position.y; |
| 1338 |
no test coverage detected