RingLayout.ts
1 /** 2 * Ring Layout Position Calculator 3 * 4 * Unified positioning algorithms for both liminal web and semantic search layouts. 5 * Implements center + hexagonal ring pattern with scalable distribution up to 36 nodes. 6 */ 7 8 import { RelationshipGraph } from './relationship-graph'; 9 import { useInterBrainStore } from '../../core/store/interbrain-store'; 10 11 /** 12 * Configuration for ring layout positioning 13 */ 14 export interface RingLayoutConfig { 15 /** Distance from camera for centered/focused node (appears largest) */ 16 centerDistance: number; 17 18 /** Distances from camera for each ring (ring 1, 2, 3) */ 19 ringDistances: [number, number, number]; 20 21 /** Radii for each ring in world space */ 22 ringRadii: [number, number, number]; 23 24 /** Maximum nodes total across all rings (36 = 6+12+18) */ 25 maxActiveNodes: number; 26 27 /** Distance from camera for outer circle nodes (second-degree, hidden buffer) */ 28 outerCircleDistance: number; 29 30 /** Radius of outer circle for second-degree nodes */ 31 outerCircleRadius: number; 32 33 /** Maximum nodes to preload in outer circle (buffer limit) */ 34 maxOuterConnections: number; 35 } 36 37 38 // Raw ring layout values - direct control for easy iteration 39 const CENTER_DISTANCE = 50; 40 41 // Ring 1 (6 nodes) 42 const RING1_DISTANCE = 100; 43 const RING1_RADIUS = 40; 44 45 // Ring 2 (12 nodes) 46 const RING2_DISTANCE = 200; 47 const RING2_RADIUS = 125; 48 49 // Ring 3 (18 nodes) 50 const RING3_DISTANCE = 450; 51 const RING3_RADIUS = 335; 52 53 // Direct arrays - no calculations, just raw values 54 const RAW_DISTANCES: [number, number, number] = [RING1_DISTANCE, RING2_DISTANCE, RING3_DISTANCE]; 55 const RAW_RADII: [number, number, number] = [RING1_RADIUS, RING2_RADIUS, RING3_RADIUS]; 56 57 58 /** 59 * Default configuration for ring layout positioning 60 */ 61 export const DEFAULT_RING_CONFIG: RingLayoutConfig = { 62 centerDistance: CENTER_DISTANCE, // Center node distance 63 ringDistances: RAW_DISTANCES, // Ring distances - direct values 64 ringRadii: RAW_RADII, // Ring radii - direct values 65 maxActiveNodes: 36, // 6 + 12 + 18 = 36 total 66 outerCircleDistance: 600, // Far distance = hidden buffer 67 outerCircleRadius: 600, // Large radius for outer buffer 68 maxOuterConnections: 50 // Preload buffer for smooth transitions 69 }; 70 71 /** 72 * Result of ring layout position calculation 73 */ 74 export interface RingLayoutPositions { 75 /** Center node position (empty for search mode) */ 76 centerNode: { 77 nodeId: string; 78 position: [number, number, number]; 79 } | null; 80 81 /** Ring 1 node positions (6 nodes) */ 82 ring1Nodes: Array<{ 83 nodeId: string; 84 position: [number, number, number]; 85 }>; 86 87 /** Ring 2 node positions (12 nodes) */ 88 ring2Nodes: Array<{ 89 nodeId: string; 90 position: [number, number, number]; 91 }>; 92 93 /** Ring 3 node positions (18 nodes) */ 94 ring3Nodes: Array<{ 95 nodeId: string; 96 position: [number, number, number]; 97 }>; 98 99 /** Nodes that remain on the sphere in constellation mode */ 100 sphereNodes: string[]; 101 } 102 103 /** 104 * Calculate ring layout positions for a given center node or ordered node list 105 * Uses precise 42-node coordinate system with boolean masking 106 */ 107 export function calculateRingLayoutPositions( 108 focusedNodeId: string | null, 109 relationshipGraph: RelationshipGraph, 110 config: RingLayoutConfig = DEFAULT_RING_CONFIG 111 ): RingLayoutPositions { 112 let orderedNodes: Array<{ id: string; name?: string; type?: string }>; 113 114 if (focusedNodeId) { 115 // Liminal web mode: get relationships for focused node 116 const focusedNode = relationshipGraph.nodes.get(focusedNodeId); 117 if (!focusedNode) { 118 throw new Error(`Focused node ${focusedNodeId} not found in relationship graph`); 119 } 120 121 // Get first-degree connections (Dreams ↔ Dreamers only) 122 orderedNodes = relationshipGraph.getOppositeTypeConnections(focusedNodeId); 123 } else { 124 // Search mode: would get ordered search results here 125 // For now, return empty layout (will be implemented in search integration) 126 orderedNodes = []; 127 } 128 129 // Limit to max active nodes (36 = 6+12+18) 130 const limitedNodes = orderedNodes.slice(0, config.maxActiveNodes); 131 const totalNodes = limitedNodes.length; 132 133 // Map nodes to their positions 134 const nodePositions: Array<{ nodeId: string; position: [number, number, number] }> = []; 135 136 if (totalNodes <= 6) { 137 // For 1-6 nodes: Use direct equidistant angle calculation (like HTML visualizer) 138 const ring1Count = totalNodes; 139 140 // Original Ring 1 logic with proper rotation 141 let startAngle = -Math.PI / 2; // Default: start at top (point up) 142 if (ring1Count === 6) { 143 startAngle = -Math.PI / 2 + Math.PI / 6; // Rotate by 30° (flat edge at top) 144 } 145 146 for (let i = 0; i < ring1Count; i++) { 147 const angle = (i / ring1Count) * 2 * Math.PI + startAngle; 148 const x = RAW_RADII[0] * Math.cos(angle); 149 const y = RAW_RADII[0] * Math.sin(angle); 150 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 151 nodePositions.push({ 152 nodeId: limitedNodes[i].id, 153 position: [x, -y, -RAW_DISTANCES[0]] 154 }); 155 } 156 } else { 157 // For 7+ nodes: Use precise coordinate system with boolean masking 158 const allPositions = generateAll42StaticPositions(); 159 const activeMask = getActiveMask(totalNodes); 160 161 let nodeIndex = 0; 162 for (let i = 0; i < 42 && nodeIndex < totalNodes; i++) { 163 if (activeMask[i]) { 164 nodePositions.push({ 165 nodeId: limitedNodes[nodeIndex].id, 166 position: allPositions[i] 167 }); 168 nodeIndex++; 169 } 170 } 171 } 172 173 // Separate nodes into rings based on approach used 174 const ring1Nodes: Array<{ nodeId: string; position: [number, number, number] }> = []; 175 const ring2Nodes: Array<{ nodeId: string; position: [number, number, number] }> = []; 176 const ring3Nodes: Array<{ nodeId: string; position: [number, number, number] }> = []; 177 178 if (totalNodes <= 6) { 179 // For 1-6 nodes: All nodes go to ring1 (they're all using Ring 1 positions) 180 ring1Nodes.push(...nodePositions); 181 } else { 182 // For 7+ nodes: Use mask-based ring separation 183 const activeMask = getActiveMask(totalNodes); 184 185 for (let i = 0; i < nodePositions.length; i++) { 186 const maskIndex = activeMask.findIndex((active, idx) => { 187 if (!active) return false; 188 const activeUpToHere = activeMask.slice(0, idx + 1).filter(Boolean).length; 189 return activeUpToHere === i + 1; 190 }); 191 192 if (maskIndex < 6) { 193 ring1Nodes.push(nodePositions[i]); 194 } else if (maskIndex < 18) { 195 ring2Nodes.push(nodePositions[i]); 196 } else { 197 ring3Nodes.push(nodePositions[i]); 198 } 199 } 200 } 201 202 // Calculate center position (only for liminal web mode) 203 const centerNode = focusedNodeId ? { 204 nodeId: focusedNodeId, 205 position: [0, 0, -config.centerDistance] as [number, number, number] 206 } : null; 207 208 // All nodes that aren't active stay on the sphere 209 const activeNodeIds = new Set([ 210 ...(focusedNodeId ? [focusedNodeId] : []), 211 ...limitedNodes.map(n => n.id) 212 ]); 213 214 const allNodeIds = Array.from(relationshipGraph.nodes.keys()); 215 const sphereNodes = allNodeIds.filter(nodeId => !activeNodeIds.has(nodeId)); 216 217 return { 218 centerNode, 219 ring1Nodes, 220 ring2Nodes, 221 ring3Nodes, 222 sphereNodes 223 }; 224 } 225 226 // REMOVED: distributeNodesAcrossRings function is no longer needed 227 // The 42-node coordinate system with boolean masking handles distribution automatically 228 229 /** 230 * Generate all 42 static node positions using precise coordinate system 231 * Preserves existing 3D perspective framework with enhanced positioning logic 232 * Note: Y coordinates are negated to convert from HTML canvas coordinates (Y-down) to 3D coordinates (Y-up) 233 */ 234 function generateAll42StaticPositions(): [number, number, number][] { 235 const allPositions: [number, number, number][] = []; 236 237 // Ring 1: Nodes 1-6 (same as existing logic with 30° rotation for flat edge at top) 238 const ring1StartAngle = -Math.PI / 2 + Math.PI / 6; 239 for (let i = 0; i < 6; i++) { 240 const angle = (i / 6) * 2 * Math.PI + ring1StartAngle; 241 const x = RAW_RADII[0] * Math.cos(angle); 242 const y = RAW_RADII[0] * Math.sin(angle); 243 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 244 allPositions.push([x, -y, -RAW_DISTANCES[0]]); // Use existing Ring 1 distance 245 } 246 247 // Ring 2: Nodes 7-18 (6 edge positions + 6 corner positions) 248 const ring2EdgeRadius = RAW_RADII[1] * Math.cos(Math.PI / 6); // cos(30°) = √3/2 ≈ 0.866 249 250 // First 6 nodes (7-12): edge positions (reduced radius) 251 for (let i = 0; i < 6; i++) { 252 const angle = (i / 6) * 2 * Math.PI - Math.PI / 2; 253 const x = ring2EdgeRadius * Math.cos(angle); 254 const y = ring2EdgeRadius * Math.sin(angle); 255 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 256 allPositions.push([x, -y, -RAW_DISTANCES[1]]); // Use existing Ring 2 distance 257 } 258 259 // Next 6 nodes (13-18): corner positions (full radius) 260 for (let i = 0; i < 6; i++) { 261 const angle = (i / 6) * 2 * Math.PI - Math.PI / 2 + Math.PI / 6; 262 const x = RAW_RADII[1] * Math.cos(angle); 263 const y = RAW_RADII[1] * Math.sin(angle); 264 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 265 allPositions.push([x, -y, -RAW_DISTANCES[1]]); // Use existing Ring 2 distance 266 } 267 268 // Ring 3: Nodes 19-42 (6 vertices + 18 edge nodes using path parameterization) 269 const hexagonAngles = [30, 90, 150, 210, 270, 330]; 270 const baseRadius = RAW_RADII[2]; 271 const vertexPositions: [number, number][] = []; 272 273 // Calculate vertex positions (19-24) 274 for (let i = 0; i < 6; i++) { 275 const angleDegrees = hexagonAngles[i]; 276 const angleRadians = (angleDegrees - 90) * Math.PI / 180; 277 const x = baseRadius * Math.cos(angleRadians); 278 const y = baseRadius * Math.sin(angleRadians); 279 vertexPositions.push([x, y]); 280 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 281 allPositions.push([x, -y, -RAW_DISTANCES[2]]); // Use existing Ring 3 distance 282 } 283 284 // Path parameterization helper function 285 const lerpPath = (pointA: [number, number], pointB: [number, number], t: number): [number, number] => [ 286 pointA[0] + t * (pointB[0] - pointA[0]), 287 pointA[1] + t * (pointB[1] - pointA[1]) 288 ]; 289 290 // Add edge nodes using path parameterization (25-36: t = 1/3, 2/3) 291 for (let edgeIndex = 0; edgeIndex < 6; edgeIndex++) { 292 const startVertex = vertexPositions[edgeIndex]; 293 const endVertex = vertexPositions[(edgeIndex + 1) % 6]; 294 295 for (let nodeOnEdge = 0; nodeOnEdge < 2; nodeOnEdge++) { 296 const t = (nodeOnEdge + 1) / 3; // t = 1/3, 2/3 297 const [worldX, worldY] = lerpPath(startVertex, endVertex, t); 298 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 299 allPositions.push([worldX, -worldY, -RAW_DISTANCES[2]]); 300 } 301 } 302 303 // Add 6 additional edge midpoint nodes (37-42: t = 0.5) 304 for (let edgeIndex = 0; edgeIndex < 6; edgeIndex++) { 305 const startVertex = vertexPositions[edgeIndex]; 306 const endVertex = vertexPositions[(edgeIndex + 1) % 6]; 307 308 const [worldX, worldY] = lerpPath(startVertex, endVertex, 0.5); // Exact midpoint 309 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 310 allPositions.push([worldX, -worldY, -RAW_DISTANCES[2]]); 311 } 312 313 return allPositions; 314 } 315 316 /** 317 * Generate boolean mask for active nodes based on total node count 318 * Implements precise node activation patterns from honeycomb coordinate system 319 */ 320 function getActiveMask(totalNodes: number): boolean[] { 321 const mask = new Array(42).fill(false); 322 323 // Ring 1: Equidistant placement for counts 1-6 (matches HTML visualizer logic) 324 if (totalNodes >= 1) { 325 const ring1Count = Math.min(totalNodes, 6); 326 327 // For equidistant placement, we need to map logical positions to physical indices 328 // Generate equidistant indices for Ring 1 329 const equidistantIndices: number[] = []; 330 331 if (ring1Count === 1) { 332 equidistantIndices.push(0); // Top position only 333 } else if (ring1Count === 2) { 334 equidistantIndices.push(0, 3); // Top and bottom 335 } else if (ring1Count === 3) { 336 equidistantIndices.push(0, 2, 4); // Every other position for triangle 337 } else if (ring1Count === 4) { 338 equidistantIndices.push(0, 1, 3, 4); // Skip position 2 and 5 for even distribution 339 } else if (ring1Count === 5) { 340 equidistantIndices.push(0, 1, 2, 3, 4); // All except position 5 341 } else if (ring1Count === 6) { 342 equidistantIndices.push(0, 1, 2, 3, 4, 5); // All positions 343 } 344 345 // Activate the equidistant indices 346 equidistantIndices.forEach(index => { 347 mask[index] = true; 348 }); 349 } 350 351 // If totalNodes <= 6, we're done 352 if (totalNodes <= 6) return mask; 353 354 // Helper functions for Ring 2 and Ring 3 activation 355 const activateRing2 = (mask: boolean[]) => { 356 for (let i = 6; i < 18; i++) { 357 mask[i] = true; // Nodes 7-18 (indices 6-17) 358 } 359 }; 360 361 const activateRing3Nodes = (mask: boolean[], nodeNumbers: number[]) => { 362 nodeNumbers.forEach(nodeNum => { 363 mask[nodeNum - 1] = true; // Convert 1-based to 0-based indexing 364 }); 365 }; 366 367 // Ring 2 specific patterns (nodes 7-18) 368 if (totalNodes === 7) { 369 mask[6] = true; // Node 7 370 } else if (totalNodes === 8) { 371 mask[6] = true; // Node 7 372 mask[9] = true; // Node 10 373 } else if (totalNodes === 9) { 374 mask[6] = true; // Node 7 375 mask[8] = true; // Node 9 376 mask[10] = true; // Node 11 377 } else if (totalNodes === 10) { 378 mask[11] = true; // Node 12 379 mask[7] = true; // Node 8 380 mask[8] = true; // Node 9 381 mask[10] = true; // Node 11 382 } else if (totalNodes === 11) { 383 mask[6] = true; // Node 7 384 mask[7] = true; // Node 8 385 mask[8] = true; // Node 9 386 mask[10] = true; // Node 11 387 mask[11] = true; // Node 12 388 } else if (totalNodes === 12) { 389 mask[6] = true; // Node 7 390 mask[7] = true; // Node 8 391 mask[8] = true; // Node 9 392 mask[9] = true; // Node 10 393 mask[10] = true; // Node 11 394 mask[11] = true; // Node 12 395 } else if (totalNodes === 13) { 396 mask[6] = true; // Node 7 397 mask[7] = true; // Node 8 398 mask[8] = true; // Node 9 399 mask[14] = true; // Node 15 400 mask[15] = true; // Node 16 401 mask[10] = true; // Node 11 402 mask[11] = true; // Node 12 403 } else if (totalNodes === 14) { 404 mask[17] = true; // Node 18 405 mask[12] = true; // Node 13 406 mask[7] = true; // Node 8 407 mask[8] = true; // Node 9 408 mask[14] = true; // Node 15 409 mask[15] = true; // Node 16 410 mask[10] = true; // Node 11 411 mask[11] = true; // Node 12 412 } else if (totalNodes === 15) { 413 mask[17] = true; // Node 18 414 mask[12] = true; // Node 13 415 mask[7] = true; // Node 8 416 mask[8] = true; // Node 9 417 mask[14] = true; // Node 15 418 mask[15] = true; // Node 16 419 mask[10] = true; // Node 11 420 mask[11] = true; // Node 12 421 mask[6] = true; // Node 7 422 } else if (totalNodes === 16) { 423 mask[17] = true; // Node 18 424 mask[12] = true; // Node 13 425 mask[7] = true; // Node 8 426 mask[8] = true; // Node 9 427 mask[14] = true; // Node 15 428 mask[15] = true; // Node 16 429 mask[10] = true; // Node 11 430 mask[11] = true; // Node 12 431 mask[6] = true; // Node 7 432 mask[9] = true; // Node 10 433 } else if (totalNodes === 17) { 434 mask[17] = true; // Node 18 435 mask[12] = true; // Node 13 436 mask[7] = true; // Node 8 437 mask[8] = true; // Node 9 438 mask[14] = true; // Node 15 439 mask[15] = true; // Node 16 440 mask[10] = true; // Node 11 441 mask[11] = true; // Node 12 442 mask[6] = true; // Node 7 443 mask[16] = true; // Node 17 444 mask[13] = true; // Node 14 445 } else if (totalNodes === 18) { 446 activateRing2(mask); 447 } 448 // Ring 3 specific patterns (Ring 1 + Ring 2 always fully active for 19+) 449 else if (totalNodes === 19) { 450 activateRing2(mask); 451 activateRing3Nodes(mask, [42]); 452 } else if (totalNodes === 20) { 453 activateRing2(mask); 454 activateRing3Nodes(mask, [42, 39]); 455 } else if (totalNodes === 21) { 456 activateRing2(mask); 457 activateRing3Nodes(mask, [42, 38, 40]); 458 } else if (totalNodes === 22) { 459 activateRing2(mask); 460 activateRing3Nodes(mask, [37, 38, 40, 41]); 461 } else if (totalNodes === 23) { 462 activateRing2(mask); 463 activateRing3Nodes(mask, [37, 38, 40, 41, 42]); 464 } else if (totalNodes === 24) { 465 activateRing2(mask); 466 activateRing3Nodes(mask, [37, 38, 39, 40, 41, 42]); // Edge midpoints (t=0.5) - one per hexagon edge 467 } else if (totalNodes === 25) { 468 activateRing2(mask); 469 activateRing3Nodes(mask, [37, 38, 40, 41, 42, 29, 30]); 470 } else if (totalNodes === 26) { 471 activateRing2(mask); 472 activateRing3Nodes(mask, [37, 38, 40, 41, 29, 30, 35, 36]); 473 } else if (totalNodes >= 27 && totalNodes <= 36) { 474 // Complex patterns for 27-36 nodes - preserve explicit logic for geometric clarity 475 activateRing2(mask); 476 477 if (totalNodes === 27) { 478 // Exact pattern from HTML visualizer: 9 Ring 3 nodes 479 // First, explicitly clear all Ring 3 positions to prevent contamination 480 for (let i = 18; i < 42; i++) { 481 mask[i] = false; 482 } 483 484 // Now set ONLY the 9 nodes we want for 27-node pattern 485 mask[36] = true; // Node 37 - edge 0 midpoint (t=0.5) 486 mask[40] = true; // Node 41 - edge 4 midpoint (t=0.5) 487 mask[34] = true; // Node 35 - edge 5, t=2/3 488 mask[35] = true; // Node 36 - edge 5, t=2/3 (completing the pair) 489 mask[38] = true; // Node 39 - edge 2 midpoint (t=0.5) - BOTTOM NODE 490 mask[26] = true; // Node 27 - edge 1, t=1/3 491 mask[27] = true; // Node 28 - edge 1, t=2/3 492 mask[30] = true; // Node 31 - edge 3, t=1/3 493 mask[31] = true; // Node 32 - edge 3, t=2/3 494 } else if (totalNodes === 28) { 495 mask[36] = true; mask[40] = true; mask[34] = true; mask[35] = true; 496 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 497 mask[28] = true; 498 } else if (totalNodes === 29) { 499 mask[34] = true; mask[35] = true; mask[26] = true; mask[27] = true; 500 mask[30] = true; mask[31] = true; 501 } else if (totalNodes === 30) { 502 mask[34] = true; mask[35] = true; mask[26] = true; mask[27] = true; 503 mask[30] = true; mask[31] = true; mask[32] = true; mask[33] = true; 504 mask[24] = true; 505 } else if (totalNodes === 31) { 506 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 507 mask[32] = true; mask[33] = true; mask[24] = true; mask[25] = true; 508 } else if (totalNodes === 32) { 509 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 510 mask[32] = true; mask[33] = true; mask[24] = true; mask[25] = true; 511 mask[23] = true; mask[41] = true; mask[18] = true; // Node 24, 42, 19 512 mask[20] = true; mask[38] = true; mask[21] = true; // Node 21, 39, 22 513 } else if (totalNodes === 33) { 514 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 515 mask[32] = true; mask[33] = true; mask[24] = true; mask[25] = true; 516 mask[23] = true; mask[34] = true; mask[35] = true; 517 } else if (totalNodes === 34) { 518 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 519 mask[32] = true; mask[33] = true; mask[24] = true; mask[25] = true; 520 mask[23] = true; mask[18] = true; // Node 27-28, 31-34, 25-26, 24, 19 521 mask[20] = true; mask[21] = true; // Node 21, 22 522 mask[34] = true; mask[35] = true; // Node 35, 36 523 mask[29] = true; mask[28] = true; // Node 30, 29 524 } else if (totalNodes === 35) { 525 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 526 mask[32] = true; mask[33] = true; mask[24] = true; mask[25] = true; 527 mask[23] = true; mask[18] = true; // Node 27-28, 31-34, 25-26, 24, 19 528 mask[20] = true; mask[21] = true; // Node 21, 22 529 mask[34] = true; mask[35] = true; // Node 35, 36 530 // Note: Nodes 29, 30 (indices 28, 29) are explicitly OFF 531 mask[38] = true; mask[22] = true; mask[19] = true; // Node 39, 23, 20 532 } else if (totalNodes === 36) { 533 mask[26] = true; mask[27] = true; mask[30] = true; mask[31] = true; 534 mask[32] = true; mask[33] = true; mask[24] = true; mask[25] = true; 535 mask[23] = true; mask[18] = true; mask[29] = true; mask[28] = true; 536 } 537 } 538 539 return mask; 540 } 541 542 // REMOVED: calculateHexagonalRingPositions function is no longer needed 543 // The main functions now directly use generateAll42StaticPositions and getActiveMask 544 545 546 /** 547 * Calculate ring layout positions for search results (no center node) 548 * Uses precise 42-node coordinate system with boolean masking 549 */ 550 export function calculateRingLayoutPositionsForSearch( 551 orderedNodes: Array<{ id: string; name?: string; type?: string }>, 552 relationshipGraph: RelationshipGraph, 553 config: RingLayoutConfig = DEFAULT_RING_CONFIG 554 ): RingLayoutPositions { 555 // Apply priority wrapper: Pre-sort nodes to ensure related nodes get priority positions 556 const prioritySortedNodes = applyPriorityMapping(orderedNodes, relationshipGraph); 557 558 // Limit to max active nodes (36 = 6+12+18) 559 const limitedNodes = prioritySortedNodes.slice(0, config.maxActiveNodes); 560 const totalNodes = limitedNodes.length; 561 562 // Map nodes to their positions 563 const nodePositions: Array<{ nodeId: string; position: [number, number, number] }> = []; 564 565 if (totalNodes <= 6) { 566 // For 1-6 nodes: Use direct equidistant angle calculation (like HTML visualizer) 567 const ring1Count = totalNodes; 568 569 // Original Ring 1 logic with proper rotation 570 let startAngle = -Math.PI / 2; // Default: start at top (point up) 571 if (ring1Count === 6) { 572 startAngle = -Math.PI / 2 + Math.PI / 6; // Rotate by 30° (flat edge at top) 573 } 574 575 for (let i = 0; i < ring1Count; i++) { 576 const angle = (i / ring1Count) * 2 * Math.PI + startAngle; 577 const x = RAW_RADII[0] * Math.cos(angle); 578 const y = RAW_RADII[0] * Math.sin(angle); 579 // Negate Y to convert from screen coordinates (Y-down) to 3D coordinates (Y-up) 580 nodePositions.push({ 581 nodeId: limitedNodes[i].id, 582 position: [x, -y, -RAW_DISTANCES[0]] 583 }); 584 } 585 } else { 586 // For 7+ nodes: Use precise coordinate system with boolean masking 587 const allPositions = generateAll42StaticPositions(); 588 const activeMask = getActiveMask(totalNodes); 589 590 let nodeIndex = 0; 591 for (let i = 0; i < 42 && nodeIndex < totalNodes; i++) { 592 if (activeMask[i]) { 593 nodePositions.push({ 594 nodeId: limitedNodes[nodeIndex].id, 595 position: allPositions[i] 596 }); 597 nodeIndex++; 598 } 599 } 600 } 601 602 // Separate nodes into rings based on approach used 603 const ring1Nodes: Array<{ nodeId: string; position: [number, number, number] }> = []; 604 const ring2Nodes: Array<{ nodeId: string; position: [number, number, number] }> = []; 605 const ring3Nodes: Array<{ nodeId: string; position: [number, number, number] }> = []; 606 607 if (totalNodes <= 6) { 608 // For 1-6 nodes: All nodes go to ring1 (they're all using Ring 1 positions) 609 ring1Nodes.push(...nodePositions); 610 } else { 611 // For 7+ nodes: Use mask-based ring separation 612 const activeMask = getActiveMask(totalNodes); 613 614 for (let i = 0; i < nodePositions.length; i++) { 615 const maskIndex = activeMask.findIndex((active, idx) => { 616 if (!active) return false; 617 const activeUpToHere = activeMask.slice(0, idx + 1).filter(Boolean).length; 618 return activeUpToHere === i + 1; 619 }); 620 621 if (maskIndex < 6) { 622 ring1Nodes.push(nodePositions[i]); 623 } else if (maskIndex < 18) { 624 ring2Nodes.push(nodePositions[i]); 625 } else { 626 ring3Nodes.push(nodePositions[i]); 627 } 628 } 629 } 630 631 // No center node for search mode 632 const centerNode = null; 633 634 // All nodes that aren't search results stay on the sphere 635 const searchNodeIds = new Set(limitedNodes.map(n => n.id)); 636 const allNodeIds = Array.from(relationshipGraph.nodes.keys()); 637 const sphereNodes = allNodeIds.filter(nodeId => !searchNodeIds.has(nodeId)); 638 639 return { 640 centerNode, 641 ring1Nodes, 642 ring2Nodes, 643 ring3Nodes, 644 sphereNodes 645 }; 646 } 647 648 /** 649 * Get layout statistics for debugging 650 */ 651 export function getRingLayoutStats(positions: RingLayoutPositions): { 652 centerNode: string | null; 653 ring1Count: number; 654 ring2Count: number; 655 ring3Count: number; 656 sphereNodesCount: number; 657 totalProcessed: number; 658 } { 659 const centerCount = positions.centerNode ? 1 : 0; 660 const activeCount = positions.ring1Nodes.length + positions.ring2Nodes.length + positions.ring3Nodes.length; 661 662 return { 663 centerNode: positions.centerNode?.nodeId || null, 664 ring1Count: positions.ring1Nodes.length, 665 ring2Count: positions.ring2Nodes.length, 666 ring3Count: positions.ring3Nodes.length, 667 sphereNodesCount: positions.sphereNodes.length, 668 totalProcessed: centerCount + activeCount + positions.sphereNodes.length 669 }; 670 } 671 672 /** 673 * Priority-Preserving Mapping Layer 674 * 675 * Wraps the existing mask logic to ensure related nodes (with golden glow) 676 * always occupy inner ring positions without modifying the core mask algorithms. 677 * 678 * Strategy: Pre-sort the input list so mask logic naturally assigns 679 * inner positions to related nodes and outer positions to unrelated nodes. 680 */ 681 function applyPriorityMapping( 682 orderedNodes: Array<{ id: string; name?: string; type?: string }>, 683 _relationshipGraph: RelationshipGraph 684 ): Array<{ id: string; name?: string; type?: string }> { 685 686 // Get current pending relationships from edit mode store 687 // Since this is called during edit mode, we can safely access the store 688 const store = useInterBrainStore.getState(); 689 const pendingRelationshipIds = store.editMode.pendingRelationships || []; 690 691 console.log('[PriorityMapping] Input analysis:', { 692 totalNodes: orderedNodes.length, 693 pendingRelationshipIds, 694 nodeIds: orderedNodes.map(n => n.id) 695 }); 696 697 // Separate nodes into related (golden glow) and unrelated groups 698 const relatedNodes: typeof orderedNodes = []; 699 const unrelatedNodes: typeof orderedNodes = []; 700 701 orderedNodes.forEach(node => { 702 if (pendingRelationshipIds.includes(node.id)) { 703 relatedNodes.push(node); 704 } else { 705 unrelatedNodes.push(node); 706 } 707 }); 708 709 // Priority order: Related nodes first (will get inner ring positions), 710 // then unrelated nodes (will get outer ring positions) 711 const prioritySortedNodes = [...relatedNodes, ...unrelatedNodes]; 712 713 console.log('[PriorityMapping] Output analysis:', { 714 relatedCount: relatedNodes.length, 715 unrelatedCount: unrelatedNodes.length, 716 relatedIds: relatedNodes.map(n => n.id), 717 unrelatedIds: unrelatedNodes.map(n => n.id), 718 finalOrder: prioritySortedNodes.map(n => n.id) 719 }); 720 721 return prioritySortedNodes; 722 }