Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Cube Data Visualization</title> | |
| <style> | |
| /* | |
| * MODERN CSS RESET & BASE STYLES | |
| */ | |
| :root { | |
| --bg-color: #eeeeee; | |
| --text-color: #000000; | |
| --accent-color: #3b82f6; | |
| --ui-bg: rgba(255, 255, 255, 0.9); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| user-select: none; /* Prevent text selection during interaction */ | |
| -webkit-user-drag: none; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| background-color: var(--bg-color); | |
| color: var(--text-color); | |
| overflow: hidden; /* Prevent scrollbars */ | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| /* | |
| * 3D CANVAS CONTAINER | |
| */ | |
| #canvas-container { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 1; | |
| outline: none; | |
| } | |
| /* | |
| * UI OVERLAY LAYER | |
| */ | |
| #ui-layer { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 2; | |
| pointer-events: none; /* Let clicks pass through to canvas */ | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| } | |
| /* | |
| * HEADER | |
| */ | |
| header { | |
| padding: 20px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| pointer-events: auto; | |
| background: linear-gradient(to bottom, rgba(238,238,238,0.8), transparent); | |
| } | |
| h1 { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| letter-spacing: -0.5px; | |
| opacity: 0.8; | |
| } | |
| .built-with { | |
| font-size: 0.8rem; | |
| color: #555; | |
| text-decoration: none; | |
| transition: color 0.3s ease; | |
| } | |
| .built-with:hover { | |
| color: var(--accent-color); | |
| font-weight: bold; | |
| } | |
| /* | |
| * INFO PANEL (Bottom Left) | |
| */ | |
| .info-panel { | |
| padding: 20px; | |
| pointer-events: auto; | |
| background: var(--ui-bg); | |
| border-radius: 12px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.1); | |
| max-width: 300px; | |
| backdrop-filter: blur(10px); | |
| transform: translateY(20px); | |
| opacity: 0; | |
| animation: slideUp 0.8s ease-out forwards 0.5s; | |
| } | |
| @keyframes slideUp { | |
| to { | |
| transform: translateY(0); | |
| opacity: 1; | |
| } | |
| } | |
| .info-panel h2 { | |
| font-size: 1rem; | |
| margin-bottom: 8px; | |
| } | |
| .info-panel p { | |
| font-size: 0.85rem; | |
| line-height: 1.5; | |
| color: #444; | |
| } | |
| /* | |
| * LOADING OVERLAY | |
| */ | |
| #loader { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: var(--bg-color); | |
| z-index: 10; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| transition: opacity 0.5s ease; | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 4px solid #ccc; | |
| border-top: 4px solid var(--text-color); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin-bottom: 15px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| /* | |
| * ANNOTATION LABELS (CSS 2D Renderer) | |
| */ | |
| .label { | |
| color: #000; | |
| font-family: sans-serif; | |
| font-size: 12px; | |
| font-weight: 500; | |
| background: rgba(255, 255, 255, 0.8); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| pointer-events: none; /* Let clicks pass through */ | |
| white-space: nowrap; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| opacity: 0.7; | |
| transition: opacity 0.2s; | |
| } | |
| .label.hidden { | |
| opacity: 0; | |
| } | |
| </style> | |
| <!-- Import Three.js and Addons via ES Modules --> | |
| <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> | |
| </head> | |
| <body> | |
| <!-- Loading Screen --> | |
| <div id="loader"> | |
| <div class="spinner"></div> | |
| <p>데이터를 로딩 중입니다...</p> | |
| </div> | |
| <!-- 3D Scene Container --> | |
| <div id="canvas-container"></div> | |
| <!-- UI Overlay --> | |
| <div id="ui-layer"> | |
| <header> | |
| <h1>3D Cube Data Points</h1> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="built-with">Built with anycoder</a> | |
| </header> | |
| <div class="info-panel"> | |
| <h2>인터랙션 가이드</h2> | |
| <p>• <strong>회전:</strong> 마우스 드래그</p> | |
| <p>• <strong>줌:</strong> 마우스 휠</p> | |
| <p>• <strong>줌인:</strong> 포인트 클릭</p> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; | |
| // --- Configuration --- | |
| const CONFIG = { | |
| pointCount: 100, | |
| pointColor: 0x000000, | |
| lineColor: 0x000000, | |
| bgColor: 0xeeeeee, | |
| cubeSize: 10, | |
| cameraZ: 25 | |
| }; | |
| // --- Data Generation (100 Unique Animal Names) --- | |
| const animalNames = [ | |
| "Tiger", "Lion", "Bear", "Wolf", "Fox", "Rabbit", "Deer", "Horse", | |
| "Eagle", "Shark", "Whale", "Dolphin", "Panda", "Koala", "Kangaroo", | |
| "Penguin", "Owl", "Falcon", "Eagle", "Crab", "Turtle", "Snake", "Lizard", | |
| "Frog", "Toad", "Cat", "Dog", "Bird", "Fish", "Shrimp", "Cricket", | |
| "Ant", "Bee", "Butterfly", "Dragonfly", "Spider", "Scorpion", "Centipede", | |
| "Mosquito", "Beetle", "Wasp", "Hornet", "Fly", "Moth", "Ladybug", | |
| "Bee", "Honey", "Hive", "Nest", "Honey", "Bee", "Honey", "Bee" | |
| ]; | |
| // Generate unique pairs if needed, or just pick random ones | |
| const uniqueNames = Array.from(new Set(animalNames)).slice(0, CONFIG.pointCount); | |
| // --- Scene Setup --- | |
| const container = document.getElementById('canvas-container'); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(CONFIG.bgColor); | |
| scene.fog = new THREE.Fog(CONFIG.bgColor, 20, 50); | |
| // --- Camera --- | |
| const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(0, 0, CONFIG.cameraZ); | |
| // --- Renderers --- | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.shadowMap.enabled = true; | |
| container.appendChild(renderer.domElement); | |
| const labelRenderer = new CSS2DRenderer(); | |
| labelRenderer.setSize(window.innerWidth, window.innerHeight); | |
| labelRenderer.domElement.style.position = 'absolute'; | |
| labelRenderer.domElement.style.top = '0px'; | |
| labelRenderer.domElement.style.pointerEvents = 'none'; // Crucial for clicks | |
| container.appendChild(labelRenderer.domElement); | |
| // --- Controls --- | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.enablePan = false; | |
| controls.minDistance = 2; // Don't get too close | |
| controls.maxDistance = 50; | |
| controls.autoRotate = true; | |
| controls.autoRotateSpeed = 0.5; | |
| // --- Lighting --- | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| dirLight.position.set(10, 20, 10); | |
| dirLight.castShadow = true; | |
| scene.add(dirLight); | |
| // --- Objects --- | |
| // 1. The Cube (Wireframe) | |
| const geometry = new THREE.BoxGeometry(CONFIG.cubeSize, CONFIG.cubeSize, CONFIG.cubeSize); | |
| const edges = new THREE.EdgesGeometry(geometry); | |
| const lineMaterial = new THREE.LineBasicMaterial({ color: CONFIG.lineColor, linewidth: 2 }); | |
| const wireframeCube = new THREE.LineSegments(edges, lineMaterial); | |
| scene.add(wireframeCube); | |
| // 2. The Points and Labels | |
| const pointsGroup = new THREE.Group(); | |
| scene.add(pointsGroup); | |
| const pointGeometry = new THREE.SphereGeometry(0.15, 16, 16); | |
| const pointMaterial = new THREE.MeshBasicMaterial({ color: CONFIG.pointColor }); | |
| // Helper to calculate position on surface | |
| // We use a simple rejection sampling to place points on the faces of the cube | |
| function getRandomPointOnCube() { | |
| const halfSize = CONFIG.cubeSize / 2; | |
| let x, y, z; | |
| // Randomly choose a face (0: +x, 1: -x, 2: +y, 3: -y, 4: +z, 5: -z) | |
| const face = Math.floor(Math.random() * 6); | |
| switch(face) { | |
| case 0: x = halfSize; y = (Math.random() - 0.5) * CONFIG.cubeSize; z = (Math.random() - 0.5) * CONFIG.cubeSize; break; | |
| case 1: x = -halfSize; y = (Math.random() - 0.5) * CONFIG.cubeSize; z = (Math.random() - 0.5) * CONFIG.cubeSize; break; | |
| case 2: x = (Math.random() - 0.5) * CONFIG.cubeSize; y = halfSize; z = (Math.random() - 0.5) * CONFIG.cubeSize; break; | |
| case 3: x = (Math.random() - 0.5) * CONFIG.cubeSize; y = -halfSize; z = (Math.random() - 0.5) * CONFIG.cubeSize; break; | |
| case 4: x = (Math.random() - 0.5) * CONFIG.cubeSize; y = (Math.random() - 0.5) * CONFIG.cubeSize; z = halfSize; break; | |
| case 5: x = (Math.random() - 0.5) * CONFIG.cubeSize; y = (Math.random() - 0.5) * CONFIG.cubeSize; z = -halfSize; break; | |
| } | |
| return new THREE.Vector3(x, y, z); | |
| } | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| const clickablePoints = []; // Store meshes for raycasting | |
| uniqueNames.forEach((name, index) => { | |
| // Create 3D Point | |
| const pos = getRandomPointOnCube(); | |
| const pointMesh = new THREE.Mesh(pointGeometry, pointMaterial); | |
| pointMesh.position.copy(pos); | |
| pointMesh.userData = { id: index, name: name }; // Store data for interaction | |
| pointsGroup.add(pointMesh); | |
| clickablePoints.push(pointMesh); | |
| // Create HTML Label | |
| const div = document.createElement('div'); | |
| div.className = 'label'; | |
| div.textContent = name; | |
| const label = new CSS2DObject(div); | |
| label.position.set(0, 0.3, 0); // Offset slightly above point | |
| pointMesh.add(label); | |
| }); | |
| // --- Interaction Logic --- | |
| function onMouseClick(event) { | |
| // Calculate mouse position in normalized device coordinates | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| raycaster.setFromCamera(mouse, camera); | |
| // Intersect with our clickable points | |
| const intersects = raycaster.intersectObjects(clickablePoints); | |
| if (intersects.length > 0) { | |
| const target = intersects[0].object; | |
| const targetPos = target.position.clone(); | |
| // Calculate distance to target | |
| const distance = camera.position.distanceTo(targetPos); | |
| // Desired distance (zoom in) | |
| // We want to be closer to the surface of the cube, minus the radius of the point | |
| const targetDistance = CONFIG.cubeSize / 2 + 2; | |
| // Animate Camera Position (Simple Linear Interpolation via TWEEN logic or manual loop) | |
| // Here we use a simple GSAP-like manual tween for zero-dependency | |
| const startPos = camera.position.clone(); | |
| const endPos = targetPos.clone().normalize().multiplyScalar(targetDistance); | |
| let alpha = 0; | |
| const duration = 1000; // ms | |
| const startTime = performance.now(); | |
| function animateZoom(currentTime) { | |
| const elapsed = currentTime - startTime; | |
| alpha = Math.min(elapsed / duration, 1); | |
| // Ease out cubic | |
| const ease = 1 - Math.pow(1 - alpha, 3); | |
| camera.position.lerpVectors(startPos, endPos, ease); | |
| controls.target.lerp(targetPos, ease * 0.5); // Look at target | |
| if (alpha < 1) { | |
| requestAnimationFrame(animateZoom); | |
| } else { | |
| // Stop auto-rotation when zoomed in for better UX | |
| controls.autoRotate = false; | |
| } | |
| } | |
| requestAnimationFrame(animateZoom); | |
| } | |
| } | |
| // --- Event Listeners --- | |
| window.addEventListener('resize', onWindowResize); | |
| window.addEventListener('click', onMouseClick); | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| labelRenderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| // --- Animation Loop --- | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| labelRenderer.render(scene, camera); | |
| } | |
| // --- Init --- | |
| // Hide loader when scene is ready | |
| setTimeout(() => { | |
| document.getElementById('loader').style.opacity = '0'; | |
| setTimeout(() => { | |
| document.getElementById('loader').style.display = 'none'; | |
| }, 500); | |
| animate(); | |
| }, 1000); | |
| </script> | |
| </body> | |
| </html> |