UNPKG

aframe-babia-components

Version:

A data visualization set of components for A-Frame.

1,753 lines 61.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>npm package dependencies</title>

  <script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/donmccurdy/aframe-extras@v7.2.0/dist/aframe-extras.min.js"></script>
    <script src="/dist/aframe-babia-components.js"></script>

  <style>
    body {
      margin: 0;
      overflow: hidden;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: #000000;
    }

    /* ── Info panel (top-left) ───────────────────────────────────────────── */
    #info-panel {
      position: absolute;
      top: 20px;
      left: 20px;
      background: rgba(0, 10, 20, 0.95);
      color: white;
      padding: 25px;
      border-radius: 15px;
      max-width: 350px;
      z-index: 1000;
      backdrop-filter: blur(15px);
      border: 2px solid rgba(39, 238, 245, 0.4);
      box-shadow: 0 8px 32px rgba(39, 238, 245, 0.15);
    }

    #info-panel h2 {
      margin-top: 0;
      color: #27EEF5;
      font-size: 20px;
      display: flex;
      align-items: center;
      gap: 10px;
      text-shadow: 0 0 10px rgba(39, 238, 245, 0.5);
    }

    #info-panel p {
      margin: 10px 0;
      font-size: 14px;
      line-height: 1.6;
    }

    .badge {
      display: inline-block;
      background: linear-gradient(135deg, #27EEF5, #0099a8);
      color: #000;
      padding: 4px 10px;
      border-radius: 15px;
      font-size: 11px;
      font-weight: bold;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }

    .vr-badge {
      background: linear-gradient(135deg, #FF1493, #FF69B4);
      color: #fff;
      animation: pulse 2s infinite;
    }

    @keyframes pulse {
      0%, 100% { opacity: 1; transform: scale(1); }
      50%       { opacity: 0.8; transform: scale(1.05); }
    }

    /* ── Bottom controls ─────────────────────────────────────────────────── */
    #controls {
      position: absolute;
      bottom: 20px;
      left: 50%;
      transform: translateX(-50%);
      background: rgba(0, 10, 20, 0.95);
      padding: 20px 30px;
      border-radius: 15px;
      z-index: 1000;
      backdrop-filter: blur(15px);
      display: flex;
      gap: 12px;
      align-items: center;
      flex-wrap: wrap;
      justify-content: center;
      border: 2px solid rgba(39, 238, 245, 0.4);
      max-width: 90vw;
      box-shadow: 0 8px 32px rgba(39, 238, 245, 0.15);
    }

    #controls button {
      background: linear-gradient(135deg, #27EEF5, #0099a8);
      border: none;
      color: #000;
      padding: 12px 20px;
      margin: 0;
      border-radius: 10px;
      cursor: pointer;
      font-size: 14px;
      transition: all 0.3s;
      font-weight: 700;
      box-shadow: 0 4px 15px rgba(39, 238, 245, 0.25);
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }

    #controls button:hover {
      background: linear-gradient(135deg, #0099a8, #006d78);
      transform: translateY(-3px);
      box-shadow: 0 6px 20px rgba(39, 238, 245, 0.4);
    }

    #controls button.active {
      background: linear-gradient(135deg, #FF1493, #FF69B4);
      color: white;
    }

    .divider {
      width: 2px;
      height: 35px;
      background: linear-gradient(180deg,
        rgba(39, 238, 245, 0),
        rgba(39, 238, 245, 0.5),
        rgba(39, 238, 245, 0));
      margin: 0 10px;
    }

    .slider-container {
      display: flex;
      align-items: center;
      gap: 10px;
      color: white;
      font-size: 13px;
      background: rgba(39, 238, 245, 0.08);
      padding: 10px 14px;
      border-radius: 10px;
      border: 1px solid rgba(39, 238, 245, 0.3);
    }

    input[type="range"] {
      width: 100px;
      height: 6px;
      background: rgba(255, 255, 255, 0.1);
      border-radius: 5px;
      outline: none;
      cursor: pointer;
      -webkit-appearance: none;
    }

    input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 18px;
      height: 18px;
      background: linear-gradient(135deg, #27EEF5, #0099a8);
      border-radius: 50%;
      cursor: pointer;
      box-shadow: 0 2px 8px rgba(39, 238, 245, 0.5);
    }

    input[type="range"]::-moz-range-thumb {
      width: 18px;
      height: 18px;
      background: linear-gradient(135deg, #27EEF5, #0099a8);
      border-radius: 50%;
      cursor: pointer;
      border: none;
    }

    /* ── Legend (top-right) ──────────────────────────────────────────────── */
    .legend {
      position: absolute;
      top: 20px;
      right: 20px;
      background: rgba(0, 10, 20, 0.95);
      padding: 20px;
      border-radius: 15px;
      color: white;
      z-index: 1000;
      backdrop-filter: blur(15px);
      border: 2px solid rgba(39, 238, 245, 0.4);
      max-width: 260px;
      box-shadow: 0 8px 32px rgba(39, 238, 245, 0.15);
    }

    .legend h3 {
      margin-top: 0;
      font-size: 16px;
      color: #27EEF5;
      margin-bottom: 12px;
      text-shadow: 0 0 8px rgba(39, 238, 245, 0.4);
    }

    .legend-item {
      display: flex;
      align-items: center;
      margin: 8px 0;
      font-size: 13px;
    }

    .legend-color {
      width: 20px;
      height: 20px;
      border-radius: 50%;
      margin-right: 10px;
      flex-shrink: 0;
    }

    .legend-section {
      margin-top: 16px;
      padding-top: 12px;
      border-top: 1px solid rgba(39, 238, 245, 0.25);
    }

    /* ── Loading screen ──────────────────────────────────────────────────── */
    #loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: white;
      font-size: 22px;
      z-index: 2000;
      text-align: center;
      background: rgba(0, 10, 20, 0.95);
      padding: 50px;
      border-radius: 20px;
      backdrop-filter: blur(15px);
      border: 2px solid rgba(39, 238, 245, 0.5);
    }

    .spinner {
      border: 5px solid rgba(39, 238, 245, 0.2);
      border-top: 5px solid #27EEF5;
      border-radius: 50%;
      width: 70px;
      height: 70px;
      animation: spin 1s linear infinite;
      margin: 25px auto;
      box-shadow: 0 0 20px rgba(39, 238, 245, 0.4);
    }

    @keyframes spin {
      0%   { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }

    .stats {
      margin-top: 15px;
      padding-top: 15px;
      border-top: 1px solid rgba(39, 238, 245, 0.25);
      font-size: 13px;
    }

    .stats p {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin: 8px 0;
    }

    .stat-value {
      color: #27EEF5;
      font-weight: bold;
      text-shadow: 0 0 5px rgba(39, 238, 245, 0.4);
    }

    .vr-info {
      background: rgba(255, 20, 147, 0.15);
      border: 1px solid rgba(255, 105, 180, 0.4);
      padding: 12px;
      border-radius: 10px;
      margin-top: 12px;
      font-size: 13px;
    }

    .instruction {
      display: flex;
      align-items: center;
      gap: 8px;
      margin: 6px 0;
      font-size: 13px;
    }
  </style>
</head>
<body>

  <!-- ── Loading ─────────────────────────────────────────────────────────── -->
  <div id="loading">
    <div class="spinner"></div>
    <p>📦 npm package dependencies</p>
    <p style="font-size: 16px; color: #444b4b;">Resolving dependency tree…</p>
  </div>

  <!-- ── Info panel ──────────────────────────────────────────────────────── -->
  <div id="info-panel">
    <h2>
      📦 npm dependencies
      <span class="badge">Use Case</span>
      <span class="badge vr-badge">VR</span>
    </h2>

    <p style="color: #414546; font-weight: bold; font-size: 15px;">
      express dependency tree
    </p>

    <p><strong>🖥️ Desktop:</strong></p>
    <div class="instruction">🖱️ Drag to rotate view</div>
    <div class="instruction">👆 Hover on a package node</div>

    <div class="vr-info">
      <p><strong>🥽 VR Mode:</strong></p>
      <div class="instruction">🕹️ Left joystick to move</div>
      <div class="instruction">🎮 Point with controllers</div>
      <div class="instruction">📦 View package info</div>
    </div>

    <p id="node-count" style="color:#aaa;">Loading packages…</p>

    <div class="stats">
      <p><span>⚡ Physics:</span>    <span id="physics-status"  class="stat-value">Enabled</span></p>
      <p><span>📊 FPS:</span>        <span id="fps-counter"     class="stat-value">--</span></p>
      <p><span>🥽 VR:</span>         <span id="vr-status"       class="stat-value">Inactive</span></p>
    </div>
  </div>

  <!-- ── Legend ──────────────────────────────────────────────────────────── -->
  <div class="legend">
    <h3>🎯 Node Color (CVE Severity)</h3>
    <div class="legend-item">
      <div class="legend-color" style="background:#1a3a5c; box-shadow:0 0 8px #1a3a5c;"></div>
      <span>No CVE / No vulnerabilities</span>
    </div>
    <div class="legend-item">
      <div class="legend-color" style="background:#00ff00; box-shadow:0 0 8px #00ff00;"></div>
      <span>Low severity (0-3)</span>
    </div>
    <div class="legend-item">
      <div class="legend-color" style="background:#ffff00; box-shadow:0 0 8px #ffff00;"></div>
      <span>Medium severity (4-6)</span>
    </div>
    <div class="legend-item">
      <div class="legend-color" style="background:#ff0000; box-shadow:0 0 8px #ff0000;"></div>
      <span>High severity (7-10)</span>
    </div>

    <div class="legend-section">
      <h3>📏 Node Size (Package Size)</h3>
      <p style="font-size:12px; line-height:1.7; color:#ccc;">
        Sphere radius proportional<br>
        to npm package size (KB)<br>
        Small = lightweight<br>
        Large = heavy package
      </p>
    </div>

    <div class="legend-section">
      <h3>🔗 Links (Dependency Type)</h3>
      <div class="legend-item">
        <div class="legend-color" style="background:#3050F8; border-radius:3px; height:4px; width:24px; margin-right:10px;"></div>
        <span>Direct dependency</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#808080; border-radius:3px; height:4px; width:24px; margin-right:10px;"></div>
        <span>Transitive dependency</span>
      </div>
    </div>

    <div class="legend-section">
      <h3>📜 Node Color (License)</h3>
      <div class="legend-item">
        <div class="legend-color" style="background:#ff00ff; box-shadow:0 0 8px #ff00ff;"></div>
        <span>AGPL (strong copyleft)</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#ff2222; box-shadow:0 0 8px #ff2222;"></div>
        <span>GPL (restrictive)</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#ff8800; box-shadow:0 0 8px #ff8800;"></div>
        <span>LGPL</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#ffcc00; box-shadow:0 0 8px #ffcc00;"></div>
        <span>MPL</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#00aaff; box-shadow:0 0 8px #00aaff;"></div>
        <span>Apache</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#00ddaa; box-shadow:0 0 8px #00ddaa;"></div>
        <span>BSD</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#33cc66; box-shadow:0 0 8px #33cc66;"></div>
        <span>MIT / ISC (permissive)</span>
      </div>
      <div class="legend-item">
        <div class="legend-color" style="background:#666666; box-shadow:0 0 8px #666666;"></div>
        <span>Unknown</span>
      </div>
    </div>

    <div class="legend-section">
      <h3>💡 How to read it</h3>
      <p style="font-size:12px; line-height:1.7; color:#ccc;">
        Red nodes = vulnerable<br>
        Large nodes = big packages<br>
        Blue links = direct deps<br>
        Grey links = indirect deps
      </p>
    </div>
  </div>

  <!-- ── Bottom controls ─────────────────────────────────────────────────── -->
  <div id="controls">
    <button id="toggle-links" onclick="toggleLinks()">🔗 Links</button>
    <button onclick="toggleAnimation()">⏯️ Rotation</button>

    <div class="divider"></div>

    <div class="slider-container">
      <span>🎨 Color:</span>
      <select id="color-mode" onchange="setColorMode(this.value)"
              style="background:#0a1a2a; color:#27EEF5; border:1px solid rgba(39,238,245,0.4); border-radius:6px; padding:4px 6px; font-size:12px;">
        <option value="cve" selected>CVE severity</option>
        <option value="license">License</option>
      </select>
    </div>

    <div class="slider-container">
      <span>📜 License:</span>
      <input type="text" id="license-filter" placeholder="GPL, AGPL…"
             oninput="setLicenseFilter(this.value)"
             style="background:#0a1a2a; color:#27EEF5; border:1px solid rgba(39,238,245,0.4); border-radius:6px; padding:4px 6px; font-size:12px; width:120px;">
    </div>

    <div class="divider"></div>

    <div class="slider-container">
      <span>💪 Force:</span>
      <input type="range" id="physics-strength" min="0" max="1" step="0.05" value="0.4"
             oninput="updatePhysicsStrength(this.value)">
      <span id="strength-value">0.4</span>
    </div>

    <div class="slider-container">
      <span>📦 Size:</span>
      <input type="range" id="node-size-slider" min="0.5" max="2.5" step="0.1" value="1"
             oninput="updateStarSize(this.value)">
      <span id="size-value">1.0</span>
    </div>

    <div class="divider"></div>

    <button onclick="resetView()">🔄 Reset</button>
  </div>

  <!-- ── A-Frame scene ───────────────────────────────────────────────────── -->
 <a-scene xr-mode-ui="XRMode: xr" 
           background="color: #000008" 
           renderer="antialias: true; colorManagement: true; physicallyCorrectLights: true; exposure: 1.5"
           >

    <!-- Raycaster for Mouse -->
    <a-entity 
      id="mouse-cursor"
      cursor="rayOrigin: mouse" 
      raycaster="objects: .interactive-node; far: 100; interval: 100">
    </a-entity>

    <!-- Camera Rig -->
    <a-entity id="camera-rig" position="0 0 0" vr-thumbstick-locomotion="speed: 5; deadZone: 0.15" >
      
      <!-- Main Camera -->
      <!-- En tu cámara -->
<a-camera id="main-camera" position="0 0 0" look-controls wasd-controls="acceleration: 40; fly: true" vertical-controls="speed: 5" vr-info-panel vr-color-toggle vr-gamepad-controls></a-camera>
      
      <!-- LEFT VR Controller -->
      <a-entity 
        id="left-hand"
        laser-controls="hand: left"
        raycaster="objects: .interactive-node; far: 50; lineColor: #FFD700; lineOpacity: 0.8"
        line="color: #FFD700; opacity: 0.8; visible: true">
        
      </a-entity>
      
      <!-- RIGHT VR Controller -->
      <a-entity 
        id="right-hand"
        laser-controls="hand: right"
        raycaster="objects: .interactive-node; far: 50; lineColor: #FF69B4; lineOpacity: 0.8"
        line="color: #FF69B4; opacity: 0.8; visible: true">
      
      </a-entity>
    </a-entity>

    <!-- ENHANCED LIGHTING FOR STARS -->
    <a-entity light="type: ambient; color: #FFFFFF; intensity: 1.2"></a-entity>
    <a-entity light="type: directional; color: #FFFFFF; intensity: 0.8" position="5 10 5"></a-entity>
    <a-entity light="type: directional; color: #AACCFF; intensity: 0.5" position="-5 -5 -5"></a-entity>
    <a-entity light="type: point; color: #FFD700; intensity: 0.6; distance: 100" position="0 0 0"></a-entity>

    <!-- Visualizer Component (BASE - UNCHANGED) -->
    <a-entity id="visualizer" babia-dome="
      dataUrl: express-dome.json;
      sphereRadius: 18;
      nodeSize: 0.18;
      showLabels: false;
      animateNodes: true;
      colorByValue: false;
      nodeOpacity: 1.0;
      enablePhysics: true;
      physicsStrength: 0.1;
      collisionRadius: 0;
      showLinks: true;
      linkSegments: 50;
      linkOpacity: 0.4;
      linkWidth: 0.02;
      linkStyle: line;
      colorByCVE: true;
      sizeByPackageSize: true;
      linkColorByType: true;
      minNodeSize: 0.1;
      maxNodeSize: 0.4;
    "></a-entity>


    <a-entity id="distant-stars"></a-entity>


    <a-sky hide-on-enter-ar color="#000008" material="shader: flat"></a-sky>
  </a-scene>

  <script>

    // Vertical fly controls for desktop: Space / E = up, Shift / Q = down
    AFRAME.registerComponent('vertical-controls', {
      schema: {
        speed: { type: 'number', default: 5 }
      },
      init: function () {
        this.keys = { up: false, down: false };
        this.onKeyDown = (e) => {
          if (e.code === 'Space' || e.code === 'KeyE') this.keys.up = true;
          else if (e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'KeyQ') this.keys.down = true;
        };
        this.onKeyUp = (e) => {
          if (e.code === 'Space' || e.code === 'KeyE') this.keys.up = false;
          else if (e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'KeyQ') this.keys.down = false;
        };
        window.addEventListener('keydown', this.onKeyDown);
        window.addEventListener('keyup', this.onKeyUp);
      },
      remove: function () {
        window.removeEventListener('keydown', this.onKeyDown);
        window.removeEventListener('keyup', this.onKeyUp);
      },
      tick: function (time, deltaTime) {
        if (!deltaTime) return;
        // Skip in VR (handled by thumbstick locomotion)
        if (this.el.sceneEl && (this.el.sceneEl.is('vr-mode') || this.el.sceneEl.is('ar-mode'))) return;
        const dt = deltaTime / 1000;
        let dy = 0;
        if (this.keys.up) dy += this.data.speed * dt;
        if (this.keys.down) dy -= this.data.speed * dt;
        if (dy !== 0) {
          // Move the camera rig if exists, otherwise the camera itself
          const rig = document.getElementById('camera-rig');
          const target = rig ? rig.object3D : this.el.object3D;
          target.position.y += dy;
        }
      }
    });

    AFRAME.registerComponent('vr-thumbstick-locomotion', {
      schema: {
        speed: { type: 'number', default: 4 },
        deadZone: { type: 'number', default: 0.15 },
        rotationSpeed: { type: 'number', default: 1.5 }
      },

      init: function () {
        this.leftInput = { x: 0, y: 0 };
        this.rightInput = { x: 0, y: 0 };
        this.cameraEl = null;
        this.forward = new THREE.Vector3();
        this.right = new THREE.Vector3();
        this.moveDir = new THREE.Vector3();
        this.up = new THREE.Vector3(0, 1, 0);

        this.onLeftThumbstickMoved = this.onLeftThumbstickMoved.bind(this);
        this.onLeftThumbstickEnded = this.onLeftThumbstickEnded.bind(this);
        this.onRightThumbstickMoved = this.onRightThumbstickMoved.bind(this);
        this.onRightThumbstickEnded = this.onRightThumbstickEnded.bind(this);

        this.el.sceneEl.addEventListener('loaded', () => {
          this.cameraEl = document.querySelector('#main-camera');
          this.leftHandEl = document.querySelector('#left-hand');
          this.rightHandEl = document.querySelector('#right-hand');

          if (this.leftHandEl) {
            this.leftHandEl.addEventListener('thumbstickmoved', this.onLeftThumbstickMoved);
            this.leftHandEl.addEventListener('thumbstickend', this.onLeftThumbstickEnded);
          }

          if (this.rightHandEl) {
            this.rightHandEl.addEventListener('thumbstickmoved', this.onRightThumbstickMoved);
            this.rightHandEl.addEventListener('thumbstickend', this.onRightThumbstickEnded);
          }
        });
      },

      remove: function () {
        if (this.leftHandEl) {
          this.leftHandEl.removeEventListener('thumbstickmoved', this.onLeftThumbstickMoved);
          this.leftHandEl.removeEventListener('thumbstickend', this.onLeftThumbstickEnded);
        }
        if (this.rightHandEl) {
          this.rightHandEl.removeEventListener('thumbstickmoved', this.onRightThumbstickMoved);
          this.rightHandEl.removeEventListener('thumbstickend', this.onRightThumbstickEnded);
        }
      },

      onLeftThumbstickMoved: function (evt) {
        const x = evt.detail && typeof evt.detail.x === 'number' ? evt.detail.x : 0;
        const y = evt.detail && typeof evt.detail.y === 'number' ? evt.detail.y : 0;

        this.leftInput.x = Math.abs(x) > this.data.deadZone ? -x : 0;
        this.leftInput.y = Math.abs(y) > this.data.deadZone ? -y : 0;
      },

      onLeftThumbstickEnded: function () {
        this.leftInput.x = 0;
        this.leftInput.y = 0;
      },

      onRightThumbstickMoved: function (evt) {
        const x = evt.detail && typeof evt.detail.x === 'number' ? evt.detail.x : 0;
        const y = evt.detail && typeof evt.detail.y === 'number' ? evt.detail.y : 0;

        this.rightInput.x = Math.abs(x) > this.data.deadZone ? x : 0;
        this.rightInput.y = Math.abs(y) > this.data.deadZone ? y : 0;
      },

      onRightThumbstickEnded: function () {
        this.rightInput.x = 0;
        this.rightInput.y = 0;
      },

      tick: function (time, deltaTime) {
        if (!this.el.sceneEl || (!this.el.sceneEl.is('vr-mode') && !this.el.sceneEl.is('ar-mode'))) return;
        if (!this.cameraEl || !this.cameraEl.object3D) return;
        if (!deltaTime) return;

        const dt = deltaTime / 1000;

        // Handle movement with left thumbstick
        if (this.leftInput.x || this.leftInput.y) {
          const cameraObj = this.cameraEl.object3D;

          cameraObj.getWorldDirection(this.forward);
          this.forward.normalize();

          // 'right' debe permanecer horizontal para que el strafe no incline
          this.right.crossVectors(this.forward, this.up).normalize();

          this.moveDir.set(0, 0, 0);
          this.moveDir.addScaledVector(this.forward, -this.leftInput.y);
          this.moveDir.addScaledVector(this.right, this.leftInput.x);

          if (this.moveDir.lengthSq() > 0) {
            this.moveDir.normalize().multiplyScalar(this.data.speed * dt);
            this.el.object3D.position.add(this.moveDir);
          }
        }

        // Handle rotation with right thumbstick
        if (this.rightInput.x || this.rightInput.y) {
          // Yaw (left-right)
          this.el.object3D.rotation.y -= this.rightInput.x * this.data.rotationSpeed * dt;

          // Pitch (up-down), clamp to prevent flipping
          const currentPitch = this.cameraEl.object3D.rotation.x;
          const newPitch = currentPitch - this.rightInput.y * this.data.rotationSpeed * dt;
          this.cameraEl.object3D.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, newPitch));
        }
      }
    });
    // Create thousands of distant stars
    function createDistantStars() {
      const container = document.getElementById('distant-stars');
      const starCount = 2000;
      const minRadius = 25; // Beyond visualization sphere
      const maxRadius = 60;
      
      for (let i = 0; i < starCount; i++) {
        const star = document.createElement('a-sphere');
        
        // Random position in 3D space
        const phi = Math.random() * Math.PI * 2;
        const theta = Math.random() * Math.PI;
        const radius = minRadius + Math.random() * (maxRadius - minRadius);
        
        const x = radius * Math.sin(theta) * Math.cos(phi);
        const y = radius * Math.sin(theta) * Math.sin(phi);
        const z = radius * Math.cos(theta);
        
        // Varying star sizes (most small, some larger)
        const size = Math.random() < 0.9 ? 0.02 + Math.random() * 0.03 : 0.05 + Math.random() * 0.05;
        
        // Varying brightness/opacity
        const opacity = 0.3 + Math.random() * 0.7;
        
        // Slight color variation (white to blue-white)
        const colorOptions = ['#FFFFFF', '#F0F8FF', '#E6F2FF', '#FFFACD'];
        const color = colorOptions[Math.floor(Math.random() * colorOptions.length)];
        
        star.setAttribute('radius', size);
        star.setAttribute('position', `${x} ${y} ${z}`);
        star.setAttribute('material', {
          shader: 'flat',
          color: color,
          opacity: opacity,
          transparent: true,

        });
        
        
        if (Math.random() > 0.7) {
          star.setAttribute('animation', {
            property: 'components.material.material.opacity',
            from: opacity,
            to: opacity * 0.3,
            dur: 2000 + Math.random() * 3000,
            dir: 'alternate',
            loop: true,
            easing: 'easeInOutQuad'
          });
        }
        
        container.appendChild(star);
      }
      
      console.log(`✨ Created ${starCount} distant background stars`);
    }
    
    // Initialize space background when scene loads
    document.querySelector('a-scene').addEventListener('loaded', function() {
      createDistantStars();
    });
  </script>

  <script>
