All files / apps/web/src/components/graph3d geometry.js

91.93% Statements 57/62
77.08% Branches 37/48
100% Functions 3/3
97.82% Lines 45/46

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124                            14x 14x 14x   14x 36x 34x 34x 34x 34x   14x   11x                                                       8x 8x   7x 7x 7x   7x 7x 7x 7x   7x 7x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x     7x                                     6x 2x   4x   4x 4x 4x 4x 4x     4x     4x 4x   4x          
/**
 * Géométrie du graphe 3D : boîte englobante des nœuds, pour centrer la cible
 * d'orbite de la caméra (le graphe tourne autour de son centre réel, pas de
 * l'origine du repère).
 */
 
/**
 * Centre et plus grande dimension de la boîte englobante.
 * Les positions sont celles mutées par la simulation (`x`, `y`, `z`).
 *
 * @param {Array<{x?:number,y?:number,z?:number}>} nodes
 * @returns {{center:{x:number,y:number,z:number}, size:number}|null} `null` si aucune position valide
 */
export function measureNodes(nodes = []) {
  let minX = Infinity; let minY = Infinity; let minZ = Infinity;
  let maxX = -Infinity; let maxY = -Infinity; let maxZ = -Infinity;
  let count = 0;
 
  for (const node of nodes) {
    if (!Number.isFinite(node?.x) || !Number.isFinite(node?.y) || !Number.isFinite(node?.z)) continue;
    minX = Math.min(minX, node.x); maxX = Math.max(maxX, node.x);
    minY = Math.min(minY, node.y); maxY = Math.max(maxY, node.y);
    minZ = Math.min(minZ, node.z); maxZ = Math.max(maxZ, node.z);
    count += 1;
  }
  if (!count) return null;
 
  return {
    center: {
      x: (minX + maxX) / 2,
      y: (minY + maxY) / 2,
      z: (minZ + maxZ) / 2,
    },
    size: Math.max(maxX - minX, maxY - minY, maxZ - minZ),
  };
}
 
/**
 * Distance caméra nécessaire pour que **tous** les nœuds tiennent dans le champ.
 *
 * Remplace `zoomToFit` de la librairie : la boîte englobante 3D ne suffit pas, un
 * nœud proche de la caméra se projette plus loin que sa position (perspective) et
 * sort du cadre. On calcule donc, pour chaque nœud, la distance minimale exigée
 * par son écart perpendiculaire à l'axe de vue, puis on prend le maximum.
 *
 * @param {Array<{x?:number,y?:number,z?:number}>} nodes positions (mutées par la simulation)
 * @param {{x:number,y:number,z:number}} direction axe cible → caméra (normalisé ou non)
 * @param {{ fovDeg?: number, aspect?: number, margin?: number }} [options]
 * @returns {number|null} distance caméra → cible, `null` si aucune position
 */
export function fitDistanceForNodes(
  nodes = [],
  direction = { x: 0, y: 0, z: 1 },
  { fovDeg = 50, aspect = 1.6, margin = 1.08 } = {},
) {
  const box = measureNodes(nodes);
  if (!box) return null;
 
  const vertical = (fovDeg * Math.PI) / 180;
  const horizontal = 2 * Math.atan(Math.tan(vertical / 2) * (aspect || 1));
  const tanHalf = Math.tan(Math.min(vertical, horizontal) / 2) || 1;
 
  const length = Math.hypot(direction.x, direction.y, direction.z) || 1;
  const ux = direction.x / length;
  const uy = direction.y / length;
  const uz = direction.z / length;
 
  let distance = 0;
  for (const node of nodes) {
    Iif (!Number.isFinite(node?.x) || !Number.isFinite(node?.y) || !Number.isFinite(node?.z)) continue;
    const dx = node.x - box.center.x;
    const dy = node.y - box.center.y;
    const dz = node.z - box.center.z;
    const depth = dx * ux + dy * uy + dz * uz; // > 0 : du côté de la caméra
    const rx = dx - depth * ux;
    const ry = dy - depth * uy;
    const rz = dz - depth * uz;
    const perpendicular = Math.hypot(rx, ry, rz);
    distance = Math.max(distance, perpendicular / tanHalf - depth);
  }
 
  return Math.max(distance, 1) * margin;
}
 
/**
 * Cadrage sur un nœud : on garde l'orientation et la distance de la caméra
 * (le zoom choisi par l'utilisateur) et on déplace la cible d'orbite sur le nœud.
 *
 * @param {{node:{x?:number,y?:number,z?:number}, cameraPosition:object, target:object,
 *          distance?:number, minDistance?:number, maxDistance?:number}} options
 * @returns {{target:{x:number,y:number,z:number}, position:{x:number,y:number,z:number}}|null}
 */
export function focusCamera({
  node,
  cameraPosition,
  target,
  distance,
  minDistance = 60,
  maxDistance = 4000,
} = {}) {
  if (!node || !Number.isFinite(node.x) || !Number.isFinite(node.y) || !Number.isFinite(node.z)) {
    return null;
  }
  Iif (!cameraPosition || !target) return null;
 
  let dx = cameraPosition.x - target.x;
  let dy = cameraPosition.y - target.y;
  let dz = cameraPosition.z - target.z;
  const length = Math.hypot(dx, dy, dz);
  Iif (length < 1e-6) {
    dx = 0.5; dy = 0.3; dz = 1;
  } else {
    dx /= length; dy /= length; dz /= length;
  }
 
  const wanted = Number.isFinite(distance) && distance > 0 ? distance : length || 300;
  const d = Math.min(maxDistance, Math.max(minDistance, wanted));
 
  return {
    target: { x: node.x, y: node.y, z: node.z },
    position: { x: node.x + dx * d, y: node.y + dy * d, z: node.z + dz * d },
  };
}