667 lines
37 KiB
HTML
667 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Genre Universe - Das Artist Positioning</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { overflow: hidden; background: #000; font-family: 'Space Grotesk', system-ui, sans-serif; }
|
|
#canvas { display: block; }
|
|
|
|
.panel {
|
|
position: fixed;
|
|
background: linear-gradient(135deg, rgba(15, 15, 30, 0.95), rgba(5, 5, 15, 0.98));
|
|
border: 1px solid rgba(168, 85, 247, 0.15);
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
color: white;
|
|
backdrop-filter: blur(20px);
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 60px rgba(168, 85, 247, 0.05);
|
|
z-index: 100;
|
|
}
|
|
|
|
#info-panel { top: 20px; left: 20px; max-width: 280px; }
|
|
#info-panel h1 {
|
|
font-size: 1.4rem;
|
|
font-weight: 600;
|
|
margin-bottom: 12px;
|
|
background: linear-gradient(135deg, #00f5ff, #a855f7, #ff00ff);
|
|
background-size: 200% 200%;
|
|
animation: gradient-shift 3s ease infinite;
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
@keyframes gradient-shift { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
|
#info-panel p { font-size: 0.75rem; color: rgba(255,255,255,0.5); line-height: 1.6; }
|
|
.subtitle { margin-top: 10px; font-size: 0.65rem !important; font-family: 'JetBrains Mono', monospace; color: rgba(255,255,255,0.3) !important; }
|
|
|
|
#legend { bottom: 20px; left: 20px; max-height: 350px; overflow-y: auto; }
|
|
#legend::-webkit-scrollbar { width: 4px; }
|
|
#legend::-webkit-scrollbar-thumb { background: rgba(168,85,247,0.3); border-radius: 2px; }
|
|
.panel h3 { font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 12px; color: rgba(255,255,255,0.4); }
|
|
.legend-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.7rem; color: rgba(255,255,255,0.6); padding: 3px 6px; margin-left: -6px; border-radius: 4px; cursor: pointer; transition: all 0.2s; }
|
|
.legend-item:hover { background: rgba(168,85,247,0.1); color: white; }
|
|
.legend-item.disabled { opacity: 0.35; }
|
|
.legend-item.disabled .legend-color { box-shadow: none; }
|
|
.legend-color { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 6px currentColor; }
|
|
.legend-checkbox { width: 14px; height: 14px; margin-right: 8px; accent-color: #a855f7; cursor: pointer; flex-shrink: 0; }
|
|
.legend-buttons { display: flex; gap: 6px; margin-bottom: 12px; }
|
|
.legend-btn { background: rgba(168,85,247,0.15); border: 1px solid rgba(168,85,247,0.2); color: rgba(255,255,255,0.6); padding: 4px 8px; border-radius: 4px; font-size: 0.6rem; font-family: 'Space Grotesk', sans-serif; cursor: pointer; transition: all 0.2s; }
|
|
.legend-btn:hover { background: rgba(168,85,247,0.3); color: white; }
|
|
|
|
#axes-legend { bottom: 20px; right: 20px; }
|
|
.axis-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.65rem; color: rgba(255,255,255,0.5); font-family: 'JetBrains Mono', monospace; }
|
|
.axis-color { width: 14px; height: 2px; margin-right: 8px; border-radius: 1px; }
|
|
|
|
#controls { top: 20px; right: 20px; }
|
|
.control-row { display: flex; align-items: center; margin-bottom: 10px; font-size: 0.7rem; }
|
|
.control-row label { flex: 1; color: rgba(255,255,255,0.5); }
|
|
.control-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: #a855f7; }
|
|
.control-row input[type="range"] { width: 90px; height: 3px; -webkit-appearance: none; background: rgba(255,255,255,0.1); border-radius: 2px; }
|
|
.control-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; background: linear-gradient(135deg, #00f5ff, #a855f7); border-radius: 50%; box-shadow: 0 0 10px rgba(168,85,247,0.5); }
|
|
button { background: linear-gradient(135deg, rgba(168,85,247,0.25), rgba(0,245,255,0.15)); border: 1px solid rgba(168,85,247,0.25); color: white; padding: 8px 14px; border-radius: 6px; font-size: 0.7rem; font-family: 'Space Grotesk', sans-serif; font-weight: 500; margin-top: 6px; width: 100%; cursor: pointer; text-transform: uppercase; letter-spacing: 0.05em; transition: all 0.3s; }
|
|
button:hover { background: linear-gradient(135deg, rgba(168,85,247,0.4), rgba(0,245,255,0.25)); box-shadow: 0 0 20px rgba(168,85,247,0.3); }
|
|
|
|
#tooltip { position: fixed; background: rgba(10,10,20,0.98); border: 1px solid rgba(168,85,247,0.3); border-radius: 10px; padding: 14px 18px; color: white; pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 1000; max-width: 260px; backdrop-filter: blur(20px); }
|
|
#tooltip.visible { opacity: 1; }
|
|
#tooltip h4 { font-size: 0.95rem; font-weight: 600; margin-bottom: 8px; }
|
|
#tooltip .description { font-size: 0.65rem; color: rgba(255,255,255,0.4); margin-bottom: 10px; line-height: 1.5; font-style: italic; }
|
|
#tooltip .stats { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; font-size: 0.65rem; font-family: 'JetBrains Mono', monospace; }
|
|
#tooltip .stat-label { color: rgba(255,255,255,0.35); }
|
|
#tooltip .stat-value { color: rgba(255,255,255,0.85); text-align: right; }
|
|
|
|
#loading { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; display: flex; align-items: center; justify-content: center; z-index: 9999; transition: opacity 0.8s; }
|
|
#loading.hidden { opacity: 0; pointer-events: none; }
|
|
.loader { width: 50px; height: 50px; border: 2px solid rgba(168,85,247,0.2); border-top-color: #a855f7; border-radius: 50%; animation: spin 1s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="loading"><div class="loader"></div></div>
|
|
<canvas id="canvas"></canvas>
|
|
|
|
<div id="info-panel" class="panel">
|
|
<h1>🎵 Genre Universe</h1>
|
|
<p>Interactive 3D visualization of musical genre space. Artists positioned by sonic DNA, spikes show dimensional attributes.</p>
|
|
<p class="subtitle">DRAG → ROTATE • SCROLL → ZOOM • HOVER → INFO</p>
|
|
</div>
|
|
|
|
<div id="legend" class="panel">
|
|
<h3>Artists</h3>
|
|
<div class="legend-buttons">
|
|
<button class="legend-btn" id="selectAll">All</button>
|
|
<button class="legend-btn" id="selectNone">None</button>
|
|
<button class="legend-btn" id="selectMain">Das Only</button>
|
|
</div>
|
|
<div id="artist-legend"></div>
|
|
</div>
|
|
|
|
<div id="axes-legend" class="panel">
|
|
<h3>Dimensions</h3>
|
|
<div class="axis-item"><div class="axis-color" style="background: #ff6b6b;"></div><span>↑ Energy</span></div>
|
|
<div class="axis-item"><div class="axis-color" style="background: #4ecdc4;"></div><span>↓ Acousticness</span></div>
|
|
<div class="axis-item"><div class="axis-color" style="background: #ffe66d;"></div><span>← Emotion</span></div>
|
|
<div class="axis-item"><div class="axis-color" style="background: #95e1d3;"></div><span>→ Danceability</span></div>
|
|
<h3 style="margin-top: 14px;">Position</h3>
|
|
<div class="axis-item"><div class="axis-color" style="background: #ff6b6b;"></div><span>X: Valence</span></div>
|
|
<div class="axis-item"><div class="axis-color" style="background: #4ade80;"></div><span>Y: Tempo</span></div>
|
|
<div class="axis-item"><div class="axis-color" style="background: #60a5fa;"></div><span>Z: Organic</span></div>
|
|
</div>
|
|
|
|
<div id="controls" class="panel">
|
|
<h3>Controls</h3>
|
|
<div class="control-row"><label>Auto-rotate</label><input type="checkbox" id="autoRotate" checked></div>
|
|
<div class="control-row"><label>Show grid</label><input type="checkbox" id="showGrid" checked></div>
|
|
<div class="control-row"><label>Show spikes</label><input type="checkbox" id="showSpikes" checked></div>
|
|
<div class="control-row"><label>Show connections</label><input type="checkbox" id="showConnections" checked></div>
|
|
<div class="control-row"><label>Bloom</label><input type="range" id="glowIntensity" min="0" max="2.5" step="0.1" value="1.5"></div>
|
|
<div class="control-row"><label>Spacing</label><input type="range" id="spacing" min="0.5" max="2" step="0.1" value="1"></div>
|
|
<button id="focusDas">Focus on Das ⭐</button>
|
|
<button id="resetCamera">Reset View</button>
|
|
</div>
|
|
|
|
<div id="tooltip">
|
|
<h4 id="tooltip-name"></h4>
|
|
<p class="description" id="tooltip-desc"></p>
|
|
<div class="stats" id="tooltip-stats"></div>
|
|
</div>
|
|
|
|
<script type="importmap">
|
|
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
|
|
</script>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
|
|
|
// Artist data
|
|
const artists = [
|
|
{ name: "Das", color: 0x00ffff, position: { valence: 0.43, tempo: 0.35, organic: 0.7 }, spikes: { energy: 0.9, acousticness: 0.6, emotionalDepth: 0.95, danceability: 0.85, lyricComplexity: 0.85, productionDensity: 0.8 }, description: "Singer-songwriter meets bass. Organic vocals with melodic bass drops.", isMain: true },
|
|
{ name: "San Holo", color: 0x4a90d9, position: { valence: 0.55, tempo: 0.6, organic: 0.45 }, spikes: { energy: 0.7, acousticness: 0.35, emotionalDepth: 0.8, danceability: 0.65, lyricComplexity: 0.6, productionDensity: 0.7 }, description: "Future bass pioneer with guitar-driven sound." },
|
|
{ name: "Illenium", color: 0xff6b9d, position: { valence: 0.4, tempo: 0.7, organic: 0.35 }, spikes: { energy: 0.85, acousticness: 0.25, emotionalDepth: 0.9, danceability: 0.75, lyricComplexity: 0.7, productionDensity: 0.85 }, description: "Melodic dubstep with massive emotional builds." },
|
|
{ name: "Seven Lions", color: 0x9b59b6, position: { valence: 0.45, tempo: 0.75, organic: 0.25 }, spikes: { energy: 0.9, acousticness: 0.15, emotionalDepth: 0.85, danceability: 0.8, lyricComplexity: 0.65, productionDensity: 0.95 }, description: "Melodic dubstep meets trance." },
|
|
{ name: "Kygo", color: 0xf39c12, position: { valence: 0.75, tempo: 0.55, organic: 0.6 }, spikes: { energy: 0.55, acousticness: 0.5, emotionalDepth: 0.6, danceability: 0.7, lyricComplexity: 0.5, productionDensity: 0.5 }, description: "Tropical house pioneer." },
|
|
{ name: "Joji", color: 0xe74c3c, position: { valence: 0.25, tempo: 0.4, organic: 0.7 }, spikes: { energy: 0.35, acousticness: 0.6, emotionalDepth: 0.95, danceability: 0.4, lyricComplexity: 0.8, productionDensity: 0.55 }, description: "Lo-fi R&B with deep melancholy." },
|
|
{ name: "Porter Robinson", color: 0xe91e8c, position: { valence: 0.6, tempo: 0.65, organic: 0.35 }, spikes: { energy: 0.75, acousticness: 0.3, emotionalDepth: 0.9, danceability: 0.65, lyricComplexity: 0.75, productionDensity: 0.8 }, description: "Anime-inspired emotional electronic." },
|
|
{ name: "Odesza", color: 0x3498db, position: { valence: 0.6, tempo: 0.55, organic: 0.5 }, spikes: { energy: 0.65, acousticness: 0.45, emotionalDepth: 0.75, danceability: 0.7, lyricComplexity: 0.55, productionDensity: 0.7 }, description: "Atmospheric electronic journeys." },
|
|
{ name: "Subtronics", color: 0x8e44ad, position: { valence: 0.45, tempo: 0.85, organic: 0.1 }, spikes: { energy: 0.98, acousticness: 0.05, emotionalDepth: 0.4, danceability: 0.9, lyricComplexity: 0.2, productionDensity: 0.98 }, description: "Heavy dubstep and riddim." },
|
|
{ name: "Brakence", color: 0x00ff88, position: { valence: 0.3, tempo: 0.5, organic: 0.55 }, spikes: { energy: 0.6, acousticness: 0.4, emotionalDepth: 0.9, danceability: 0.5, lyricComplexity: 0.95, productionDensity: 0.75 }, description: "Hyperpop-adjacent emotional chaos." },
|
|
{ name: "Flume", color: 0xff6f61, position: { valence: 0.5, tempo: 0.55, organic: 0.45 }, spikes: { energy: 0.6, acousticness: 0.35, emotionalDepth: 0.7, danceability: 0.6, lyricComplexity: 0.5, productionDensity: 0.85 }, description: "Experimental glitchy electronic." },
|
|
{ name: "Frank Ocean", color: 0xffa500, position: { valence: 0.35, tempo: 0.45, organic: 0.55 }, spikes: { energy: 0.4, acousticness: 0.5, emotionalDepth: 0.98, danceability: 0.4, lyricComplexity: 0.95, productionDensity: 0.7 }, description: "Experimental R&B legend." },
|
|
{ name: "keshi", color: 0xffd700, position: { valence: 0.35, tempo: 0.42, organic: 0.6 }, spikes: { energy: 0.35, acousticness: 0.55, emotionalDepth: 0.9, danceability: 0.45, lyricComplexity: 0.8, productionDensity: 0.55 }, description: "Lo-fi R&B melancholy." },
|
|
{ name: "Eden", color: 0x2f4f4f, position: { valence: 0.2, tempo: 0.45, organic: 0.6 }, spikes: { energy: 0.45, acousticness: 0.55, emotionalDepth: 0.98, danceability: 0.35, lyricComplexity: 0.95, productionDensity: 0.7 }, description: "Dark indie electronic devastation." },
|
|
{ name: "Clairo", color: 0xffb6c1, position: { valence: 0.4, tempo: 0.42, organic: 0.7 }, spikes: { energy: 0.3, acousticness: 0.7, emotionalDepth: 0.8, danceability: 0.4, lyricComplexity: 0.7, productionDensity: 0.4 }, description: "Bedroom pop pioneer." },
|
|
{ name: "KSHMR", color: 0xff8c00, position: { valence: 0.55, tempo: 0.75, organic: 0.35 }, spikes: { energy: 0.85, acousticness: 0.3, emotionalDepth: 0.6, danceability: 0.8, lyricComplexity: 0.4, productionDensity: 0.9 }, description: "Indian/Egyptian big room fusion." },
|
|
{ name: "Rezz", color: 0x8b0000, position: { valence: 0.3, tempo: 0.6, organic: 0.15 }, spikes: { energy: 0.7, acousticness: 0.1, emotionalDepth: 0.6, danceability: 0.7, lyricComplexity: 0.15, productionDensity: 0.8 }, description: "Dark hypnotic midtempo." },
|
|
{ name: "Excision", color: 0x660066, position: { valence: 0.35, tempo: 0.85, organic: 0.05 }, spikes: { energy: 0.99, acousticness: 0.02, emotionalDepth: 0.3, danceability: 0.85, lyricComplexity: 0.1, productionDensity: 0.99 }, description: "Earth-shaking dubstep." },
|
|
{ name: "Marshmello", color: 0xffffff, position: { valence: 0.8, tempo: 0.7, organic: 0.2 }, spikes: { energy: 0.75, acousticness: 0.15, emotionalDepth: 0.5, danceability: 0.85, lyricComplexity: 0.35, productionDensity: 0.7 }, description: "Mainstream happy EDM." },
|
|
{ name: "Madeon", color: 0xff9ecd, position: { valence: 0.7, tempo: 0.7, organic: 0.35 }, spikes: { energy: 0.75, acousticness: 0.25, emotionalDepth: 0.7, danceability: 0.75, lyricComplexity: 0.6, productionDensity: 0.9 }, description: "Retro-future French electronic." },
|
|
{ name: "Daniel Caesar", color: 0xdaa520, position: { valence: 0.4, tempo: 0.4, organic: 0.7 }, spikes: { energy: 0.35, acousticness: 0.7, emotionalDepth: 0.9, danceability: 0.4, lyricComplexity: 0.75, productionDensity: 0.5 }, description: "Soulful gospel R&B." },
|
|
{ name: "Bonobo", color: 0x228b22, position: { valence: 0.5, tempo: 0.45, organic: 0.65 }, spikes: { energy: 0.5, acousticness: 0.6, emotionalDepth: 0.75, danceability: 0.55, lyricComplexity: 0.3, productionDensity: 0.65 }, description: "Organic downtempo." },
|
|
{ name: "Virtual Self", color: 0x00ffcc, position: { valence: 0.55, tempo: 0.8, organic: 0.15 }, spikes: { energy: 0.9, acousticness: 0.1, emotionalDepth: 0.7, danceability: 0.85, lyricComplexity: 0.4, productionDensity: 0.9 }, description: "Y2K trance revival." },
|
|
{ name: "Yung Lean", color: 0x483d8b, position: { valence: 0.25, tempo: 0.5, organic: 0.4 }, spikes: { energy: 0.45, acousticness: 0.25, emotionalDepth: 0.85, danceability: 0.5, lyricComplexity: 0.75, productionDensity: 0.7 }, description: "Cloud rap sad boy pioneer." }
|
|
];
|
|
|
|
// Scene setup
|
|
const canvas = document.getElementById('canvas');
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.set(10, 7, 10);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 0.9;
|
|
|
|
const labelRenderer = new CSS2DRenderer();
|
|
labelRenderer.setSize(window.innerWidth, window.innerHeight);
|
|
labelRenderer.domElement.style.position = 'absolute';
|
|
labelRenderer.domElement.style.top = '0';
|
|
labelRenderer.domElement.style.pointerEvents = 'none';
|
|
document.body.appendChild(labelRenderer.domElement);
|
|
|
|
// Post-processing
|
|
const composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.6, 0.7);
|
|
composer.addPass(bloomPass);
|
|
|
|
// Controls
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.05;
|
|
controls.autoRotate = true;
|
|
controls.autoRotateSpeed = 0.25;
|
|
controls.minDistance = 5;
|
|
controls.maxDistance = 30;
|
|
|
|
// Lighting
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.15));
|
|
const lights = [
|
|
{ color: 0x00ffff, pos: [12, 12, 12] },
|
|
{ color: 0xff00ff, pos: [-12, -8, 12] },
|
|
{ color: 0xa855f7, pos: [0, 15, -10] },
|
|
{ color: 0x00ff88, pos: [-10, 5, -10] }
|
|
];
|
|
lights.forEach(l => {
|
|
const light = new THREE.PointLight(l.color, 0.6, 50);
|
|
light.position.set(...l.pos);
|
|
scene.add(light);
|
|
});
|
|
|
|
// =====================
|
|
// COSMIC BACKGROUND
|
|
// =====================
|
|
// Nebula particles
|
|
const nebulaCount = 4000;
|
|
const nebulaGeo = new THREE.BufferGeometry();
|
|
const nebulaPos = new Float32Array(nebulaCount * 3);
|
|
const nebulaCol = new Float32Array(nebulaCount * 3);
|
|
const nebulaSizes = new Float32Array(nebulaCount);
|
|
|
|
for (let i = 0; i < nebulaCount; i++) {
|
|
const r = 30 + Math.random() * 40;
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const phi = Math.acos(2 * Math.random() - 1);
|
|
nebulaPos[i*3] = r * Math.sin(phi) * Math.cos(theta);
|
|
nebulaPos[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
|
|
nebulaPos[i*3+2] = r * Math.cos(phi);
|
|
|
|
const colors = [[0.4, 0.2, 0.8], [0, 0.9, 1], [1, 0.4, 0.8], [0.2, 0.6, 1]];
|
|
const c = colors[Math.floor(Math.random() * colors.length)];
|
|
nebulaCol[i*3] = c[0]; nebulaCol[i*3+1] = c[1]; nebulaCol[i*3+2] = c[2];
|
|
nebulaSizes[i] = Math.random() * 3 + 1;
|
|
}
|
|
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(nebulaPos, 3));
|
|
nebulaGeo.setAttribute('color', new THREE.BufferAttribute(nebulaCol, 3));
|
|
const nebulaMat = new THREE.PointsMaterial({ size: 0.15, vertexColors: true, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, sizeAttenuation: true });
|
|
const nebula = new THREE.Points(nebulaGeo, nebulaMat);
|
|
scene.add(nebula);
|
|
|
|
// Inner particles
|
|
const innerCount = 800;
|
|
const innerGeo = new THREE.BufferGeometry();
|
|
const innerPos = new Float32Array(innerCount * 3);
|
|
for (let i = 0; i < innerCount; i++) {
|
|
innerPos[i*3] = (Math.random() - 0.5) * 14;
|
|
innerPos[i*3+1] = (Math.random() - 0.5) * 14;
|
|
innerPos[i*3+2] = (Math.random() - 0.5) * 14;
|
|
}
|
|
innerGeo.setAttribute('position', new THREE.BufferAttribute(innerPos, 3));
|
|
const innerMat = new THREE.PointsMaterial({ size: 0.04, color: 0x8888ff, transparent: true, opacity: 0.4, blending: THREE.AdditiveBlending });
|
|
const innerParticles = new THREE.Points(innerGeo, innerMat);
|
|
scene.add(innerParticles);
|
|
|
|
// =====================
|
|
// HOLOGRAPHIC GRID
|
|
// =====================
|
|
const gridGroup = new THREE.Group();
|
|
scene.add(gridGroup);
|
|
const gridSize = 6;
|
|
|
|
// Outer cube frame with glow
|
|
const frameMat = new THREE.LineBasicMaterial({ color: 0xa855f7, transparent: true, opacity: 0.4 });
|
|
const frameGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(gridSize, gridSize, gridSize));
|
|
gridGroup.add(new THREE.LineSegments(frameGeo, frameMat));
|
|
|
|
// Animated scan planes
|
|
const scanPlaneMat = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.03, side: THREE.DoubleSide });
|
|
const scanPlanes = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const plane = new THREE.Mesh(new THREE.PlaneGeometry(gridSize, gridSize), scanPlaneMat.clone());
|
|
if (i === 0) plane.rotation.x = Math.PI / 2;
|
|
if (i === 2) plane.rotation.y = Math.PI / 2;
|
|
plane.userData.axis = i;
|
|
plane.userData.offset = Math.random() * Math.PI * 2;
|
|
scanPlanes.push(plane);
|
|
gridGroup.add(plane);
|
|
}
|
|
|
|
// Corner nodes
|
|
const cornerMat = new THREE.MeshBasicMaterial({ color: 0xa855f7 });
|
|
const corners = [[-1,-1,-1],[-1,-1,1],[-1,1,-1],[-1,1,1],[1,-1,-1],[1,-1,1],[1,1,-1],[1,1,1]];
|
|
corners.forEach(c => {
|
|
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.06, 16, 16), cornerMat);
|
|
sphere.position.set(c[0] * gridSize/2, c[1] * gridSize/2, c[2] * gridSize/2);
|
|
gridGroup.add(sphere);
|
|
});
|
|
|
|
// Floor grid with glow
|
|
const floorGrid = new THREE.GridHelper(gridSize * 2, 20, 0x4a1f7a, 0x1a0a2e);
|
|
floorGrid.position.y = -gridSize / 2 - 0.01;
|
|
floorGrid.material.transparent = true;
|
|
floorGrid.material.opacity = 0.3;
|
|
gridGroup.add(floorGrid);
|
|
|
|
// =====================
|
|
// AXIS LABELS
|
|
// =====================
|
|
function createAxisLabel(text, position, color) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
div.style.cssText = `
|
|
color: ${color};
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.15em;
|
|
text-shadow: 0 0 10px ${color}, 0 0 20px rgba(0,0,0,0.8);
|
|
opacity: 0.8;
|
|
`;
|
|
const label = new CSS2DObject(div);
|
|
label.position.copy(position);
|
|
return label;
|
|
}
|
|
|
|
const axisLabels = [
|
|
{ text: 'SAD', pos: new THREE.Vector3(-gridSize/2 - 0.8, 0, 0), color: '#ff6b6b' },
|
|
{ text: 'HAPPY', pos: new THREE.Vector3(gridSize/2 + 0.8, 0, 0), color: '#4ade80' },
|
|
{ text: 'SLOW', pos: new THREE.Vector3(0, -gridSize/2 - 0.8, 0), color: '#60a5fa' },
|
|
{ text: 'FAST', pos: new THREE.Vector3(0, gridSize/2 + 0.8, 0), color: '#fbbf24' },
|
|
{ text: 'ELECTRONIC', pos: new THREE.Vector3(0, 0, -gridSize/2 - 1.0), color: '#a855f7' },
|
|
{ text: 'ORGANIC', pos: new THREE.Vector3(0, 0, gridSize/2 + 1.0), color: '#22d3ee' }
|
|
];
|
|
|
|
axisLabels.forEach(({ text, pos, color }) => {
|
|
gridGroup.add(createAxisLabel(text, pos, color));
|
|
});
|
|
|
|
// =====================
|
|
// ARTIST NODES
|
|
// =====================
|
|
const artistMeshes = [], artistLabels = [], spikeGroups = [], artistGroups = [];
|
|
const connectionLines = [];
|
|
|
|
const spikeColors = { energy: 0xff6b6b, acousticness: 0x4ecdc4, emotionalDepth: 0xffe66d, danceability: 0x95e1d3, lyricComplexity: 0xdda0dd, productionDensity: 0xf38181 };
|
|
const spikeDirections = {
|
|
energy: new THREE.Vector3(0, 1, 0), acousticness: new THREE.Vector3(0, -1, 0),
|
|
emotionalDepth: new THREE.Vector3(-1, 0, 0), danceability: new THREE.Vector3(1, 0, 0),
|
|
lyricComplexity: new THREE.Vector3(0.707, 0.707, 0), productionDensity: new THREE.Vector3(-0.707, -0.707, 0)
|
|
};
|
|
|
|
// Custom shader for orbs
|
|
const orbVertexShader = `
|
|
varying vec3 vNormal;
|
|
varying vec3 vPosition;
|
|
void main() {
|
|
vNormal = normalize(normalMatrix * normal);
|
|
vPosition = position;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`;
|
|
const orbFragmentShader = `
|
|
uniform vec3 uColor;
|
|
uniform float uTime;
|
|
uniform float uIntensity;
|
|
varying vec3 vNormal;
|
|
varying vec3 vPosition;
|
|
void main() {
|
|
float fresnel = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.5);
|
|
vec3 glow = uColor * fresnel * 1.5;
|
|
float pulse = 0.9 + 0.1 * sin(uTime * 2.0);
|
|
vec3 core = uColor * uIntensity * pulse;
|
|
gl_FragColor = vec4(core + glow, 1.0);
|
|
}
|
|
`;
|
|
|
|
function createArtistNode(artist) {
|
|
const group = new THREE.Group();
|
|
const x = (artist.position.valence - 0.5) * 6;
|
|
const y = (artist.position.tempo - 0.5) * 6;
|
|
const z = (artist.position.organic - 0.5) * 6;
|
|
group.position.set(x, y, z);
|
|
|
|
const radius = artist.isMain ? 0.45 : 0.2;
|
|
const color = new THREE.Color(artist.color);
|
|
|
|
// Shader orb
|
|
const orbMat = new THREE.ShaderMaterial({
|
|
uniforms: { uColor: { value: color }, uTime: { value: 0 }, uIntensity: { value: artist.isMain ? 0.7 : 0.5 } },
|
|
vertexShader: orbVertexShader, fragmentShader: orbFragmentShader, transparent: true
|
|
});
|
|
const orb = new THREE.Mesh(new THREE.SphereGeometry(radius, 64, 64), orbMat);
|
|
orb.userData = { artist, type: 'artist', material: orbMat };
|
|
group.add(orb);
|
|
|
|
// Glow layers
|
|
[1.3, 1.8, 2.5].forEach((scale, i) => {
|
|
const glowMat = new THREE.MeshBasicMaterial({ color: artist.color, transparent: true, opacity: 0.15 - i * 0.04, side: THREE.BackSide });
|
|
group.add(new THREE.Mesh(new THREE.SphereGeometry(radius * scale, 24, 24), glowMat));
|
|
});
|
|
|
|
// Rings for main artist
|
|
if (artist.isMain) {
|
|
[1.6, 2.2, 2.8].forEach((r, i) => {
|
|
const ring = new THREE.Mesh(
|
|
new THREE.TorusGeometry(radius * r, 0.015, 16, 100),
|
|
new THREE.MeshBasicMaterial({ color: artist.color, transparent: true, opacity: 0.5 - i * 0.15 })
|
|
);
|
|
ring.rotation.x = Math.PI / 2 + i * 0.3;
|
|
ring.userData.rotSpeed = 0.3 + i * 0.2;
|
|
group.add(ring);
|
|
});
|
|
|
|
// Energy particles around Das
|
|
const particleCount = 50;
|
|
const particleGeo = new THREE.BufferGeometry();
|
|
const particlePos = new Float32Array(particleCount * 3);
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const angle = (i / particleCount) * Math.PI * 2;
|
|
const r = radius * 3 + Math.random() * 0.5;
|
|
particlePos[i*3] = Math.cos(angle) * r;
|
|
particlePos[i*3+1] = (Math.random() - 0.5) * 1.5;
|
|
particlePos[i*3+2] = Math.sin(angle) * r;
|
|
}
|
|
particleGeo.setAttribute('position', new THREE.BufferAttribute(particlePos, 3));
|
|
const particleMat = new THREE.PointsMaterial({ size: 0.05, color: artist.color, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending });
|
|
const particles = new THREE.Points(particleGeo, particleMat);
|
|
particles.userData.isOrbital = true;
|
|
group.add(particles);
|
|
}
|
|
|
|
// Spikes
|
|
const spikeGroup = new THREE.Group();
|
|
for (const [key, value] of Object.entries(artist.spikes)) {
|
|
const length = value * 1.0;
|
|
const dir = spikeDirections[key].clone();
|
|
const color = spikeColors[key];
|
|
|
|
// Spike line
|
|
const points = [new THREE.Vector3(0,0,0).add(dir.clone().multiplyScalar(radius)), dir.clone().multiplyScalar(radius + length)];
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.7 });
|
|
spikeGroup.add(new THREE.Line(lineGeo, lineMat));
|
|
|
|
// Tip
|
|
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.04, 8, 8), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }));
|
|
tip.position.copy(dir.clone().multiplyScalar(radius + length));
|
|
spikeGroup.add(tip);
|
|
}
|
|
group.add(spikeGroup);
|
|
spikeGroups.push(spikeGroup);
|
|
|
|
// Label
|
|
const labelDiv = document.createElement('div');
|
|
labelDiv.textContent = artist.name;
|
|
labelDiv.style.cssText = `color: #${artist.color.toString(16).padStart(6,'0')}; font-size: ${artist.isMain ? '12px' : '9px'}; font-weight: ${artist.isMain ? '600' : '400'}; font-family: 'Space Grotesk', sans-serif; text-shadow: 0 0 8px rgba(0,0,0,0.9), 0 0 16px #${artist.color.toString(16).padStart(6,'0')}40; letter-spacing: 0.05em;`;
|
|
const label = new CSS2DObject(labelDiv);
|
|
label.position.set(0, radius + 0.3, 0);
|
|
group.add(label);
|
|
artistLabels.push(label);
|
|
|
|
scene.add(group);
|
|
artistMeshes.push(orb);
|
|
artistGroups.push(group);
|
|
return group;
|
|
}
|
|
|
|
artists.forEach(createArtistNode);
|
|
|
|
// =====================
|
|
// CONNECTION LINES
|
|
// =====================
|
|
const connectionGroup = new THREE.Group();
|
|
scene.add(connectionGroup);
|
|
|
|
function createConnections() {
|
|
connectionGroup.clear();
|
|
for (let i = 0; i < artistGroups.length; i++) {
|
|
if (!artistGroups[i].visible) continue; // skip hidden artists
|
|
for (let j = i + 1; j < artistGroups.length; j++) {
|
|
if (!artistGroups[j].visible) continue; // skip hidden artists
|
|
const dist = artistGroups[i].position.distanceTo(artistGroups[j].position);
|
|
if (dist < 2.5) {
|
|
const opacity = Math.max(0, 0.2 - dist * 0.06);
|
|
const points = [artistGroups[i].position.clone(), artistGroups[j].position.clone()];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const mat = new THREE.LineBasicMaterial({ color: 0x8855ff, transparent: true, opacity });
|
|
connectionGroup.add(new THREE.Line(geo, mat));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
createConnections();
|
|
|
|
// Legend with checkboxes
|
|
const legendContainer = document.getElementById('artist-legend');
|
|
const artistVisibility = new Map(); // track which artists are visible
|
|
|
|
artists.forEach((a, idx) => {
|
|
artistVisibility.set(idx, true);
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'legend-item';
|
|
item.dataset.idx = idx;
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.className = 'legend-checkbox';
|
|
checkbox.checked = true;
|
|
checkbox.dataset.idx = idx;
|
|
|
|
const colorDot = document.createElement('div');
|
|
colorDot.className = 'legend-color';
|
|
colorDot.style.background = '#' + a.color.toString(16).padStart(6,'0');
|
|
colorDot.style.color = '#' + a.color.toString(16).padStart(6,'0');
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.textContent = a.name + (a.isMain ? ' ⭐' : '');
|
|
|
|
item.appendChild(checkbox);
|
|
item.appendChild(colorDot);
|
|
item.appendChild(nameSpan);
|
|
legendContainer.appendChild(item);
|
|
|
|
// Toggle on checkbox change
|
|
checkbox.addEventListener('change', () => {
|
|
const visible = checkbox.checked;
|
|
artistVisibility.set(idx, visible);
|
|
artistGroups[idx].visible = visible;
|
|
item.classList.toggle('disabled', !visible);
|
|
createConnections(); // update connections
|
|
});
|
|
|
|
// Click on row (not checkbox) toggles too
|
|
item.addEventListener('click', (e) => {
|
|
if (e.target === checkbox) return;
|
|
checkbox.checked = !checkbox.checked;
|
|
checkbox.dispatchEvent(new Event('change'));
|
|
});
|
|
});
|
|
|
|
// Select all/none/main buttons
|
|
function setAllVisibility(visible, mainOnly = false) {
|
|
artists.forEach((a, idx) => {
|
|
const shouldShow = mainOnly ? a.isMain : visible;
|
|
artistVisibility.set(idx, shouldShow);
|
|
artistGroups[idx].visible = shouldShow;
|
|
const checkbox = legendContainer.querySelector(`input[data-idx="${idx}"]`);
|
|
const item = legendContainer.querySelector(`.legend-item[data-idx="${idx}"]`);
|
|
if (checkbox) checkbox.checked = shouldShow;
|
|
if (item) item.classList.toggle('disabled', !shouldShow);
|
|
});
|
|
createConnections();
|
|
}
|
|
|
|
document.getElementById('selectAll').addEventListener('click', () => setAllVisibility(true));
|
|
document.getElementById('selectNone').addEventListener('click', () => setAllVisibility(false));
|
|
document.getElementById('selectMain').addEventListener('click', () => setAllVisibility(false, true));
|
|
|
|
// Raycasting
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2();
|
|
let hoveredArtist = null;
|
|
const tooltip = document.getElementById('tooltip');
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const intersects = raycaster.intersectObjects(artistMeshes);
|
|
|
|
if (intersects.length > 0) {
|
|
const artist = intersects[0].object.userData.artist;
|
|
if (hoveredArtist !== artist) {
|
|
hoveredArtist = artist;
|
|
document.getElementById('tooltip-name').textContent = artist.name;
|
|
document.getElementById('tooltip-name').style.color = '#' + artist.color.toString(16).padStart(6, '0');
|
|
document.getElementById('tooltip-desc').textContent = artist.description || '';
|
|
document.getElementById('tooltip-stats').innerHTML = `
|
|
<span class="stat-label">Valence</span><span class="stat-value">${(artist.position.valence*100).toFixed(0)}%</span>
|
|
<span class="stat-label">Tempo</span><span class="stat-value">${(artist.position.tempo*100).toFixed(0)}%</span>
|
|
<span class="stat-label">Organic</span><span class="stat-value">${(artist.position.organic*100).toFixed(0)}%</span>
|
|
<span class="stat-label">Energy</span><span class="stat-value">${(artist.spikes.energy*100).toFixed(0)}%</span>
|
|
`;
|
|
tooltip.classList.add('visible');
|
|
}
|
|
tooltip.style.left = Math.min(e.clientX + 15, window.innerWidth - 280) + 'px';
|
|
tooltip.style.top = Math.min(e.clientY + 15, window.innerHeight - 180) + 'px';
|
|
document.body.style.cursor = 'pointer';
|
|
} else {
|
|
hoveredArtist = null;
|
|
tooltip.classList.remove('visible');
|
|
document.body.style.cursor = 'default';
|
|
}
|
|
});
|
|
|
|
// Controls
|
|
document.getElementById('autoRotate').addEventListener('change', e => controls.autoRotate = e.target.checked);
|
|
document.getElementById('showGrid').addEventListener('change', e => gridGroup.visible = e.target.checked);
|
|
document.getElementById('showSpikes').addEventListener('change', e => spikeGroups.forEach(g => g.visible = e.target.checked));
|
|
document.getElementById('showConnections').addEventListener('change', e => connectionGroup.visible = e.target.checked);
|
|
document.getElementById('glowIntensity').addEventListener('input', e => bloomPass.strength = parseFloat(e.target.value));
|
|
|
|
const originalPositions = artistGroups.map(g => g.position.clone());
|
|
document.getElementById('spacing').addEventListener('input', e => {
|
|
const scale = parseFloat(e.target.value);
|
|
artistGroups.forEach((g, i) => g.position.copy(originalPositions[i].clone().multiplyScalar(scale)));
|
|
gridGroup.scale.setScalar(scale);
|
|
createConnections();
|
|
});
|
|
|
|
document.getElementById('focusDas').addEventListener('click', () => {
|
|
const das = artistGroups.find(g => g.children[0].userData.artist?.isMain);
|
|
if (das) { controls.target.copy(das.position); camera.position.set(das.position.x + 4, das.position.y + 2, das.position.z + 4); }
|
|
});
|
|
document.getElementById('resetCamera').addEventListener('click', () => { controls.target.set(0, 0, 0); camera.position.set(10, 7, 10); });
|
|
|
|
// Animation
|
|
const clock = new THREE.Clock();
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
const t = clock.getElapsedTime();
|
|
|
|
nebula.rotation.y = t * 0.01;
|
|
innerParticles.rotation.y = -t * 0.02;
|
|
innerParticles.rotation.x = Math.sin(t * 0.1) * 0.1;
|
|
|
|
// Scan planes
|
|
scanPlanes.forEach(p => {
|
|
const pos = Math.sin(t * 0.5 + p.userData.offset) * 3;
|
|
if (p.userData.axis === 0) p.position.y = pos;
|
|
else if (p.userData.axis === 1) p.position.z = pos;
|
|
else p.position.x = pos;
|
|
});
|
|
|
|
// Update orb shaders
|
|
artistMeshes.forEach((m, i) => {
|
|
if (m.userData.material) m.userData.material.uniforms.uTime.value = t;
|
|
m.scale.setScalar(1 + Math.sin(t * 2 + i) * 0.06);
|
|
});
|
|
|
|
// Rotate Das rings and particles
|
|
artistGroups.forEach(g => {
|
|
g.children.forEach(c => {
|
|
if (c.userData.rotSpeed) c.rotation.z += 0.005 * c.userData.rotSpeed;
|
|
if (c.userData.isOrbital) c.rotation.y += 0.01;
|
|
});
|
|
});
|
|
|
|
controls.update();
|
|
composer.render();
|
|
labelRenderer.render(scene, camera);
|
|
}
|
|
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
composer.setSize(window.innerWidth, window.innerHeight);
|
|
labelRenderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
setTimeout(() => document.getElementById('loading').classList.add('hidden'), 600);
|
|
animate();
|
|
console.log('✨ Genre Universe v2 loaded!');
|
|
</script>
|
|
</body>
|
|
</html>
|