let animationEnabled = true;
let linksEnabled = true;
let visualizerComponent = null;
let isVRMode = false;
let currentStarScale = 1.0;

// FPS Counter
let frameCount = 0;
let lastFPSUpdate = Date.now();

function updateFPS() {
  frameCount++;
  const now = Date.now();
  if (now - lastFPSUpdate >= 1000) {
    const fps = Math.round(frameCount * 1000 / (now - lastFPSUpdate));
    const fpsEl = document.getElementById('fps-counter');
    if (fpsEl) fpsEl.textContent = fps;
    frameCount = 0;
    lastFPSUpdate = now;
  }
  requestAnimationFrame(updateFPS);
}

document.addEventListener('DOMContentLoaded', function() {
  const scene = document.querySelector('a-scene');
  const visualizer = document.querySelector('#visualizer');
  const loading = document.getElementById('loading');
  
  updateFPS();
  
  console.log('🚀 VR Planetarium initialized');

  // Intentar sincronizar inmediatamente los sliders leyendo los atributos
  // del elemento `#visualizer` (atributo inline `babia-dome`) para reflejar
  // valores como `physicsStrength: 0.4;` sin esperar al componente.
  (function syncSlidersFromEntityAttrs() {
    try {
      if (!visualizer) return;
      const raw = visualizer.getAttribute('babia-dome');
      if (!raw || typeof raw !== 'string') return;

      // Buscar physicsStrength
      const psMatch = raw.match(/physicsStrength\s*:\s*([0-9.]+)/);
      if (psMatch && psMatch[1]) {
        const val = parseFloat(psMatch[1]);
        const strengthSlider = document.getElementById('physics-strength');
        const strengthValue = document.getElementById('strength-value');
        if (strengthSlider) strengthSlider.value = val;
        if (strengthValue) strengthValue.textContent = val.toFixed(2);
        console.log('🔧 Initial physicsStrength from attribute:', val);
      }

      // Buscar nodeSize -> mapear a escala del slider (base 0.18)
      const nsMatch = raw.match(/nodeSize\s*:\s*([0-9.]+)/);
      if (nsMatch && nsMatch[1]) {
        const nodeSize = parseFloat(nsMatch[1]);
        const base = 0.18;
        const scale = nodeSize / base;
        const sizeSlider = document.getElementById('star-size');
        const sizeValue = document.getElementById('size-value');
        if (sizeSlider) sizeSlider.value = scale;
        if (sizeValue) sizeValue.textContent = scale.toFixed(1) + 'x';
        currentStarScale = scale;
        console.log('⭐ Initial nodeSize from attribute:', nodeSize, '-> scale', scale);
      }
    } catch (e) {
      console.warn('Could not parse babia-dome attrs for sliders', e);
    }
  })();
  
  // Detect VR entry
  scene.addEventListener('enter-vr', function() {
    console.log('🥽 VR MODE ACTIVATED');
    isVRMode = true;
    
    const vrStatus = document.getElementById('vr-status');
    if (vrStatus) {
      vrStatus.textContent = 'Active';
      vrStatus.style.color = '#FF69B4';
    }
    
    document.getElementById('info-panel').style.display = 'none';
    document.getElementById('controls').style.display = 'none';
    document.querySelector('.legend').style.display = 'none';
    animationEnabled = false;
  });
  
  // Detect VR exit
  scene.addEventListener('exit-vr', function() {
    console.log('🖥️ Desktop mode');
    isVRMode = false;
    
    const vrStatus = document.getElementById('vr-status');
    if (vrStatus) {
      vrStatus.textContent = 'Inactive';
      vrStatus.style.color = '#FFD700';
    }
    
    document.getElementById('info-panel').style.display = 'block';
    document.getElementById('controls').style.display = 'flex';
    document.querySelector('.legend').style.display = 'block';
  });
  
  // Scene loaded
  scene.addEventListener('loaded', function() {
    console.log('✅ Scene loaded');
    
    setTimeout(() => {
      if (loading) loading.style.display = 'none';
      
      visualizerComponent = visualizer.components['babia-dome'];
      
      if (visualizerComponent) {
       
        // ✅ Sincronizar valores del componente con los sliders
        syncComponentValuesToUI(visualizerComponent);
        updateLicenseStats();
        
        
      }
    }, 1500);
  });
  AFRAME.registerComponent('vr-gamepad-controls', {
  init: function () {
    this.lastButtonState = {};
    this.REPEAT_DELAY = 300;    // ms antes de repetir al mantener pulsado
    this.REPEAT_INTERVAL = 150; // ms entre repeticiones
    this.repeatTimers = {};

    // Mapeo de botones (estándar WebXR)
    // Y = índice 5 (mano izquierda), X = índice 4 (mano izquierda)
    // B = índice 5 (mano derecha),  A = índice 4 (mano derecha)
    this.buttonMap = {
      left:  { 4: 'X', 5: 'Y' },
      right: { 4: 'A', 5: 'B' }
    };

    this.STAR_SIZE_STEP      = 0.1;
    this.STAR_SIZE_MIN       = 0.5;
    this.STAR_SIZE_MAX       = 2.5;
    this.PHYSICS_STEP        = 0.05;
    this.PHYSICS_MIN         = 0.0;
    this.PHYSICS_MAX         = 1.0;

    this.onButtonDown = this.onButtonDown.bind(this);
    this.onButtonUp = this.onButtonUp.bind(this);

    this.el.sceneEl.addEventListener('loaded', () => {
      this.leftHandEl = document.querySelector('#left-hand');
      this.rightHandEl = document.querySelector('#right-hand');

      if (this.leftHandEl) {
        this.leftHandEl.addEventListener('buttondown', this.onButtonDown);
        this.leftHandEl.addEventListener('buttonup', this.onButtonUp);
      }

      if (this.rightHandEl) {
        this.rightHandEl.addEventListener('buttondown', this.onButtonDown);
        this.rightHandEl.addEventListener('buttonup', this.onButtonUp);
      }
    });
  },

  // ── Helpers para leer sliders y componente ──────────────────────────────

  _getVisualizer: function () {
    const el = document.querySelector('#visualizer');
    return el ? el.components['babia-dome'] : null;
  },

  _getStarSize: function () {
    const slider = document.getElementById('star-size');
    return slider ? parseFloat(slider.value) : (window.currentStarScale || 1.0);
  },

  _getPhysicsStrength: function () {
    const slider = document.getElementById('physics-strength');
    return slider ? parseFloat(slider.value) : 0.6;
  },

  // ── Acciones ────────────────────────────────────────────────────────────

  _increaseStarSize: function () {
    let val = Math.min(this.STAR_SIZE_MAX,
                       parseFloat((this._getStarSize() + this.STAR_SIZE_STEP).toFixed(2)));
    const slider = document.getElementById('star-size');
    if (slider) slider.value = val;
    const sizeValueEl = document.getElementById('size-value');
    if (sizeValueEl) sizeValueEl.textContent = val.toFixed(1) + 'x';
    window.currentStarScale = val;
    const baseRadius = this._getVisualizer() ? this._getVisualizer().data.nodeSize : 0.18;
    const stars = document.querySelectorAll('.interactive-node');
    stars.forEach(star => {
      star.setAttribute('radius', baseRadius * val);
    });
    console.log(`⭐ Star size → ${val}`);
  },

  _decreaseStarSize: function () {
    let val = Math.max(this.STAR_SIZE_MIN,
                       parseFloat((this._getStarSize() - this.STAR_SIZE_STEP).toFixed(2)));
    const slider = document.getElementById('star-size');
    if (slider) slider.value = val;
    const sizeValueEl = document.getElementById('size-value');
    if (sizeValueEl) sizeValueEl.textContent = val.toFixed(1) + 'x';
    window.currentStarScale = val;
    const baseRadius = this._getVisualizer() ? this._getVisualizer().data.nodeSize : 0.18;
    const stars = document.querySelectorAll('.interactive-node');
    stars.forEach(star => {
      star.setAttribute('radius', baseRadius * val);
    });
    console.log(`⭐ Star size → ${val}`);
  },

  _increasePhysics: function () {
    let val = Math.min(this.PHYSICS_MAX,
                       parseFloat((this._getPhysicsStrength() + this.PHYSICS_STEP).toFixed(2)));
    const slider = document.getElementById('physics-strength');
    if (slider) slider.value = val;
    const strengthValueEl = document.getElementById('strength-value');
    if (strengthValueEl) strengthValueEl.textContent = val.toFixed(2);
    const vis = this._getVisualizer();
    if (vis) vis.updatePhysicsStrength(val);
    console.log(`💪 Physics → ${val}`);
  },

  _decreasePhysics: function () {
    let val = Math.max(this.PHYSICS_MIN,
                       parseFloat((this._getPhysicsStrength() - this.PHYSICS_STEP).toFixed(2)));
    const slider = document.getElementById('physics-strength');
    if (slider) slider.value = val;
    const strengthValueEl = document.getElementById('strength-value');
    if (strengthValueEl) strengthValueEl.textContent = val.toFixed(2);
    const vis = this._getVisualizer();
    if (vis) vis.updatePhysicsStrength(val);
    console.log(`💪 Physics → ${val}`);
  },

  // ── Dispatch por botón ──────────────────────────────────────────────────

  _handleButton: function (hand, index) {
    const name = this.buttonMap[hand] && this.buttonMap[hand][index];
    if (!name) return;

    if      (name === 'Y') this._increaseStarSize();
    else if (name === 'X') this._decreaseStarSize();
    else if (name === 'B') this._increasePhysics();
    else if (name === 'A') this._decreasePhysics();
  },

  onButtonDown: function (evt) {
    const hand = evt.target.id === 'left-hand' ? 'left' : 'right';
    const button = evt.detail.id;

    const key = `${hand}-${button}`;
    const wasPressed = this.lastButtonState[key] || false;

    if (!wasPressed) {
      // Pulsación inicial
      this._handleButton(hand, button);

      // Iniciar repetición al mantener
      this.repeatTimers[key] = {
        timeout: setTimeout(() => {
          this.repeatTimers[key].interval = setInterval(() => {
            this._handleButton(hand, button);
          }, this.REPEAT_INTERVAL);
        }, this.REPEAT_DELAY)
      };
    }

    this.lastButtonState[key] = true;
  },

  onButtonUp: function (evt) {
    const hand = evt.target.id === 'left-hand' ? 'left' : 'right';
    const button = evt.detail.id;

    const key = `${hand}-${button}`;

    // Botón soltado: cancelar repetición
    if (this.repeatTimers[key]) {
      clearTimeout(this.repeatTimers[key].timeout);
      clearInterval(this.repeatTimers[key].interval);
      delete this.repeatTimers[key];
    }

    this.lastButtonState[key] = false;
  },

  // ── Tick: no necesitamos leer gamepads ───────────────────────────────

  tick: function () {
    // Eventos manejan todo
  },

  remove: function () {
    if (this.leftHandEl) {
      this.leftHandEl.removeEventListener('buttondown', this.onButtonDown);
      this.leftHandEl.removeEventListener('buttonup', this.onButtonUp);
    }
    if (this.rightHandEl) {
      this.rightHandEl.removeEventListener('buttondown', this.onButtonDown);
      this.rightHandEl.removeEventListener('buttonup', this.onButtonUp);
    }
    for (const key of Object.keys(this.repeatTimers)) {
      clearTimeout(this.repeatTimers[key].timeout);
      clearInterval(this.repeatTimers[key].interval);
    }
    this.repeatTimers = {};
  }
});
  // Auto-rotation
  let rotation = 0;
  function autoRotate() {
    if (animationEnabled && visualizer && !isVRMode) {
      rotation += 0.0001;
      if (visualizer.object3D) {
        visualizer.object3D.rotation.y = rotation;
      }
    }
    requestAnimationFrame(autoRotate);
  }
  autoRotate();
});

