aframe-babia-components
Version:
A data visualization set of components for A-Frame.
1,492 lines (1,266 loc) • 61.1 kB
HTML
<!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 },
off