aframe-babia-components
Version:
A data visualization set of components for A-Frame.
1,627 lines (1,356 loc) • 93.5 kB
JavaScript
// babia-dome.js - Componente Base Universal VR
// Compatible con cualquier caso de uso: planetario, sensores, redes, etc.
AFRAME.registerComponent('babia-dome', {
schema: {
// Datos
dataUrl: { type: 'string', default: 'data.json' },
// Geometría
sphereRadius: { type: 'number', default: 10 },
nodeSize: { type: 'number', default: 0.15 },
// Visual
showLabels: { type: 'boolean', default: false },
animateNodes: { type: 'boolean', default: true },
colorByValue: { type: 'boolean', default: false },
nodeOpacity: { type: 'number', default: 1.0 },
// Mapeo de métricas
colorByCVE: { type: 'boolean', default: false },
colorByLicense: { type: 'boolean', default: false },
licenseFilter: { type: 'string', default: '' },
sizeByPackageSize: { type: 'boolean', default: false },
linkColorByType: { type: 'boolean', default: false },
// Físicas
enablePhysics: { type: 'boolean', default: true },
physicsStrength: { type: 'number', default: 0.5 },
collisionRadius: { type: 'number', default: 0.25 },
// Links - NUEVOS PARÁMETROS PARA GROSOR
showLinks: { type: 'boolean', default: true },
linkSegments: { type: 'number', default: 30 },
linkOpacity: { type: 'number', default: 0.5 },
linkWidth: { type: 'number', default: 0.08 }, // Grosor del enlace (radio del cilindro)
linkStyle: { type: 'string', default: 'line' }, // 'line' o 'cylinder'
// Tooltip - Retraso en VR
tooltipDelayVR: { type: 'number', default: 1000 }, // Tiempo en ms antes de mostrar tooltip en VR
// Escalas
minNodeSize: { type: 'number', default: 0.1 },
maxNodeSize: { type: 'number', default: 0.4 },
},
init: function() {
console.log('🚀 Inicializando Visualizador Universal v2.1 - Enlaces Gruesos');
this.nodes = [];
this.nodeElements = [];
this.linkElements = [];
this.simulation = null;
this.simulationNodes = [];
this.simulationLinks = [];
this.constellations = [];
// Estado del sistema
this.state = {
hoveredNode: null,
tooltipEl: null,
tooltipBg: null,
errorTooltipEl: null,
errorTooltipBg: null,
isVRMode: false,
physicsRunning: this.data.enablePhysics,
tooltipTimer: null
};
this.setupTooltip();
this.setupErrorTooltip();
this.setupRaycasterEvents();
this.detectVRMode();
this.loadData();
},
detectVRMode: function() {
const scene = this.el.sceneEl;
const self = this;
scene.addEventListener('enter-vr', function() {
console.log('🥽 VR Mode Activated');
self.state.isVRMode = true;
self.repositionTooltipForVR();
});
scene.addEventListener('exit-vr', function() {
console.log('🖥️ Desktop Mode');
self.state.isVRMode = false;
self.repositionTooltipForDesktop();
});
},
setupTooltip: function() {
const self = this;
const scene = document.querySelector('a-scene');
const createTooltip = function() {
const cameraEl = document.querySelector('a-entity[camera], a-camera');
if (!cameraEl) {
setTimeout(createTooltip, 100);
return;
}
// Fondo del tooltip
const bg = document.createElement('a-plane');
bg.setAttribute('id', 'tooltip-background');
bg.setAttribute('width', '2');
bg.setAttribute('height', '0.5');
bg.setAttribute('color', '#000000');
bg.setAttribute('opacity', '0.9');
bg.setAttribute('position', '0 -0.3 -1.5');
bg.setAttribute('visible', 'false');
// Texto del tooltip
const tooltipEl = document.createElement('a-text');
tooltipEl.setAttribute('id', 'node-tooltip');
tooltipEl.setAttribute('position', '0 -0.3 -1.49');
tooltipEl.setAttribute('width', '1.8');
tooltipEl.setAttribute('align', 'center');
tooltipEl.setAttribute('color', '#4ECDC4');
tooltipEl.setAttribute('value', '');
tooltipEl.setAttribute('wrap-count', '40');
tooltipEl.setAttribute('visible', 'false');
cameraEl.appendChild(bg);
cameraEl.appendChild(tooltipEl);
self.state.tooltipEl = tooltipEl;
self.state.tooltipBg = bg;
console.log('✅ Tooltip creado');
};
if (scene.hasLoaded) {
createTooltip();
} else {
scene.addEventListener('loaded', createTooltip);
}
},
repositionTooltipForVR: function() {
if (this.state.tooltipEl && this.state.tooltipBg) {
this.state.tooltipEl.setAttribute('position', '0 -0.4 -1');
this.state.tooltipBg.setAttribute('position', '0 -0.4 -1.01');
this.state.tooltipEl.setAttribute('width', '1.5');
this.state.tooltipBg.setAttribute('width', '1.7');
}
},
repositionTooltipForDesktop: function() {
if (this.state.tooltipEl && this.state.tooltipBg) {
this.state.tooltipEl.setAttribute('position', '0 -0.3 -1.5');
this.state.tooltipBg.setAttribute('position', '0 -0.3 -1.51');
this.state.tooltipEl.setAttribute('width', '1.8');
this.state.tooltipBg.setAttribute('width', '2');
}
},
setupErrorTooltip: function() {
const self = this;
const scene = document.querySelector('a-scene');
const createErrorTooltip = function() {
const cameraEl = document.querySelector('a-entity[camera], a-camera');
if (!cameraEl) {
setTimeout(createErrorTooltip, 100);
return;
}
// Fondo del tooltip de error
const bg = document.createElement('a-plane');
bg.setAttribute('id', 'error-tooltip-background');
bg.setAttribute('width', '1.4');
bg.setAttribute('height', '0.6');
bg.setAttribute('color', '#C85A54');
bg.setAttribute('opacity', '0.9');
bg.setAttribute('position', '-1.2 -0.8 -1.5');
bg.setAttribute('visible', 'false');
bg.setAttribute('material', 'shader: flat');
// Borde rojo del tooltip de error
const border = document.createElement('a-plane');
border.setAttribute('id', 'error-tooltip-border');
border.setAttribute('width', '1.45');
border.setAttribute('height', '0.65');
border.setAttribute('color', '#E08B7E');
border.setAttribute('opacity', '0.6');
border.setAttribute('position', '-1.2 -0.8 -1.51');
border.setAttribute('visible', 'false');
border.setAttribute('material', 'shader: flat');
// Texto del tooltip de error
const errorEl = document.createElement('a-text');
errorEl.setAttribute('id', 'error-tooltip');
errorEl.setAttribute('position', '-1.2 -0.75 -1.49');
errorEl.setAttribute('width', '1.2');
errorEl.setAttribute('align', 'center');
errorEl.setAttribute('color', '#FFFFFF');
errorEl.setAttribute('value', '');
errorEl.setAttribute('wrap-count', '15');
errorEl.setAttribute('visible', 'false');
errorEl.setAttribute('font', 'https://cdn.aframe.io/fonts/Roboto-msdf.json');
cameraEl.appendChild(border);
cameraEl.appendChild(bg);
cameraEl.appendChild(errorEl);
self.state.errorTooltipEl = errorEl;
self.state.errorTooltipBg = bg;
console.log('✅ Error tooltip creado');
};
if (scene.hasLoaded) {
createErrorTooltip();
} else {
scene.addEventListener('loaded', createErrorTooltip);
}
},
showErrorScene: function(error) {
const errorMessage = error.message || 'Error desconocido al cargar datos';
if (this.state.errorTooltipEl && this.state.errorTooltipBg) {
const fullMessage = `❌ Error\n${errorMessage}`;
this.state.errorTooltipEl.setAttribute('value', fullMessage);
this.state.errorTooltipEl.setAttribute('visible', 'true');
this.state.errorTooltipBg.setAttribute('visible', 'true');
// Animación de parpadeo para el tooltip de error
this.state.errorTooltipBg.setAttribute('animation', {
property: 'opacity',
from: 0.8,
to: 0.9,
dur: 1000,
dir: 'alternate',
loop: true,
easing: 'easeInOutQuad'
});
}
console.error('⚠️ Escena vacía - No hay datos válidos para mostrar');
},
setupRaycasterEvents: function() {
const self = this;
this.el.addEventListener('raycaster-intersected', function(evt) {
self.onRaycasterIntersected(evt);
});
this.el.addEventListener('raycaster-intersected-cleared', function(evt) {
self.onRaycasterCleared(evt);
});
this.el.addEventListener('mouseenter', function(evt) {
self.onMouseEnter(evt);
});
this.el.addEventListener('mouseleave', function(evt) {
self.onMouseLeave(evt);
});
},
onRaycasterIntersected: function(evt) {
const intersection = evt.detail && evt.detail.intersection;
if (!intersection) return;
let obj = intersection.object;
let depth = 0;
while (obj && !obj.userData.nodeData && depth < 10) {
obj = obj.parent;
depth++;
}
if (obj && obj.userData.nodeData) {
const nodeData = obj.userData.nodeData;
this.state.hoveredNode = nodeData;
if (obj.el) {
const sphere = obj.el.querySelector('a-sphere');
if (sphere) {
sphere.setAttribute('scale', '1.8 1.8 1.8');
}
}
// En VR, usar retraso; en desktop, mostrar inmediatamente
if (this.state.isVRMode) {
// Cancelar timer anterior si existe
if (this.state.tooltipTimer) {
clearTimeout(this.state.tooltipTimer);
}
// Iniciar nuevo timer
this.state.tooltipTimer = setTimeout(() => {
this.showTooltip(nodeData);
}, this.data.tooltipDelayVR);
} else {
// Modo desktop: mostrar inmediatamente
this.showTooltip(nodeData);
}
}
},
onRaycasterCleared: function(evt) {
if (this.state.hoveredNode) {
// Cancelar timer si existe
if (this.state.tooltipTimer) {
clearTimeout(this.state.tooltipTimer);
this.state.tooltipTimer = null;
}
this.hideTooltip();
const nodeEl = this.nodeElements.find(el =>
el.nodeData && el.nodeData.id === this.state.hoveredNode.id
);
if (nodeEl) {
const sphere = nodeEl.querySelector('a-sphere');
if (sphere) {
sphere.setAttribute('scale', '1 1 1');
}
}
this.state.hoveredNode = null;
}
},
onMouseEnter: function(evt) {
this.onRaycasterIntersected(evt);
},
onMouseLeave: function(evt) {
this.onRaycasterCleared(evt);
},
showTooltip: function(nodeData) {
if (this.state.tooltipEl && this.state.tooltipBg) {
let text = `${nodeData.label}`;
if (nodeData.type) {
text += `\nTipo: ${nodeData.type}`;
}
if (nodeData.license) {
text += `\n\u00A9 License: ${nodeData.license}`;
}
this.state.tooltipEl.setAttribute('value', text);
this.state.tooltipEl.setAttribute('visible', 'true');
this.state.tooltipBg.setAttribute('visible', 'true');
}
},
hideTooltip: function() {
if (this.state.tooltipEl && this.state.tooltipBg) {
this.state.tooltipEl.setAttribute('value', '');
this.state.tooltipEl.setAttribute('visible', 'false');
this.state.tooltipBg.setAttribute('visible', 'false');
}
},
loadData: async function() {
console.log('📂 Cargando datos desde:', this.data.dataUrl);
try {
const response = await fetch(this.data.dataUrl);
const data = await response.json();
if (!data.nodes || !Array.isArray(data.nodes)) {
throw new Error('Formato inválido: falta array "nodes"');
}
const requiredFields = ['id', 'lat', 'lon', 'label', 'color'];
// Filtrar nodos incompletos
const validNodes = [];
const invalidNodes = [];
data.nodes.forEach((node, index) => {
const missingFields = requiredFields.filter(field => !(field in node));
if (missingFields.length > 0) {
invalidNodes.push({
id: node.id || `nodo_${index}`,
missingFields: missingFields
});
} else {
validNodes.push(node);
}
});
// Avisos en consola sobre nodos descartados
if (invalidNodes.length > 0) {
console.warn(`⚠️ ${invalidNodes.length} nodo(s) descartado(s) por campos faltantes:`);
invalidNodes.forEach(invalid => {
console.warn(` - Nodo "${invalid.id}": faltan campos [${invalid.missingFields.join(', ')}]`);
});
}
if (validNodes.length === 0) {
throw new Error('No hay nodos válidos en el archivo JSON');
}
console.log(`✅ ${validNodes.length} nodo(s) válido(s) cargado(s)`);
// Filtrar enlaces que referencien nodos inexistentes
const validNodeIds = new Set(validNodes.map(n => n.id));
const linksData = data.links || [];
const validLinks = [];
const invalidLinks = [];
linksData.forEach((link, index) => {
const sourceExists = validNodeIds.has(link.source);
const targetExists = validNodeIds.has(link.target);
if (!sourceExists || !targetExists) {
invalidLinks.push({
index: index,
source: link.source,
target: link.target,
reason: !sourceExists && !targetExists ? 'source y target inexistentes' :
!sourceExists ? 'source inexistente' : 'target inexistente'
});
} else {
validLinks.push(link);
}
});
// Avisos en consola sobre enlaces descartados
if (invalidLinks.length > 0) {
console.warn(`⚠️ ${invalidLinks.length} enlace(s) descartado(s) por referencia(s) inválida(s):`);
invalidLinks.forEach(invalid => {
console.warn(` - Enlace ${invalid.index}: ${invalid.source} → ${invalid.target} (${invalid.reason})`);
});
}
if (validLinks.length > 0) {
console.log(`✅ ${validLinks.length} enlace(s) válido(s) cargado(s)`);
} else {
console.log(`ℹ️ Sin enlaces en los datos`);
}
this.createVisualization(validNodes, validLinks, data.constellations || []);
} catch (error) {
console.error('❌ Error cargando datos:', error);
this.showErrorScene(error);
}
},
createVisualization: function(nodesData, linksData, constellationsData) {
console.log('🎨 Creando visualización...');
const container = this.el;
this.simulationNodes = nodesData.map((nodeData, index) => {
const targetPos = this.latLonToVector3(
nodeData.lat,
nodeData.lon,
this.data.sphereRadius
);
const length = Math.sqrt(targetPos.x ** 2 + targetPos.y ** 2 + targetPos.z ** 2);
const factor = this.data.sphereRadius / length;
return {
...nodeData,
targetX: targetPos.x,
targetY: targetPos.y,
targetZ: targetPos.z,
x: targetPos.x * factor,
y: targetPos.y * factor,
z: targetPos.z * factor,
vx: 0,
vy: 0,
vz: 0,
index: index
};
});
this.simulationLinks = linksData.map(link => ({
...link,
source: link.source,
target: link.target
}));
this.constellations = constellationsData;
if (this.data.enablePhysics && typeof d3 !== 'undefined' && d3.forceSimulation) {
console.log('⚙️ Inicializando simulación física');
this.setupPhysicsSimulation();
} else {
console.log('📍 Modo estático (sin físicas)');
}
console.log('🔵 Creando', this.simulationNodes.length, 'nodos...');
this.simulationNodes.forEach((nodeData, index) => {
const nodeEl = this.createNodeElement(nodeData, container);
this.nodeElements.push(nodeEl);
container.appendChild(nodeEl);
});
if (this.data.showLinks && this.simulationLinks.length > 0) {
console.log('🔗 Creando', this.simulationLinks.length, 'enlaces...');
this.createLinkElements(container);
}
this.createReferenceSphere();
this.createCoordinateGrid();
if (this.simulation) {
this.startPhysicsUpdate();
}
console.log('✅ Visualización completa:', this.simulationNodes.length, 'nodos', this.simulationLinks.length, 'enlaces');
},
setupPhysicsSimulation: function() {
this.simulation = d3.forceSimulation(this.simulationNodes, 3)
.numDimensions(3)
.force("x", d3.forceX(d => d.targetX).strength(this.data.physicsStrength))
.force("y", d3.forceY(d => d.targetY).strength(this.data.physicsStrength))
.force("z", d3.forceZ(d => d.targetZ).strength(this.data.physicsStrength))
.force("collide", d3.forceCollide(this.data.collisionRadius).strength(1).iterations(2))
.force("radial", d3.forceRadial(this.data.sphereRadius).strength(0.15))
.force("charge", d3.forceManyBody().strength(-1).distanceMax(1.5))
.velocityDecay(0.9)
.alphaDecay(0.005)
.alphaMin(0.001)
.alpha(0.2);
if (this.simulationLinks.length > 0) {
this.simulation.force("link", d3.forceLink(this.simulationLinks)
.id(d => d.id)
.distance(1.2)
.strength(0.4)
.iterations(2)
);
}
this.simulation.force("spherical", () => {
this.simulationNodes.forEach(node => {
const dx = node.x;
const dy = node.y;
const dz = node.z;
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (Math.abs(distance - this.data.sphereRadius) > 0.5) {
const factor = this.data.sphereRadius / distance;
node.x *= factor;
node.y *= factor;
node.z *= factor;
}
});
});
console.log('⚡ Simulación física inicializada');
},
startPhysicsUpdate: function() {
const self = this;
let lastTime = Date.now();
let tickCount = 0;
this.simulation.on('tick', () => {
const currentTime = Date.now();
const deltaTime = currentTime - lastTime;
if (deltaTime < 16) return;
lastTime = currentTime;
tickCount++;
self.simulationNodes.forEach((nodeData, i) => {
const nodeEl = self.nodeElements[i];
if (nodeEl && nodeEl.parentNode) {
if (tickCount % 5 === 0) {
const distance = Math.sqrt(
nodeData.x ** 2 +
nodeData.y ** 2 +
nodeData.z ** 2
);
const factor = self.data.sphereRadius / distance;
nodeData.x *= factor;
nodeData.y *= factor;
nodeData.z *= factor;
}
nodeEl.setAttribute('position', {
x: nodeData.x,
y: nodeData.y,
z: nodeData.z
});
const line = nodeEl.lineElement;
if (line && line.parentNode) {
line.setAttribute('line', {
start: '0 0 0',
end: `${nodeData.x} ${nodeData.y} ${nodeData.z}`,
color: line.getAttribute('line').color,
opacity: 0.0
});
line.setAttribute('visible', 'false');
}
}
});
if (self.data.showLinks) {
self.updateLinkArcs();
}
});
this.simulation.on('end', () => {
console.log('✅ Simulación estabilizada');
});
},
createNodeElement: function(nodeData, container) {
const node = document.createElement('a-entity');
const sphere = document.createElement('a-sphere');
// Calcular tamaño basado en npm_size_bytes si está activado
let nodeRadius = this.data.nodeSize;
if (this.data.sizeByPackageSize && nodeData.npm_size_bytes) {
const minSize = this.data.minNodeSize;
const maxSize = this.data.maxNodeSize;
// Normalizar tamaño de paquete (en bytes) a escala logarítmica
const minBytes = 2000;
const maxBytes = 400000;
const normalized = Math.log(Math.max(minBytes, nodeData.npm_size_bytes)) / Math.log(maxBytes);
nodeRadius = minSize + (maxSize - minSize) * Math.min(1, normalized);
}
sphere.setAttribute('radius', nodeRadius);
sphere.setAttribute('class', 'interactive-node');
// Calcular color primero (antes de configurar el material)
let color;
if (this.data.colorByLicense) {
color = this.licenseToColor(nodeData.license);
} else if (this.data.colorByCVE) {
if (nodeData.cvss_score !== null && nodeData.cvss_score !== undefined) {
color = this.cvssScoreToColor(nodeData.cvss_score);
} else {
// Sin CVE/sin vulnerabilidades - usar azul oscuro uniforme
color = '#1a3a5c';
}
} else if (this.data.colorByValue && nodeData.value !== undefined) {
color = this.valueToColor(nodeData.value);
} else {
color = this.toColor(nodeData.color);
}
// Filtro por licencia: atenuar los que no encajan
const matchesLicense = this.licenseMatchesFilter(nodeData.license);
if (!matchesLicense) {
color = '#222222';
}
const materialConfig = {
shader: 'standard',
color: color,
emissive: color,
emissiveIntensity: matchesLicense ? 0.6 : 0.05,
roughness: 0.4,
opacity: matchesLicense ? this.data.nodeOpacity : 0.15,
transparent: matchesLicense ? (this.data.nodeOpacity < 1) : true
};
// Aplicar textura si el nodo tiene campo 'texture'
if (nodeData.texture) {
const textureId = `#texture-${nodeData.texture}`;
const textureElement = document.querySelector(textureId);
if (textureElement) {
materialConfig.src = textureId;
// IMPORTANTE: Usar blanco para no alterar la textura
materialConfig.color = '#ffffff';
materialConfig.emissive = '#ffffff';
// Opcional: reducir emissive para que se vea mejor la textura
materialConfig.emissiveIntensity = 0.2;
console.log(`✅ Textura aplicada: ${textureId}`);
} else {
// Si no hay textura, usar el color calculado
materialConfig.color = color;
materialConfig.emissive = color;
console.warn(`⚠️ Textura no encontrada: ${textureId} para nodo ${nodeData.id}`);
}
}
sphere.setAttribute('material', materialConfig);
sphere.setAttribute('opacity', this.data.nodeOpacity);
if (this.data.animateNodes) {
const delay = nodeData.index * 20;
sphere.setAttribute('animation', {
property: 'scale',
from: '1 1 1',
to: '1.3 1.3 1.3',
dur: 3000,
delay: delay,
dir: 'alternate',
loop: true,
easing: 'easeInOutQuad'
});
}
node.appendChild(sphere);
node.sphereElement = sphere;
sphere.addEventListener('loaded', () => {
if (sphere.object3D) {
sphere.object3D.userData.nodeData = nodeData;
sphere.object3D.el = node;
}
});
const line = document.createElement('a-entity');
line.setAttribute('line', {
start: '0 0 0',
end: `${nodeData.x} ${nodeData.y} ${nodeData.z}`,
color: color,
opacity: 0
});
line.setAttribute('visible', 'false');
container.appendChild(line);
node.lineElement = line;
if (this.data.showLabels) {
const label = document.createElement('a-text');
label.setAttribute('value', `${nodeData.label}`);
label.setAttribute('align', 'center');
label.setAttribute('width', 3);
label.setAttribute('color', '#ffffff');
label.setAttribute('position', `0 ${this.data.nodeSize + 0.2} 0`);
label.setAttribute('look-at', '[camera]');
node.appendChild(label);
node.labelElement = label;
}
node.setAttribute('position', {
x: nodeData.x,
y: nodeData.y,
z: nodeData.z
});
node.setAttribute('class', 'node-container');
node.nodeData = nodeData;
return node;
},
createLinkElements: function(container) {
this.simulationLinks.forEach((link, index) => {
const linkObject = new THREE.Group();
linkObject.linkData = link;
container.object3D.add(linkObject);
this.linkElements.push(linkObject);
});
console.log(`🔗 ${this.linkElements.length} enlaces creados`);
},
// MÉTODO ACTUALIZADO: Crear enlaces cilíndricos gruesos
updateLinkArcs: function() {
const LINK_STYLES = {
constellation: { color: 0xffffff, opacity: 0.9, width: this.data.linkWidth * 1.2 },
proximity: { color: 0x27EEF5, opacity: 0.8, width: this.data.linkWidth },
quimical: { color: 0xFF0D0D, opacity: 0.7, width: this.data.linkWidth }
};
// Estilos para dependency type (direct/transitive)
const DEPENDENCY_STYLES = {
direct: { color: 0x3050F8, opacity: 0.9, width: this.data.linkWidth * 1.2 }, // Azul
transitive: { color: 0x808080, opacity: 0.5, width: this.data.linkWidth } // Gris
};
const DEFAULT_LINK_STYLE = {
color: 0xffffff,
opacity: 0.6,
width: this.data.linkWidth
};
this.simulationLinks.forEach((link, i) => {
const linkObject = this.linkElements[i];
if (!linkObject) return;
const sourceNode = link.source;
const targetNode = link.target;
if (!sourceNode || !targetNode) return;
// Limpiar geometría anterior
while (linkObject.children.length > 0) {
const child = linkObject.children[0];
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
linkObject.remove(child);
}
// Elegir estilo basado en dependencyType si está activado
let style;
if (this.data.linkColorByType && DEPENDENCY_STYLES[link.type]) {
style = DEPENDENCY_STYLES[link.type];
} else {
style = LINK_STYLES[link.type] || DEFAULT_LINK_STYLE;
}
// Elegir entre cilindro o línea
if (this.data.linkStyle === 'cylinder') {
this.createCylindricalBond(linkObject, sourceNode, targetNode, style);
} else {
this.createLineBond(linkObject, sourceNode, targetNode, style);
}
});
},
// NUEVO MÉTODO: Crear enlace cilíndrico (estilo molecular)
createCylindricalBond: function(linkObject, sourceNode, targetNode, style) {
const start = new THREE.Vector3(sourceNode.x, sourceNode.y, sourceNode.z);
const end = new THREE.Vector3(targetNode.x, targetNode.y, targetNode.z);
const direction = new THREE.Vector3().subVectors(end, start);
const length = direction.length();
const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5);
// Crear geometría de cilindro
const geometry = new THREE.CylinderGeometry(
style.width, // radio superior
style.width, // radio inferior
length, // altura
8, // segmentos radiales
1 // segmentos de altura
);
// Material con color y opacidad
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(style.color),
opacity: style.opacity * this.data.linkOpacity,
transparent: true,
metalness: 0.3,
roughness: 0.6
});
const cylinder = new THREE.Mesh(geometry, material);
// Posicionar en el punto medio
cylinder.position.copy(midpoint);
// Rotar para que apunte del origen al destino
const axis = new THREE.Vector3(0, 1, 0);
const quaternion = new THREE.Quaternion().setFromUnitVectors(
axis,
direction.clone().normalize()
);
cylinder.setRotationFromQuaternion(quaternion);
linkObject.add(cylinder);
},
// NUEVO MÉTODO: Crear enlace de línea (estilo original mejorado)
createLineBond: function(linkObject, sourceNode, targetNode, style) {
const arcPoints = this.calculateGeodesicArc(
sourceNode,
targetNode,
this.data.sphereRadius,
this.data.linkSegments
);
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(arcPoints.length * 3);
arcPoints.forEach((p, idx) => {
positions[idx * 3] = p.x;
positions[idx * 3 + 1] = p.y;
positions[idx * 3 + 2] = p.z;
});
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(style.color),
opacity: style.opacity * this.data.linkOpacity,
transparent: true,
linewidth: style.width * 10 // LineBasicMaterial tiene limitaciones de grosor
});
const line = new THREE.Line(geometry, material);
linkObject.add(line);
},
calculateGeodesicArc: function(pos1, pos2, radius, segments) {
const points = [];
const v1 = new THREE.Vector3(pos1.x, pos1.y, pos1.z);
const v2 = new THREE.Vector3(pos2.x, pos2.y, pos2.z);
const v1Norm = v1.clone().normalize();
const v2Norm = v2.clone().normalize();
const omega = Math.acos(Math.max(-1, Math.min(1, v1Norm.dot(v2Norm))));
const sinOmega = Math.sin(omega);
if (Math.abs(sinOmega) < 0.001) {
for (let i = 0; i <= segments; i++) {
const t = i / segments;
points.push({
x: v1.x * (1 - t) + v2.x * t,
y: v1.y * (1 - t) + v2.y * t,
z: v1.z * (1 - t) + v2.z * t
});
}
return points;
}
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const a = Math.sin((1 - t) * omega) / sinOmega;
const b = Math.sin(t * omega) / sinOmega;
const x = a * v1.x + b * v2.x;
const y = a * v1.y + b * v2.y;
const z = a * v1.z + b * v2.z;
const length = Math.sqrt(x * x + y * y + z * z);
points.push({
x: (x / length) * radius,
y: (y / length) * radius,
z: (z / length) * radius
});
}
return points;
},
latLonToVector3: function(lat, lon, radius) {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lon + 180) * (Math.PI / 180);
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
return { x, y, z };
},
licenseToColor: function(license) {
if (!license) return '#666666';
const l = String(license).toUpperCase();
if (l.includes('AGPL')) return '#ff00ff'; // copyleft fuerte
if (l.includes('LGPL')) return '#ff8800';
if (l.includes('GPL')) return '#ff2222'; // GPL — restrictiva
if (l.includes('MPL')) return '#ffcc00';
if (l.includes('APACHE')) return '#00aaff';
if (l.includes('BSD')) return '#00ddaa';
if (l.includes('MIT') || l.includes('ISC')) return '#33cc66';
if (l.includes('CC0') || l.includes('UNLICENSE')) return '#222222';
return '#888888';
},
licenseMatchesFilter: function(license) {
const f = (this.data.licenseFilter || '').trim();
if (!f) return true;
if (!license) return false;
const lic = String(license).toUpperCase();
return f.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
.some(tok => lic.includes(tok));
},
cvssScoreToColor: function(score) {
const normalized = score / 10;
if (normalized < 0.5) {
const g = 255;
const r = Math.floor(normalized * 2 * 255);
return `#${r.toString(16).padStart(2, '0')}ff00`;
} else if (normalized < 0.7) {
const r = 255;
const g = Math.floor((1 - (normalized - 0.5) / 0.2) * 255);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}00`;
} else {
return '#ff0000';
}
},
valueToColor: function(value) {
const normalized = value / 100;
if (normalized < 0.33) {
return `#${Math.floor(normalized * 3 * 255).toString(16).padStart(2, '0')}ff00`;
} else if (normalized < 0.66) {
return `#ffff${Math.floor((1 - (normalized - 0.33) * 3) * 255).toString(16).padStart(2, '0')}`;
} else {
return `#ff${Math.floor((1 - (normalized - 0.66) * 3) * 255).toString(16).padStart(2, '0')}00`;
}
},
toColor: function(color) {
if (!color) return '#FFFFFF';
if (color.startsWith('#')) {
return color;
}
const colors = {
'red': '#FF0D0D',
'blue': '#3050F8',
'gray': '#afaeae',
'grey': '#afaeae',
'black': '#000000',
'green': '#46aa03',
'yellow': '#f4d35e',
'purple': '#9b59b6',
'pink': '#ff6f91',
'cyan': '#4ECDC4',
'teal': '#008080',
'brown': '#8b4513',
'white': '#ffffff',
'gold': '#ffd700',
'silver': '#c0c0c0',
'bronze': '#cd7f32',
'lime': '#b5eb22',
'orange': '#f08a5d'
};
return colors[color.toLowerCase()] || '#FFFFFF';
},
createReferenceSphere: function() {
const sphere = document.createElement('a-sphere');
sphere.setAttribute('radius', this.data.sphereRadius);
sphere.setAttribute('color', '#1a1a2e');
sphere.setAttribute('opacity', 0.1);
sphere.setAttribute('side', 'double');
sphere.setAttribute('wireframe', true);
sphere.setAttribute('wireframe-linewidth', 1);
this.el.appendChild(sphere);
},
createCoordinateGrid: function() {
const radius = this.data.sphereRadius;
// Líneas de latitud
for (let lat = -60; lat <= 60; lat += 30) {
const points = [];
for (let lon = -180; lon <= 180; lon += 10) {
const pos = this.latLonToVector3(lat, lon, radius);
points.push(`${pos.x} ${pos.y} ${pos.z}`);
}
for (let i = 0; i < points.length - 1; i++) {
const segment = document.createElement('a-entity');
segment.setAttribute('line', {
start: points[i],
end: points[i + 1],
color: '#ffffff',
opacity: 0.15
});
this.el.appendChild(segment);
}
}
// Líneas de longitud
for (let lon = -180; lon < 180; lon += 30) {
const points = [];
for (let lat = -90; lat <= 90; lat += 10) {
const pos = this.latLonToVector3(lat, lon, radius);
points.push(`${pos.x} ${pos.y} ${pos.z}`);
}
for (let i = 0; i < points.length - 1; i++) {
const segment = document.createElement('a-entity');
segment.setAttribute('line', {
start: points[i],
end: points[i + 1],
color: '#ffffff',
opacity: 0.15
});
this.el.appendChild(segment);
}
}
},
// Métodos públicos para control externo
pausePhysics: function() {
if (this.simulation) {
this.simulation.stop();
this.state.physicsRunning = false;
console.log('⏸️ Físicas pausadas');
}
},
resumePhysics: function() {
if (this.simulation) {
this.simulation.restart();
this.state.physicsRunning = true;
console.log('▶️ Físicas reanudadas');
}
},
updatePhysicsStrength: function(strength) {
if (this.simulation) {
this.simulation.force("x").strength(strength);
this.simulation.force("y").strength(strength);
this.simulation.force("z").strength(strength);
this.simulation.alpha(0.3).restart();
console.log('💪 Fuerza física actualizada:', strength);
}
},
toggleLinks: function(show) {
this.linkElements.forEach(linkObj => {
linkObj.visible = show;
});
console.log('Enlaces:', show ? 'Visibles' : 'Ocultos');
},
setColorMode: function(mode) {
// mode: 'cve' | 'license' | 'default'
this.data.colorByCVE = (mode === 'cve');
this.data.colorByLicense = (mode === 'license');
this.recolorNodes();
console.log('🎨 Color mode:', mode);
},
setLicenseFilter: function(str) {
this.data.licenseFilter = str || '';
this.recolorNodes();
console.log('🔎 License filter:', this.data.licenseFilter);
},
recolorNodes: function() {
this.nodeElements.forEach(nodeEl => {
const sphere = nodeEl.sphereElement || nodeEl.querySelector('a-sphere');
if (!sphere) return;
const data = (sphere.object3D && sphere.object3D.userData && sphere.object3D.userData.nodeData) || nodeEl.nodeData;
if (!data) return;
let color;
if (this.data.colorByLicense) {
color = this.licenseToColor(data.license);
} else if (this.data.colorByCVE) {
color = (data.cvss_score != null) ? this.cvssScoreToColor(data.cvss_score) : '#1a3a5c';
} else if (this.data.colorByValue && data.value !== undefined) {
color = this.valueToColor(data.value);
} else {
color = this.toColor(data.color);
}
const matches = this.licenseMatchesFilter(data.license);
if (!matches) color = '#222222';
sphere.setAttribute('material', {
shader: 'standard',
color: color,
emissive: color,
emissiveIntensity: matches ? 0.6 : 0.05,
roughness: 0.4,
opacity: matches ? this.data.nodeOpacity : 0.15,
transparent: true
});
});
},
getLicenseStats: function() {
const stats = {};
(this.simulationNodes || []).forEach(n => {
const k = n.license || 'UNKNOWN';
stats[k] = (stats[k] || 0) + 1;
});
return stats;
},
toggleLabels: function(show) {
this.nodeElements.forEach(nodeEl => {
if (nodeEl.labelElement) {
nodeEl.labelElement.setAttribute('visible', show);
}
});
console.log('Etiquetas:', show ? 'Visibles' : 'Ocultas');
}
});
//d3-force-3d
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-binarytree'), require('d3-quadtree'), require('d3-octree'), require('d3-dispatch'), require('d3-timer')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-binarytree', 'd3-quadtree', 'd3-octree', 'd3-dispatch', 'd3-timer'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3 = global.d3 || {}, global.d3, global.d3, global.d3, global.d3, global.d3));
})(this, (function (exports, d3Binarytree, d3Quadtree, d3Octree, d3Dispatch, d3Timer) { 'use strict';
function center(x, y, z) {
var nodes, strength = 1;
if (x == null) x = 0;
if (y == null) y = 0;
if (z == null) z = 0;
function force() {
var i,
n = nodes.length,
node,
sx = 0,
sy = 0,
sz = 0;
for (i = 0; i < n; ++i) {
node = nodes[i], sx += node.x || 0, sy += node.y || 0, sz += node.z || 0;
}
for (sx = (sx / n - x) * strength, sy = (sy / n - y) * strength, sz = (sz / n - z) * strength, i = 0; i < n; ++i) {
node = nodes[i];
if (sx) { node.x -= sx; }
if (sy) { node.y -= sy; }
if (sz) { node.z -= sz; }
}
}
force.initialize = function(_) {
nodes = _;
};
force.x = function(_) {
return arguments.length ? (x = +_, force) : x;
};
force.y = function(_) {
return arguments.length ? (y = +_, force) : y;
};
force.z = function(_) {
return arguments.length ? (z = +_, force) : z;
};
force.strength = function(_) {
return arguments.length ? (strength = +_, force) : strength;
};
return force;
}
function constant(x) {
return function() {
return x;
};
}
function jiggle(random) {
return (random() - 0.5) * 1e-6;
}
function x$2(d) {
return d.x + d.vx;
}
function y$2(d) {
return d.y + d.vy;
}
function z$2(d) {
return d.z + d.vz;
}
function collide(radius) {
var nodes,
nDim,
radii,
random,
strength = 1,
iterations = 1;
if (typeof radius !== "function") radius = constant(radius == null ? 1 : +radius);
function force() {
var i, n = nodes.length,
tree,
node,
xi,
yi,
zi,
ri,
ri2;
for (var k = 0; k < iterations; ++k) {
tree =
(nDim === 1 ? d3Binarytree.binarytree(nodes, x$2)
:(nDim === 2 ? d3Quadtree.quadtree(nodes, x$2, y$2)
:(nDim === 3 ? d3Octree.octree(nodes, x$2, y$2, z$2)
:null
))).visitAfter(prepare);
for (i = 0; i < n; ++i) {
node = nodes[i];
ri = radii[node.index], ri2 = ri * ri;
xi = node.x + node.vx;
if (nDim > 1) { yi = node.y + node.vy; }
if (nDim > 2) { zi = node.z + node.vz; }
tree.visit(apply);
}
}
function apply(treeNode, arg1, arg2, arg3, arg4, arg5, arg6) {
var args = [arg1, arg2, arg3, arg4, arg5, arg6];
var x0 = args[0],
y0 = args[1],
z0 = args[2],
x1 = args[nDim],
y1 = args[nDim+1],
z1 = args[nDim+2];
var data = treeNode.data, rj = treeNode.r, r = ri + rj;
if (data) {
if (data.index > node.index) {
var x = xi - data.x - data.vx,
y = (nDim > 1 ? yi - data.y - data.vy : 0),
z = (nDim > 2 ? zi - data.z - data.vz : 0),
l = x * x + y * y + z * z;
if (l < r * r) {
if (x === 0) x = jiggle(random), l += x * x;
if (nDim > 1 && y === 0) y = jiggle(random), l += y * y;
if (nDim > 2 && z === 0) z = jiggle(random), l += z * z;
l = (r - (l = Math.sqrt(l))) / l * strength;
node.vx += (x *= l) * (r = (rj *= rj) / (ri2 + rj));
if (nDim > 1) { node.vy += (y *= l) * r; }
if (nDim > 2) { node.vz += (z *= l) * r; }
data.vx -= x * (r = 1 - r);
if (nDim > 1) { data.vy -= y * r; }
if (nDim > 2) { data.vz -= z * r; }
}
}
return;
}
return x0 > xi + r || x1 < xi - r
|| (nDim > 1 && (y0 > yi + r || y1 < yi - r))
|| (nDim > 2 && (z0 > zi + r || z1 < zi - r));
}
}
function prepare(treeNode) {
if (treeNode.data) return treeNode.r = radii[treeNode.data.index];
for (var i = treeNode.r = 0; i < Math.pow(2, nDim); ++i) {
if (treeNode[i] && treeNode[i].r > treeNode.r) {
treeNode.r = treeNode[i].r;
}
}
}
function initialize() {
if (!nodes) return;
var i, n = nodes.length, node;
radii = new Array(n);
for (i = 0; i < n; ++i) node = nodes[i], radii[node.index] = +radius(node, i, nodes);
}
force.initialize = function(_nodes, ...args) {
nodes = _nodes;
random = args.find(arg => typeof arg === 'function') || Math.random;
nDim = args.find(arg => [1, 2, 3].includes(arg)) || 2;
initialize();
};
force.iterations = function(_) {
return arguments.length ? (iterations = +_, force) : iterations;
};
force.strength = function(_) {
return arguments.length ? (strength = +_, force) : strength;
};
force.radius = function(_) {
return arguments.length ? (radius = typeof _ === "function" ? _ : constant(+_), initialize(), force) : radius;
};
return force;
}
function index(d) {
return d.index;
}
function find(nodeById, nodeId) {
var node = nodeById.get(nodeId);
if (!node) throw new Error("node not found: " + nodeId);
return node;
}
function link(links) {
var id = index,
strength = defaultStrength,
strengths,
distance = constant(30),
distances,
nodes,
nDim,
count,
bias,
random,
iterations = 1;
if (links == null) links = [];
function defaultStrength(link) {
return 1 / Math.min(count[link.source.index], count[link.target.index]);
}
function force(alpha) {
for (var k = 0, n = links.length; k < iterations; ++k) {
for (var i = 0, link, source, target, x = 0, y = 0, z = 0, l, b; i < n; ++i) {
link = links[i], source = link.source, target = link.target;
x = target.x + target.vx - source.x - source.vx || jiggle(random);
if (nDim > 1) { y = target.y + target.vy - source.y - source.vy || jiggle(random); }
if (nDim > 2) { z = target.z + target.vz - source.z - source.vz || jiggle(random); }
l = Math.sqrt(x * x + y * y + z * z);
l = (l - distances[i]) / l * alpha * strengths[i];
x *= l, y *= l, z *= l;
target.vx -= x * (b = bias[i]);
if (nDim > 1) { target.vy -= y * b; }
if (nDim > 2) { target.vz -= z * b; }
source.vx += x * (b = 1 - b);
if (nDim > 1) { source.vy += y * b; }
if (nDim > 2) { source.vz += z * b; }
}
}
}
function initialize() {
if (!nodes) return;
var i,
n = nodes.length,
m = links.length,
nodeById = new Map(nodes.map((d, i) => [id(d, i, nodes), d])),
link;
for (i = 0, count = new Array(n); i < m; ++i) {
link = links[i], link.index = i;
if (typeof link.source !== "object") link.source = find(nodeById, link.source);
if (typeof link.target !== "object") link.target = find(nodeById, link.target);
count[link.source.index] = (count[link.source.index] || 0) + 1;
count[link.target.index] = (count[link.target.index] || 0) + 1;
}
for (i = 0, bias = new Array(m); i < m; ++i) {
link = links[i], bias[i] = count[link.source.index] / (count[link.source.index] + count[link.target.index]);
}
strengths = new Array(m), initializeStrength();
distances = new Array(m), initializeDistance();
}
function initializeStrength() {
if (!nodes) return;
for (var i = 0, n = links.length; i < n; ++i) {
strengths[i] = +strength(links[i], i, links);
}
}
function initializeDistance() {
if (!nodes) return;
for (var i = 0, n = links.length; i < n; ++i) {
distances[i] = +distance(links[i], i, links);
}
}
force.initialize = function(_nodes, ...args) {
nodes = _nodes;
random = args.find(arg => typeof arg === 'function') || Math.random;
nDim = args.find(arg => [1, 2, 3].includes(arg)) || 2;
initialize();
};
force.links = function(_) {
return arguments.length ? (links = _, initialize(), force) : links;
};
force.id = function(_) {
return arguments.length ? (id = _, force) : id;
};
force.iterations = function(_) {
return arguments.length ? (iterations = +_, force) : iterations;
};
force.strength = function(_) {
return arguments.length ? (strength = typeof _ === "function" ? _ : constant(+_), initializeStrength(), force) : strength;
};
force.distance = function(_) {
return arguments.length ? (distance = typeof _ === "function" ? _ : constant(+_), initializeDistance(), force) : distance;
};
return force;
}
const a = 1664525;
const c = 1013904223;
const m = 4294967296; // 2^32
function lcg() {
let s = 1;
return () => (s = (a * s + c) % m) / m;
}
var MAX_DIMENSIONS = 3;
function x$1(d) {
return d.x;
}
function y$1(d) {
return d.y;
}
function z$1(d) {
return d.z;
}
var initialRadius = 10,
initialAngleRoll = Math.PI * (3 - Math.sqrt(5)), // Golden ratio angle
initialAngleYaw = Math.PI * 20 / (9 + Math.sqrt(221)); // Markov irrational number
function simulation(nodes, numDimensions) {
numDimensions = numDimensions || 2;
var nDim = Math.min(MAX_DIMENSIONS, Math.max(1, Math.round(numDimensions))),
simulation,
alpha = 1,
alphaMin = 0.001,
alphaDecay = 1 - Math.pow(alphaMin, 1 / 300),
alphaTarget = 0,
velocityDecay = 0.6,
forces = new Map(),
stepper = d3Timer.timer(step),
event = d3Dispatch.dispatch("tick", "end"),
random = lcg();
if (nodes == null) nodes = [];
function step() {
tick();
event.call("tick", simulation);
if (alpha < alphaMin) {
stepper.stop();
event.call("end", simulation);
}
}
function tick(iterations) {
var i, n = nodes.length, node;
if (iterations === undefined) iterations = 1;
for (var k = 0; k < iterations; ++k) {
alpha += (alphaTarget - alpha) * alphaDecay;
forces.forEach(function (force) {
force(alpha);
});
for (i = 0; i < n; ++i) {
node = nodes[i];
if (node.fx == null) node.x += node.vx *= velocityDecay;
else node.x = node.fx, node.vx = 0;
if (nDim > 1) {
if (node.fy == null) node.y += node.vy *= velocityDecay;
else node.y = node.fy, node.vy = 0;
}
if (nDim > 2) {
if (node.fz == null) node.z += node.vz *= velocityDecay;
else node.z = node.fz, node.vz = 0;
}
}
}
return simulation;
}
function initializeNodes() {
for (var i = 0, n = nodes.length, node; i < n; ++i) {
node = nodes[i], node.index = i;
if (node.fx != null) node.x = node.fx;
if (node.fy != null) node.y = node.fy;
if (node.fz != null) node.z = node.fz;
if (isNaN(node.x) || (nDim > 1 && isNaN(node.y)) || (nDim > 2 && isNaN(node.z))) {
var radius = initialRadius * (nDim > 2 ? Math.cbrt(0.5 + i) : (nDim > 1 ? Math.sqrt(0.5 + i) : i)),
rollAngle = i * initialAngleRoll,
yawAngle = i * initialAngleYaw;
if (nDim === 1) {
node.x = radius;
} else if (nDim === 2) {
node.x = radius * Math.cos(rollAngle);
node.y = radius * Math.sin(rollAngle);
} else { // 3 dimensions: use spherical distribution along 2 irrational number angles
node.x = radius * Math.sin(rollAngle) * Math.cos(yawAngle);
node.y = radius * Math.cos(rollAngle);
node.z = radius * Math.sin(rollAngle) * Math.sin(yawAngle);
}
}
if (isNaN(node.vx) || (nDim > 1 && isNaN(node.vy)) || (nDim > 2 && isNaN(node.vz))) {
node.vx = 0;
if (nDim > 1) { node.vy = 0; }
if (nDim > 2) { node.vz = 0; }
}
}
}
function initializeForce(force) {
if (force.initialize) force.initialize(nodes, random, nDim);
return force;
}
initializeNodes();
return si