// ✅ FUNCIÓN DE SINCRONIZACIÓN
function syncComponentValuesToUI(component) {
  const physicsStrength = component.data.physicsStrength;
  const strengthSlider = document.getElementById('physics-strength');
  const strengthValue = document.getElementById('strength-value');
  
  if (strengthSlider && strengthValue) {
    strengthSlider.value = physicsStrength;
    strengthValue.textContent = physicsStrength.toFixed(2);
    console.log(`🔧 Physics Strength synced: ${physicsStrength}`);
  }
  
  const componentNodeSize = component.data.nodeSize;
  const baseNodeSize = 0.18;
  const currentScale = componentNodeSize / baseNodeSize;
  
  const sizeSlider = document.getElementById('star-size');
  const sizeValue = document.getElementById('size-value');
  
  if (sizeSlider && sizeValue) {
    currentStarScale = currentScale;
    sizeSlider.value = currentScale;
    sizeValue.textContent = currentScale.toFixed(1) + 'x';
    console.log(`⭐ Star Size synced: ${currentScale.toFixed(1)}x`);
  }
}

function toggleLinks() {
  linksEnabled = !linksEnabled;
  const button = document.getElementById('toggle-links');
  
  if (visualizerComponent) {
    visualizerComponent.toggleLinks(linksEnabled);
    
    if (button) {
      button.classList.toggle('active', !linksEnabled);
      button.textContent = linksEnabled ? '🔗 Lines' : '🔗 Hidden';
    }
  }
}

