/ src / features / liminal-web-layout / RingLayout.ts
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  }