* Called from the Pixi ticker every frame when 3D mode is active. * Projects all nodes and redraws edges.
()
| 2898 | * Projects all nodes and redraws edges. |
| 2899 | */ |
| 2900 | private update3D(): void { |
| 2901 | // Auto-rotation used to be gated on `layoutSettled` (Fix #54) to hide |
| 2902 | // ticker stutter while the main thread was busy in bursts during |
| 2903 | // indexing. After Plans D + E moved the store and embedder off the |
| 2904 | // main thread, that stutter source is gone, and the gate was instead |
| 2905 | // suppressing rotation on graphs where d3-force never reports settled |
| 2906 | // (large graphs, warm alphaTarget). Manual drag still updates |
| 2907 | // `mode3dAngle` directly in the pointer handler regardless. |
| 2908 | if (this.mode3dAutoRotate) { |
| 2909 | this.mode3dAngle += this.mode3dSpeed; |
| 2910 | } |
| 2911 | |
| 2912 | // Keep sphere bounds in sync with the layout. |
| 2913 | // • While the worker simulation is running (`!layoutSettled`) we |
| 2914 | // recompute every frame with allow-shrink so R tracks live |
| 2915 | // positions through any transition — e.g. spread → compact, |
| 2916 | // compact → spread. Without this, switching to a tighter |
| 2917 | // layout leaves R stuck at the larger value and the projection |
| 2918 | // collapses into a vertical cylinder (Fix #8). |
| 2919 | // • Once settled, fall back to throttled only-grows so brief |
| 2920 | // outward jitter doesn't trigger Z=0 hard-clamp flicker. |
| 2921 | this.mode3dFrameCounter++; |
| 2922 | if (!this.layoutSettled) { |
| 2923 | this.recomputeMode3DExtents(true); |
| 2924 | } else if (this.mode3dFrameCounter % 30 === 0) { |
| 2925 | this.recomputeMode3DExtents(); |
| 2926 | } |
| 2927 | |
| 2928 | const invScale = this.zoomInvScale(); |
| 2929 | const lblInv = this.labelInvScale(); |
| 2930 | // Fold community-centroid accumulation into the projection loop so |
| 2931 | // the wayfinders re-aim every frame at zero extra cost (Fix #34). |
| 2932 | // Allocating a Map per frame is cheap (~20 entries); the per-node |
| 2933 | // dict lookup is a single property read. |
| 2934 | const trackCommunities = |
| 2935 | this.showCommunityLabels && |
| 2936 | this.communityLabels.size > 0 && |
| 2937 | this.communityAssignments !== null; |
| 2938 | const communitySums: Map< |
| 2939 | number, |
| 2940 | { x: number; y: number; n: number } |
| 2941 | > | null = trackCommunities ? new Map() : null; |
| 2942 | // Per-community representative Z (golden-angle bucket from |
| 2943 | // set3DMode), captured from the first member encountered. Reused |
| 2944 | // below to project each community's 2D centroid through the |
| 2945 | // current rotation. |
| 2946 | const communityZ: Map<number, number> | null = trackCommunities |
| 2947 | ? new Map() |
| 2948 | : null; |
| 2949 | const assignments = this.communityAssignments; |
| 2950 | for (const node of this.nodeArray) { |
| 2951 | if (!node.visible) continue; |
| 2952 | // Skip the dragged node — its sprite position is controlled by pointermove |
| 2953 | if (this.dragNode === node) continue; |
| 2954 | const z = this.getNodeZ(node); |
| 2955 | const p = this.project3d(node.x, node.y, z); |
| 2956 | node.sprite.position.set(p.px, p.py); |
| 2957 |
no test coverage detected