function updateLicenseStats() {
  if (!visualizerComponent || !visualizerComponent.getLicenseStats) return;
  const stats = visualizerComponent.getLicenseStats();
  let gplTotal = 0;
  for (const [lic, c] of Object.entries(stats)) {
    if (lic.toUpperCase().includes('GPL')) gplTotal += c; // includes LGPL/AGPL/GPL
  }
  const gplEl = document.getElementById('gpl-count');
  if (gplEl) gplEl.textContent = gplTotal;
  refreshLicenseMatchCount();
}

function refreshLicenseMatchCount() {
  if (!visualizerComponent || !visualizerComponent.getLicenseStats) return;
  const input = document.getElementById('license-filter');
  const el = document.getElementById('license-match-count');
  if (!el) return;
  const str = (input && input.value || '').trim();
  if (!str) { el.textContent = 'all'; return; }
  const tokens = str.toUpperCase().split(',').map(s => s.trim()).filter(Boolean);
  const stats = visualizerComponent.getLicenseStats();
  let n = 0;
  for (const [lic, c] of Object.entries(stats)) {
    if (tokens.some(t => lic.toUpperCase().includes(t))) n += c;
  }
  el.textContent = n;
}

function setColorMode(mode) {
  if (!visualizerComponent || !visualizerComponent.setColorMode) return;
  visualizerComponent.setColorMode(mode);
}

function setLicenseFilter(str) {
  if (!visualizerComponent || !visualizerComponent.setLicenseFilter) return;
  visualizerComponent.setLicenseFilter(str);
  refreshLicenseMatchCount();
}

function toggleAnimation() {
  animationEnabled = !animationEnabled;
  console.log('⏯️ Auto-rotation:', animationEnabled ? 'Enabled' : 'Disabled');
}

function updateStarSize(value) {
  currentStarScale = parseFloat(value);
  const sizeValueEl = document.getElementById('size-value');
  if (sizeValueEl) sizeValueEl.textContent = currentStarScale.toFixed(1) + 'x';
  
  const baseRadius = visualizerComponent ? visualizerComponent.data.nodeSize : 0.18;
  const stars = document.querySelectorAll('.interactive-node');
  stars.forEach(star => {
    star.setAttribute('radius', baseRadius * currentStarScale);
  });
}

function updatePhysicsStrength(value) {
  const strengthValue = parseFloat(value);
  const strengthValueEl = document.getElementById('strength-value');
  if (strengthValueEl) strengthValueEl.textContent = strengthValue.toFixed(2);
  
  if (visualizerComponent) {
    visualizerComponent.updatePhysicsStrength(strengthValue);
  }
}

function resetView() {
  console.log('🔄 Full reset initiated');

  const cameraRig = document.querySelector('#camera-rig');
  if (cameraRig) {
    cameraRig.setAttribute('position', '0 0 0');
    cameraRig.object3D.rotation.set(0, 0, 0);
  }

  const camera = document.querySelector('a-camera');
  if (camera) {
    camera.setAttribute('position', '0 0 0');
    camera.setAttribute('rotation', '0 0 0');
  }

  if (visualizerComponent) {
    const originalPhysicsStrength = visualizerComponent.data.physicsStrength;
    updatePhysicsStrength(originalPhysicsStrength);
    
    const strengthSlider = document.getElementById('physics-strength');
    if (strengthSlider) strengthSlider.value = originalPhysicsStrength;
    
    const componentNodeSize = visualizerComponent.data.nodeSize;
    const originalScale = componentNodeSize / 0.18;
    updateStarSize(originalScale);
    
    const sizeSlider = document.getElementById('star-size');
    if (sizeSlider) sizeSlider.value = originalScale;
    
    if (visualizerComponent.simulation) {
      visualizerComponent.simulation.alpha(1).restart();
    }
  }

  animationEnabled = true;

  if (!linksEnabled && visualizerComponent) {
    linksEnabled = true;
    visualizerComponent.toggleLinks(true);
    const button = document.getElementById('toggle-links');
    if (button) {
      button.classList.remove('active');
      button.textContent = '🔗 Lines';
    }
  }

  console.log('✅ Reset completed');
}

      
      // Auto-rotation ONLY in desktop mode
      let rotation = 0;
      function autoRotate() {
        if (animationEnabled && visualizer && !isVRMode) {
          rotation += 0.0001;
          if (visualizer.object3D) {
            visualizer.object3D.rotation.y = rotation;
          }
        }
        requestAnimationFrame(autoRotate);
      }
      autoRotate();
      
      // Debugging: Check VR controllers
      const leftHand = document.querySelector('#left-hand');
      const rightHand = document.querySelector('#right-hand');
      
      if (leftHand) {
        leftHand.addEventListener('controllerconnected', function(evt) {
          console.log('🎮 LEFT controller connected:', evt.detail.name);
        });
      }
      
      if (rightHand) {
        rightHand.addEventListener('controllerconnected', function(evt) {
          console.log('🎮 RIGHT controller connected:', evt.detail.name);
        });
      }
    ;
    
    // ========== VR INFO PANEL COMPONENT ==========
    AFRAME.registerComponent('vr-info-panel', {
      schema: {
        width: { type: 'number', default: 256 },
        height: { type: 'number', default: 192 },
        distance: { type: 'number', default: 1.2 },
        offsetX: { type: 'number', default: -0.4 },
        offsetY: { type: 'number', default: -0.5 }
      },

      init: function() {
        this.scene = this.el.sceneEl;
        this.canvas = null;
        this.texture = null;
        this.mesh = null;
        this.panelMesh = null;
        this.isVR = false;

        // Create canvas with info text
        this.createPanel();
        
        // Listen for VR events
        this.scene.addEventListener('enter-vr', () => this.onEnterVR());
        this.scene.addEventListener('exit-vr', () => this.onExitVR());
      },

      createPanel: function() {
        // Create canvas
        this.canvas = document.createElement('canvas');
        this.canvas.width = this.data.width;
        this.canvas.height = this.data.height;
        
        const ctx = this.canvas.getContext('2d');
        
        // Draw semi-transparent background (black)
        ctx.fillStyle = 'rgba(0, 0, 0, 0.9)';
        ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        
        // Draw border
        ctx.strokeStyle = 'rgba(255, 215, 0, 0.6)';
        ctx.lineWidth = 2;
        ctx.strokeRect(3, 3, this.canvas.width - 6, this.canvas.height - 6);
        
        // Draw glow effect
        ctx.shadowColor = 'rgba(255, 215, 0, 0.4)';
        ctx.shadowBlur = 20;
        ctx.shadowOffsetX = 0;
        ctx.shadowOffsetY = 0;
        
        // Title
        ctx.fillStyle = '#FFD700';
        ctx.font = 'bold 16px Arial, sans-serif';
        ctx.textAlign = 'left';
        ctx.fillText('🥽 VR CONTROLS', 12, 25);
        
        // Reset shadow for text
        ctx.shadowColor = 'transparent';
        
        // Instructions
        const instructions = [
          '🕹️ LEFT/RIGHT JOYSTICK: Move around',
          '🔍 POINT with controllers: Look at stars',
          '⭐ HOVER on nodes: View star info',
          '💡 TIP: Change size with buttons'
        ];
        
        ctx.fillStyle = '#FFFFFF';
        ctx.font = '10px Arial, sans-serif';
        ctx.textAlign = 'left';
        
        let yPos = 48;
        const lineHeight = 22;
        
        instructions.forEach(instruction => {
          if (instruction === '') {
            yPos += lineHeight * 0.5;
          } else {
            ctx.fillText(instruction, 30, yPos);
            yPos += lineHeight;
          }
        });
        
        // Create THREE texture from canvas
        this.texture = new THREE.CanvasTexture(this.canvas);
        this.texture.minFilter = THREE.LinearFilter;
        this.texture.magFilter = THREE.LinearFilter;
        
        // Create material and geometry
        const material = new THREE.MeshBasicMaterial({
          map: this.texture,
          transparent: true,
          side: THREE.DoubleSide
        });
        
        const width = 0.5;
        const height = width * (this.canvas.height / this.canvas.width);
        const geometry = new THREE.PlaneGeometry(width, height);
        
        this.mesh = new THREE.Mesh(geometry, material);
        this.mesh.position.x = this.data.offsetX;
        this.mesh.position.y = this.data.offsetY;
        this.mesh.position.z = -this.data.distance;
        
        // Initially hidden
        this.mesh.visible = false;
        
        // Add to camera
        const camera = this.scene.querySelector('a-camera');
        if (camera && camera.object3D) {
          camera.object3D.add(this.mesh);
        }
        this.createCloseButton();
       
      },
      createCloseButton: function() {
  const camera = this.scene.querySelector('a-camera');
  if (!camera) return;

  // Calcular posición esquina superior derecha del panel
  const panelWidth = 0.5;
  const panelHeight = panelWidth * (this.data.height / this.data.width);

  // Posición absoluta = posición del panel + offset esquina
  const btnX = this.data.offsetX + panelWidth / 2 - 0.03;
  const btnY = this.data.offsetY + panelHeight / 2 - 0.03;
  const btnZ = -(this.data.distance - 0.01); // ligeramente por delante

  // Crear como a-entity para que el raycaster lo detecte
  this.closeBtnEl = document.createElement('a-entity');
  this.closeBtnEl.setAttribute('class', 'interactive-node'); // ← clave
  this.closeBtnEl.setAttribute('position', `${btnX} ${btnY} ${btnZ}`);
  this.closeBtnEl.setAttribute('visible', 'false');
  this.closeBtnEl.setAttribute('geometry', 'primitive: plane; width: 0.06; height: 0.06');
  this.closeBtnEl.setAttribute('material', 'color: #cc2222; opacity: 0.95; transparent: true; shader: flat');

  // Canvas para dibujar la X encima
  const xCanvas = document.createElement('canvas');
  xCanvas.width = 64;
  xCanvas.height = 64;
  const xCtx = xCanvas.getContext('2d');

  xCtx.fillStyle = '#cc2222';
  xCtx.fillRect(255, 255, 255, 0);
  xCtx.strokeStyle = '#ffffff';
  xCtx.lineWidth = 8;
  xCtx.lineCap = 'round';
  xCtx.beginPath(); xCtx.moveTo(14, 14); xCtx.lineTo(50, 50); xCtx.stroke();
  xCtx.beginPath(); xCtx.moveTo(50, 14); xCtx.lineTo(14, 50); xCtx.stroke();

  const xTexture = new THREE.CanvasTexture(xCanvas);
  
  // Aplicar textura cuando el elemento cargue
  this.closeBtnEl.addEventListener('loaded', () => {
    if (this.closeBtnEl.getObject3D('mesh')) {
      this.closeBtnEl.getObject3D('mesh').material.map = xTexture;
      this.closeBtnEl.getObject3D('mesh').material.needsUpdate = true;
    }
  });

  // Escuchar el click/trigger sobre el propio botón
  this.closeBtnEl.addEventListener('click', () => {
    console.log('❌ Panel VR cerrado');
    this.hidePanel();
  });

  // Añadir a la cámara (mismo padre que el panel)
  camera.appendChild(this.closeBtnEl);
},

hidePanel: function() {
  if (this.mesh) this.mesh.visible = false;
  if (this.closeBtnEl) this.closeBtnEl.setAttribute('visible', 'false');
  this._panelDismissed = true;
},

onEnterVR: function() {
  this.isVR = true;
  if (!this._panelDismissed) {
    if (this.mesh) this.mesh.visible = true;
    if (this.closeBtnEl) this.closeBtnEl.setAttribute('visible', 'true');
  }
},

onExitVR: function() {
  this.isVR = false;
  if (this.mesh) this.mesh.visible = false;
  if (this.closeBtnEl) this.closeBtnEl.setAttribute('visible', 'false');
  this._panelDismissed = false; // resetear para próxima sesión VR
},

  
 remove: function() {
  if (this._onTrigger) {
    const leftHand = document.querySelector('#left-hand');
    const rightHand = document.querySelector('#right-hand');
    if (leftHand) leftHand.removeEventListener('triggerdown', this._onTrigger);
    if (rightHand) rightHand.removeEventListener('triggerdown', this._onTrigger);
  }
  if (this.closeBtn) {
    if (this.closeBtn.geometry) this.closeBtn.geometry.dispose();
    if (this.closeBtn.material) this.closeBtn.material.dispose();
  }
  if (this.mesh) {
    this.mesh.parent && this.mesh.parent.remove(this.mesh);
  }
  if (this.texture) this.texture.dispose();
  this.canvas = null;
},
    });


    // ========== END VR INFO PANEL ==========

    // ========== VR COLOR MODE TOGGLE ==========
    AFRAME.registerComponent('vr-color-toggle', {
      schema: {
        width:        { type: 'number', default: 0.22 },
        height:       { type: 'number', default: 0.09 },
        distance:     { type: 'number', default: 0.9 },
        offsetX:      { type: 'number', default: 0.42 },
        offsetY:      { type: 'number', default: -0.18 },
        canvasW:      { type: 'number', default: 256 },
        canvasH:      { type: 'number', default: 96 },
        legendWidth:  { type: 'number', default: 0.26 },
        legendHeight: { type: 'number', default: 0.34 },
        legendGap:    { type: 'number', default: 0.015 },
        legendCanvasW:{ type: 'number', default: 320 },
        legendCanvasH:{ type: 'number', default: 420 }
      },

      init: function () {
        this.scene = this.el.sceneEl;
        this.mode = 'cve'; // matches default <select> value
        this.isVR = false;

        // ----- Button canvas/texture -----
        this.canvas = document.createElement('canvas');
        this.canvas.width = this.data.canvasW;
        this.canvas.height = this.data.canvasH;
        this.ctx = this.canvas.getContext('2d');
        this.texture = new THREE.CanvasTexture(this.canvas);
        this.texture.minFilter = THREE.LinearFilter;
        this.texture.magFilter = THREE.LinearFilter;

        // ----- Legend canvas/texture -----
        this.legendCanvas = document.createElement('canvas');
        this.legendCanvas.width = this.data.legendCanvasW;
        this.legendCanvas.height = this.data.legendCanvasH;
        this.legendCtx = this.legendCanvas.getContext('2d');
        this.legendTexture = new THREE.CanvasTexture(this.legendCanvas);
        this.legendTexture.minFilter = THREE.LinearFilter;
        this.legendTexture.magFilter = THREE.LinearFilter;

        // ----- Button entity -----
        this.btnEl = document.createElement('a-entity');
        this.btnEl.setAttribute('class', 'interactive-node');
        this.btnEl.setAttribute('position',
          `${this.data.offsetX} ${this.data.offsetY} ${-this.data.distance}`);
        this.btnEl.setAttribute('geometry',
          `primitive: plane; width: ${this.data.width}; height: ${this.data.height}`);
        this.btnEl.setAttribute('material',
          'shader: flat; transparent: true; opacity: 0.95; side: double');
        this.btnEl.setAttribute('visible', 'false');

        this.btnEl.addEventListener('loaded', () => {
          const mesh = this.btnEl.getObject3D('mesh');
          if (mesh) {
            mesh.material.map = this.texture;
            mesh.material.needsUpdate = true;
          }
          this.redraw();
        });

        this.btnEl.addEventListener('click', () => this.toggleMode());

        this.el.appendChild(this.btnEl);

        // ----- Legend entity (below the button) -----
        const legendY = this.data.offsetY
                      - this.data.height / 2
                      - this.data.legendGap
                      - this.data.legendHeight / 2;

        this.legendEl = document.createElement('a-entity');
        this.legendEl.setAttribute('position',
          `${this.data.offsetX} ${legendY} ${-this.data.distance}`);
        this.legendEl.setAttribute('geometry',
          `primitive: plane; width: ${this.data.legendWidth}; height: ${this.data.legendHeight}`);
        this.legendEl.setAttribute('material',
          'shader: flat; transparent: true; opacity: 0.95; side: double');
        this.legendEl.setAttribute('visible', 'false');

        this.legendEl.addEventListener('loaded', () => {
          const mesh = this.legendEl.getObject3D('mesh');
          if (mesh) {
            mesh.material.map = this.legendTexture;
            mesh.material.needsUpdate = true;
          }
          this.redrawLegend();
        });

        this.el.appendChild(this.legendEl);

        this.scene.addEventListener('enter-vr', () => this.onEnterVR());
        this.scene.addEventListener('exit-vr',  () => this.onExitVR());

        // Sync if user changes mode via desktop UI
        const select = document.getElementById('color-mode');
        if (select) {
          select.addEventListener('change', () => {
            if (select.value === 'cve' || select.value === 'license') {
              this.mode = select.value;
              this.redraw();
              this.redrawLegend();
            }
          });
        }
      },

      legendData: {
        cve: {
          title: '🎯 CVE Severity',
          accent: '#27EEF5',
          items: [
            { color: '#1a3a5c', label: 'No CVE / no vulns' },
            { color: '#00ff00', label: 'Low (0-3)' },
            { color: '#ffff00', label: 'Medium (4-6)' },
            { color: '#ff0000', label: 'High (7-10)' }
          ]
        },
        license: {
          title: '📜 License',
          accent: '#ff8800',
          items: [
            { color: '#ff00ff', label: 'AGPL (strong copyleft)' },
            { color: '#ff2222', label: 'GPL (restrictive)' },
            { color: '#ff8800', label: 'LGPL' },
            { color: '#ffcc00', label: 'MPL' },
            { color: '#00aaff', label: 'Apache' },
            { color: '#00ddaa', label: 'BSD' },
            { color: '#33cc66', label: 'MIT / ISC (permissive)' },
            { color: '#666666', label: 'Unknown' }
          ]
        }
      },

      redraw: function () {
        const ctx = this.ctx;
        const w = this.canvas.width;
        const h = this.canvas.height;
        ctx.clearRect(0, 0, w, h);

        const isLicense = (this.mode === 'license');
        ctx.fillStyle = 'rgba(10, 26, 42, 0.95)';
        ctx.fillRect(0, 0, w, h);

        ctx.strokeStyle = isLicense ? '#ff8800' : '#27EEF5';
        ctx.lineWidth = 4;
        ctx.strokeRect(2, 2, w - 4, h - 4);

        ctx.fillStyle = '#ffffff';
        ctx.font = 'bold 18px Arial, sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('🎨 Color mode', w / 2, h / 2 - 16);

        ctx.fillStyle = isLicense ? '#ffcc66' : '#27EEF5';
        ctx.font = 'bold 22px Arial, sans-serif';
        ctx.fillText(isLicense ? 'License' : 'CVE severity', w / 2, h / 2 + 18);

        if (this.texture) this.texture.needsUpdate = true;
      },

      redrawLegend: function () {
        const ctx = this.legendCtx;
        const w = this.legendCanvas.width;
        const h = this.legendCanvas.height;
        ctx.clearRect(0, 0, w, h);

        const data = this.legendData[this.mode] || this.legendData.cve;

        // Background
        ctx.fillStyle = 'rgba(10, 26, 42, 0.95)';
        ctx.fillRect(0, 0, w, h);

        // Border
        ctx.strokeStyle = data.accent;
        ctx.lineWidth = 4;
        ctx.strokeRect(2, 2, w - 4, h - 4);

        // Title
        ctx.fillStyle = data.accent;
        ctx.font = 'bold 22px Arial, sans-serif';
        ctx.textAlign = 'left';
        ctx.textBaseline = 'middle';
        ctx.fillText(data.title, 18, 32);

        // Items
        const items = data.items;
        const startY = 70;
        const availableH = h - startY - 18;
        const rowH = Math.min(38, availableH / items.length);
        const dotR = Math.min(11, rowH / 2 - 4);
        const textSize = Math.max(13, Math.min(18, Math.floor(rowH * 0.55)));

        ctx.font = `${textSize}px Arial, sans-serif`;
        ctx.textBaseline = 'middle';

        items.forEach((it, i) => {
          const cy = startY + rowH * (i + 0.5);

          // Glow
          ctx.beginPath();
          ctx.arc(28, cy, dotR + 3, 0, Math.PI * 2);
          ctx.fillStyle = it.color + '55';
          ctx.fill();

          // Dot
          ctx.beginPath();
          ctx.arc(28, cy, dotR, 0, Math.PI * 2);
          ctx.fillStyle = it.color;
          ctx.fill();
          ctx.strokeStyle = 'rgba(255,255,255,0.25)';
          ctx.lineWidth = 1;
          ctx.stroke();

          // Label
          ctx.fillStyle = '#ffffff';
          ctx.fillText(it.label, 52, cy);
        });

        if (this.legendTexture) this.legendTexture.needsUpdate = true;
      },

      toggleMode: function () {
        this.mode = (this.mode === 'cve') ? 'license' : 'cve';
        if (typeof setColorMode === 'function') {
          setColorMode(this.mode);
        } else if (visualizerComponent && visualizerComponent.setColorMode) {
          visualizerComponent.setColorMode(this.mode);
        }
        const select = document.getElementById('color-mode');
        if (select && select.value !== this.mode) select.value = this.mode;
        this.redraw();
        this.redrawLegend();
        console.log('🎨 VR color toggle ->', this.mode);
      },

      onEnterVR: function () {
        this.isVR = true;
        if (this.btnEl) this.btnEl.setAttribute('visible', 'true');
        if (this.legendEl) this.legendEl.setAttribute('visible', 'true');
      },

      onExitVR: function () {
        this.isVR = false;
        if (this.btnEl) this.btnEl.setAttribute('visible', 'false');
        if (this.legendEl) this.legendEl.setAttribute('visible', 'false');
      },

      remove: function () {
        if (this.btnEl && this.btnEl.parentNode) {
          this.btnEl.parentNode.removeChild(this.btnEl);
        }
        if (this.legendEl && this.legendEl.parentNode) {
          this.legendEl.parentNode.removeChild(this.legendEl);
        }
        if (this.texture) this.texture.dispose();
        if (this.legendTexture) this.legendTexture.dispose();
        this.canvas = null;
        this.legendCanvas = null;
      }
    });
    // ========== END VR COLOR MODE TOGGLE ==========
    
    // Duplicate functions removed: use the single canonical implementations above.
    // The synchronization with component values is handled by `syncComponentValuesToUI`.

  </script>
</body>
</html>